sw-test-env
Version:
A sandboxed ServiceWorker environment for testing
920 lines (888 loc) • 25.3 kB
JavaScript
// src/createContext.js
import fetch2, { Headers, Request as Request3, Response } from "node-fetch";
// src/api/Cache.js
import fetch, { Request } from "node-fetch";
var Cache = class {
constructor(cacheName, origin = "http://localhost:3333") {
this.name = cacheName;
this._items = /* @__PURE__ */ new Map();
this._origin = origin;
}
match(request, options = {}) {
const results = this._match(request, options);
return Promise.resolve(results.length ? results[0][1] : void 0);
}
matchAll(request, options = {}) {
const results = this._match(request, options);
return Promise.resolve(results.map((result) => result[1]));
}
add(request) {
request = this._normalizeRequest(request);
return fetch(request.url).then((response) => {
if (!response.ok) {
throw new TypeError("bad response status");
}
return this.put(request, response);
});
}
addAll(requests) {
return Promise.all(requests.map((request) => this.add(request)));
}
put(request, response) {
const existing = this._match(request, { ignoreVary: true })[0];
if (existing) {
request = existing[0];
}
request = this._normalizeRequest(request);
this._items.set(request, response);
return Promise.resolve();
}
delete(request, options = {}) {
const results = this._match(request, options);
let success = false;
results.forEach(([req]) => {
const s = this._items.delete(req);
if (s) {
success = s;
}
});
return Promise.resolve(success);
}
keys(request, options = {}) {
if (!request) {
return Promise.resolve(Array.from(this._items.keys()));
}
const results = this._match(request, options);
return Promise.resolve(results.map(([req]) => req));
}
_match(request, { ignoreSearch = false, ignoreMethod = false }) {
request = this._normalizeRequest(request);
const results = [];
const url = new URL(request.url);
const pathname = this._normalizePathname(url.pathname);
let method = request.method;
let search = url.search;
if (ignoreSearch) {
search = null;
}
if (ignoreMethod) {
method = null;
}
this._items.forEach((res, req) => {
const u = new URL(req.url);
const s = ignoreSearch ? null : u.search;
const m = ignoreMethod ? null : req.method;
const p = this._normalizePathname(u.pathname);
if (p && p === pathname && m === method && s === search) {
results.push([req, res]);
}
});
return results;
}
_normalizeRequest(request) {
if (typeof request === "string") {
request = new Request(new URL(request, this._origin).href);
}
return request;
}
_normalizePathname(pathname) {
return pathname.charAt(0) !== "/" ? `/${pathname}` : pathname;
}
_destroy() {
this._items.clear();
}
};
// src/api/CacheStorage.js
var CacheStorage = class {
constructor(origin) {
this._caches = /* @__PURE__ */ new Map();
this._origin = origin;
}
has(cacheName) {
return Promise.resolve(this._caches.has(cacheName));
}
open(cacheName) {
let cache = this._caches.get(cacheName);
if (!cache) {
cache = new Cache(cacheName, this._origin);
this._caches.set(cacheName, cache);
}
return Promise.resolve(cache);
}
match(request, options = {}) {
if (options.cacheName) {
const cache = this._caches.get(options.cacheName);
if (!cache) {
return Promise.reject(Error(`cache with name '${options.cacheName}' not found`));
}
return cache.match(request, options);
}
for (const cache of this._caches.values()) {
const results = cache._match(request, options);
if (results.length) {
return Promise.resolve(results[0][1]);
}
}
return Promise.resolve(void 0);
}
keys() {
return Promise.resolve(Array.from(this._caches.keys()));
}
delete(cacheName) {
return Promise.resolve(this._caches.delete(cacheName));
}
_destroy() {
this._caches.clear();
}
};
// src/api/Client.js
var uid = 0;
var Client = class {
constructor(url, postMessage) {
this.id = String(++uid);
this.type = "";
this.url = url;
this.postMessage = postMessage || function() {
};
}
};
// src/api/WindowClient.js
var WindowClient = class extends Client {
constructor(url, postMessage) {
super(url, postMessage);
this.type = "window";
this.focused = false;
this.visibilityState = "hidden";
}
async focus() {
this.focused = true;
this.visibilityState = "visible";
return this;
}
async navigate(url) {
this.url = url;
return this;
}
};
// src/api/Clients.js
var Clients = class {
constructor() {
this._clients = [];
}
async get(id) {
return this._clients.find((client) => client.id === id);
}
async matchAll({ type = "any" } = {}) {
return this._clients.filter((client) => type === "any" || client.type === type);
}
async openWindow(url) {
const client = new WindowClient(url);
this._clients.push(client);
return client;
}
claim() {
return Promise.resolve();
}
_connect(url, postMessage) {
this._clients.push(new Client(url, postMessage));
}
_destroy() {
this._clients = [];
}
};
// src/createContext.js
import { createRequire } from "module";
// src/api/events/ExtendableEvent.js
var ExtendableEvent = class extends Event {
constructor(type, init) {
super(type);
this.promise;
}
waitUntil(promise) {
this.promise = promise;
}
};
// src/createContext.js
import fakeIndexedDB from "fake-indexeddb/build/fakeIndexedDB.js";
import FDBCursor from "fake-indexeddb/build/FDBCursor.js";
import FDBCursorWithValue from "fake-indexeddb/build/FDBCursorWithValue.js";
import FDBDatabase from "fake-indexeddb/build/FDBDatabase.js";
import FDBFactory from "fake-indexeddb/build/FDBFactory.js";
import FDBIndex from "fake-indexeddb/build/FDBIndex.js";
import FDBKeyRange from "fake-indexeddb/build/FDBKeyRange.js";
import FDBObjectStore from "fake-indexeddb/build/FDBObjectStore.js";
import FDBOpenDBRequest from "fake-indexeddb/build/FDBOpenDBRequest.js";
import FDBRequest from "fake-indexeddb/build/FDBRequest.js";
import FDBTransaction from "fake-indexeddb/build/FDBTransaction.js";
import FDBVersionChangeEvent from "fake-indexeddb/build/FDBVersionChangeEvent.js";
// src/api/events/FetchEvent.js
import { contentType } from "mime-types";
import path from "path";
import { Request as Request2 } from "node-fetch";
var FetchEvent = class extends ExtendableEvent {
constructor(type, origin, { clientId, request, resultingClientId, replacesClientId, preloadResponse }) {
super(type);
this.request = request;
this.preloadResponse = preloadResponse ?? Promise.resolve(void 0);
this.clientId = clientId ?? "";
this.resultingClientId = resultingClientId ?? "";
this.replacesClientId = replacesClientId ?? "";
let url;
let requestInit;
if (typeof request === "string") {
url = new URL(request, origin);
const ext = path.extname(url.pathname) || ".html";
const accept = contentType(ext) || "*/*";
requestInit = {
headers: {
accept
}
};
} else {
const { body, headers = {}, method = "GET", redirect = "follow" } = request;
url = new URL(request.url, origin);
requestInit = {
body,
headers,
method,
redirect
};
}
this.request = new Request2(url.href, requestInit);
}
respondWith(promise) {
this.promise = promise;
}
};
// src/createContext.js
import FormData from "form-data";
// src/api/events/EventTarget.js
var EventTarget = class {
constructor() {
this.listeners = {};
}
addEventListener(eventType, listener) {
if (!this.listeners[eventType]) {
this.listeners[eventType] = [];
}
this.listeners[eventType].push(listener);
}
removeEventListener(eventType, listener) {
if (!this.listeners[eventType]) {
return;
}
this.listeners[eventType].splice(this.listeners[eventType].indexOf(listener), 1);
}
removeAllEventListeners() {
this.listeners = {};
}
dispatchEvent(event) {
return false;
}
};
// src/api/events/ErrorEvent.js
var ErrorEvent = class extends ExtendableEvent {
constructor(type, error) {
super(type);
this.message = (error == null ? void 0 : error.message) ?? "Error";
this.error = error;
this.promise = Promise.resolve(error);
}
};
// src/api/events/MessageEvent.js
var MessageEvent = class extends ExtendableEvent {
constructor(type, init = {}) {
super(type);
this.data = init.data ?? null;
this.origin = init.origin ?? "";
this.source = init.source ?? null;
this.ports = init.ports ?? [];
}
};
// src/api/events/NotificationEvent.js
var NotificationEvent = class extends ExtendableEvent {
constructor(type, notification) {
super(type);
this.notification = notification;
}
};
// src/events.js
function create(target, type, ...args) {
let event;
switch (type) {
case "error":
event = new ErrorEvent("error", args[0]);
break;
case "fetch":
event = new FetchEvent(type, ...args);
break;
case "message":
event = new MessageEvent(type, args[0]);
break;
case "notificationclick":
return new NotificationEvent(type, args[0]);
default:
event = new ExtendableEvent(type);
}
Object.defineProperty(event, "target", {
value: target,
writable: false
});
return event;
}
function handle(target, type, ...args) {
var _a;
const listeners = ((_a = target.listeners[type]) == null ? void 0 : _a.slice()) || [];
const onevent = target[`on${type}`];
if (onevent) {
listeners.push(onevent);
}
if ((type === "error" || type === "unhandledrejection") && !listeners.length) {
throw args[0] || Error(`unhandled error of type ${type}`);
}
if (listeners.length === 1) {
return doHandle(target, listeners[0], type, args);
}
return Promise.all(listeners.map((listener) => doHandle(target, listener, type, args)));
}
function doHandle(target, listener, type, args) {
const event = create(target, type, ...args);
listener(event);
return event.promise ?? Promise.resolve();
}
// src/api/MessagePort.js
var MessagePort = class extends EventTarget {
constructor(otherPort) {
super();
this._otherPort = otherPort;
this.onmessage;
}
postMessage(message, transferList = []) {
const ports = transferList.filter((item) => item instanceof MessagePort);
if (this._otherPort) {
handle(this._otherPort, "message", { data: message, ports });
}
}
start() {
}
close() {
}
};
// src/api/MessageChannel.js
var MessageChannel = class {
constructor() {
this.port1 = new MessagePort();
this.port2 = new MessagePort(this.port1);
}
};
// src/api/Notification.js
var Notification2 = class extends EventTarget {
constructor(title, options) {
super();
this.title = title;
Object.assign(this, options);
}
onclick() {
}
close() {
}
};
Notification2.maxActions = 16;
Notification2.permission = "default";
Notification2.requestPermission = async () => {
Notification2.permission = "granted";
return Notification2.permission;
};
// src/api/PushMessageData.js
import { Blob } from "buffer";
var PushMessageData = class {
constructor(data) {
this._data = data ?? {};
}
arrayBuffer() {
return new ArrayBuffer(20);
}
blob() {
return new Blob([this.text()]);
}
json() {
return this._data;
}
text() {
return JSON.stringify(this._data);
}
};
// src/api/events/PushEvent.js
var PushEvent = class extends ExtendableEvent {
constructor(type, init = {}) {
super(type);
this.data = new PushMessageData(init.data);
}
};
// src/api/PushSubscription.js
var PushSubscription = class {
constructor(options) {
this.endpoint = "test";
this.expirationTime = null;
this.options = options;
this._keys = {
p256dh: new ArrayBuffer(65),
auth: new ArrayBuffer(16),
applicationServerKey: new ArrayBuffer(87)
};
}
getKey(name) {
return this._keys[name];
}
toJSON() {
return {
endpoint: this.endpoint,
expirationTime: this.expirationTime,
options: this.options
};
}
unsubscribe() {
return Promise.resolve(true);
}
};
// src/api/PushManager.js
var PushManager = class {
constructor() {
this.subscription = new PushSubscription({
userVisibleOnly: true,
applicationServerKey: "BCnKOeg_Ly8MuvV3CIn21OahjnOOq8zeo_J0ojOPMD6RhxruIVFpLZzPi0huCn45aLq8RcHjOIMol0ytRhgAu8k"
});
}
getSubscription() {
return Promise.resolve(this.subscription);
}
permissionState(options) {
return Promise.resolve("granted");
}
subscribe(options) {
return Promise.resolve(this.subscription);
}
_destroy() {
this.subscription = void 0;
}
};
// src/api/ServiceWorkerGlobalScope.js
var ServiceWorkerGlobalScope = class extends EventTarget {
static [Symbol.hasInstance](instance) {
return instance.registration && instance.caches && instance.clients;
}
constructor(registration, origin, skipWaiting) {
super();
this.caches = new CacheStorage(origin);
this.clients = new Clients();
this.registration = registration;
this.skipWaiting = skipWaiting.bind(this);
this.oninstall;
this.onactivate;
this.onfetch;
this.onmessage;
this.onerror;
}
_destroy() {
this.caches._destroy();
this.clients._destroy();
this.removeAllEventListeners();
}
};
// src/api/ContentIndex.js
var ContentIndex = class {
constructor() {
this._items = /* @__PURE__ */ new Map();
}
async add(description) {
this._items.set(description.id, description);
}
async delete(id) {
this._items.delete(id);
}
async getAll() {
return Array.from(this._items.values());
}
};
// src/api/NavigationPreloadManager.js
var NavigationPreloadManager = class {
constructor() {
this.enabled = false;
this.headerValue = "Service-Worker-Navigation-Preload";
}
async enable() {
this.enabled = true;
return;
}
async disable() {
this.enabled = false;
return;
}
async setHeaderValue(headerValue) {
this.headerValue = headerValue;
}
async getState() {
return { enabled: this.enabled, headerValue: this.headerValue };
}
};
// src/api/ServiceWorkerRegistration.js
var ServiceWorkerRegistration = class extends EventTarget {
constructor(scope, unregister2) {
super();
this.unregister = unregister2;
this.scope = scope;
this.index = new ContentIndex();
this.pushManager = new PushManager();
this.navigationPreload = new NavigationPreloadManager();
this.onupdatefound;
this.installing = null;
this.waiting = null;
this.activating = null;
this.active = null;
this._notifications = /* @__PURE__ */ new Set();
}
async getNotifications(options) {
const notifications = Array.from(this._notifications);
if ((options == null ? void 0 : options.tag) && options.tag.length > 0) {
return notifications.filter((notification) => notification.tag ? notification.tag === options.tag : false);
}
return notifications;
}
async showNotification(title, options) {
const notification = new Notification(title, options);
this._notifications.add(notification);
notification.close = () => {
this._notifications.delete(notification);
};
}
update() {
}
_destroy() {
this.index = void 0;
this.pushManager._destroy();
this.pushManager = void 0;
this.navigationPreload = void 0;
this.installing = this.waiting = this.activating = this.active = null;
this.removeAllEventListeners();
}
};
// src/createContext.js
function createContext(globalScope, contextlocation, contextpath, origin) {
const context = Object.assign(globalScope, {
Cache,
CacheStorage,
Client,
Clients,
ExtendableEvent,
FetchEvent,
FormData,
Headers,
IDBCursor: FDBCursor,
IDBCursorWithValue: FDBCursorWithValue,
IDBDatabase: FDBDatabase,
IDBFactory: FDBFactory,
IDBIndex: FDBIndex,
IDBKeyRange: FDBKeyRange,
IDBObjectStore: FDBObjectStore,
IDBOpenDBRequest: FDBOpenDBRequest,
IDBRequest: FDBRequest,
IDBTransaction: FDBTransaction,
IDBVersionChangeEvent: FDBVersionChangeEvent,
MessageChannel,
MessageEvent,
MessagePort,
navigator: {
connection: "not implemented",
online: true,
permissions: "not implemented",
storage: "not implemented",
userAgent: "sw-test-env"
},
Notification: Notification2,
NotificationEvent,
PushEvent,
PushManager,
PushSubscription,
Request: Request3,
Response,
ServiceWorkerGlobalScope,
ServiceWorkerRegistration,
URL,
console,
clearImmediate,
clearInterval,
clearTimeout,
fetch: fetch2,
indexedDB: fakeIndexedDB,
location: contextlocation,
origin,
process,
require: createRequire(contextpath),
setImmediate,
setTimeout,
setInterval,
self: globalScope
});
return context;
}
// src/index.js
import esbuild from "esbuild";
import path2 from "path";
// src/api/ServiceWorker.js
var ServiceWorker = class extends EventTarget {
constructor(scriptURL, postMessage) {
super();
this.scriptURL = scriptURL;
this.self;
this.state = "installing";
this.postMessage = postMessage;
this.onstatechange;
}
_destroy() {
this.self._destroy();
this.self = void 0;
}
};
// src/api/ServiceWorkerContainer.js
var ServiceWorkerContainer = class extends EventTarget {
constructor(href, webroot, register2, trigger2) {
super();
this.controller = null;
this.__serviceWorker__;
this._registration;
this._href = href;
this._webroot = webroot;
this.register = register2.bind(this, this);
this.trigger = trigger2.bind(this, this, href);
this.onmessage;
}
get ready() {
if (!this._registration) {
throw Error("no script registered yet");
}
if (this.controller) {
return Promise.resolve(this._registration);
}
return this.trigger("install").then(() => this.trigger("activate")).then(() => this._registration);
}
getRegistration(scope) {
return Promise.resolve(this._registration);
}
getRegistrations() {
return Promise.resolve([this._registration]);
}
startMessages() {
}
_destroy() {
this.controller = this._registration = this.__serviceWorker__ = void 0;
}
};
// src/index.js
import vm from "vm";
import { Headers as Headers2, Request as Request4, Response as Response2 } from "node-fetch";
var DEFAULT_ORIGIN = "http://localhost:3333/";
var DEFAULT_SCOPE = "/";
var containers = /* @__PURE__ */ new Set();
var contexts = /* @__PURE__ */ new Map();
async function connect(origin = DEFAULT_ORIGIN, webroot = process.cwd()) {
if (!origin.endsWith("/")) {
origin += "/";
}
const container = new ServiceWorkerContainer(origin, webroot, register, trigger);
containers.add(container);
return container;
}
async function destroy() {
for (const container of containers) {
container._destroy();
}
for (const context of contexts.values()) {
context.registration._destroy();
context.sw._destroy();
}
containers.clear();
contexts.clear();
}
async function register(container, scriptURL, { scope = DEFAULT_SCOPE } = {}) {
if (scriptURL.charAt(0) == "/") {
scriptURL = scriptURL.slice(1);
}
const origin = getOrigin(container._href);
const scopeHref = new URL(scope, origin).href;
const webroot = container._webroot;
let context = contexts.get(scopeHref);
if (!context) {
const contextPath = getResolvedPath(webroot, scriptURL);
const contextLocation = new URL(scriptURL, origin);
const registration = new ServiceWorkerRegistration(scopeHref, unregister.bind(null, scopeHref));
const globalScope = new ServiceWorkerGlobalScope(registration, origin, async () => {
if (registration.waiting !== null) {
trigger(container, origin, "activate");
}
});
const sw = new ServiceWorker(scriptURL, swPostMessage.bind(null, container, origin));
const scriptPath = isRelativePath(scriptURL) ? path2.resolve(webroot, scriptURL) : scriptURL;
try {
const bundledSrc = esbuild.buildSync({
bundle: true,
entryPoints: [scriptPath],
format: "cjs",
target: "node16",
platform: "node",
write: false
});
const vmContext = createContext(globalScope, contextLocation, contextPath, origin);
const sandbox = vm.createContext(vmContext);
vm.runInContext(bundledSrc.outputFiles[0].text, sandbox);
sw.self = sandbox;
context = {
registration,
sw
};
contexts.set(scopeHref, context);
} catch (err) {
throw err.message.includes("importScripts") ? Error('"importScripts" not supported in esm ServiceWorker. Use esm "import" statement instead') : err;
}
}
for (const container2 of getContainersForUrlScope(scopeHref)) {
container2._registration = context.registration;
container2.__serviceWorker__ = context.sw;
context.sw.self.clients._connect(container2._href, clientPostMessage.bind(null, container2));
}
return container._registration;
}
function unregister(contextKey) {
const context = contexts.get(contextKey);
if (!context) {
return Promise.resolve(false);
}
for (const container of getContainersForContext(context)) {
container._destroy();
}
context.registration._destroy();
context.sw._destroy();
contexts.delete(contextKey);
return Promise.resolve(true);
}
function clientPostMessage(container, message, transferList) {
handle(container, "message", { data: message, source: container.controller, ports: transferList });
}
function swPostMessage(container, origin, message, transferList) {
trigger(container, origin, "message", { data: message, origin, ports: transferList });
}
async function trigger(container, origin, eventType, ...args) {
const context = getContextForContainer(container);
if (!context) {
throw Error("no script registered yet");
}
const containers2 = getContainersForContext(context);
switch (eventType) {
case "install":
setState("installing", context, containers2);
break;
case "activate":
setState("activating", context, containers2);
break;
case "fetch":
args.unshift(origin);
break;
default:
}
const result = await handle(context.sw.self, eventType, ...args);
switch (eventType) {
case "install":
setState("installed", context, containers2);
break;
case "activate":
setState("activated", context, containers2);
break;
default:
}
return result;
}
function setState(state, context, containers2) {
switch (state) {
case "installing":
if (context.sw.state !== "installing") {
return;
}
context.registration.installing = context.sw;
setControllerForContainers(null, containers2);
handle(context.registration, "updatefound");
break;
case "installed":
context.sw.state = state;
context.registration.installing = null;
context.registration.waiting = context.sw;
handle(context.sw, "statechange");
break;
case "activating":
if (!context.sw.state.includes("install")) {
throw Error("ServiceWorker not yet installed");
}
context.sw.state = state;
context.registration.activating = context.sw;
setControllerForContainers(null, containers2);
handle(context.sw, "statechange");
break;
case "activated":
context.sw.state = state;
context.registration.waiting = null;
context.registration.active = context.sw;
setControllerForContainers(context.sw, containers2);
handle(context.sw, "statechange");
break;
default:
if (context.sw.state !== "activated") {
throw Error("ServiceWorker not yet active");
}
}
}
function setControllerForContainers(controller, containers2) {
for (const container of containers2) {
container.controller = controller;
}
}
function getContainersForContext(context) {
const results = [];
for (const container of containers) {
if (container.__serviceWorker__ === context.sw) {
results.push(container);
}
}
return results;
}
function getContainersForUrlScope(urlScope) {
const results = [];
for (const container of containers) {
if (container._href.indexOf(urlScope) === 0) {
results.push(container);
}
}
return results;
}
function getContextForContainer(container) {
for (const context of contexts.values()) {
if (context.sw === container.__serviceWorker__) {
return context;
}
}
}
function getOrigin(urlString) {
const parsedUrl = new URL(urlString);
return `${parsedUrl.protocol}//${parsedUrl.host}`;
}
function getResolvedPath(contextPath, p) {
return isRelativePath(p) ? path2.resolve(contextPath, p) : p;
}
function isRelativePath(p) {
return !path2.isAbsolute(p);
}
export {
Headers2 as Headers,
MessageChannel,
Request4 as Request,
Response2 as Response,
connect,
destroy
};