UNPKG

sw-test-env

Version:

A sandboxed ServiceWorker environment for testing

920 lines (888 loc) 25.3 kB
// 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 };