diff --git a/lib/web/service-worker/client.js b/lib/web/service-worker/client.js new file mode 100644 index 00000000000..db16ddf3d29 --- /dev/null +++ b/lib/web/service-worker/client.js @@ -0,0 +1,46 @@ +/** + * @typedef {'???'} ClientFrameType @todo + * @typedef {'main' | 'worker'} ClientType + */ + +/** + * @typedef {Object} SerializedClient + * @property {string} id + * @property {string} url + * @property {ClientType} type + * @property {ClientFrameType} frameType + */ + +/** + * @see https://w3c.github.io/ServiceWorker/#client-interface + */ +export class Client { + /** + * @type {MessagePort['postMessage']} + */ + postMessage = () => { + throw new Error( + 'Failed to call Client#postMessage: the "postMessage" method is not implemented' + ) + } + + /** + * + * @param {string} id + * @param {string} url + * @param {ClientType} type + * @param {ClientFrameType} frameType + */ + constructor (id, url, type, frameType) { + /** @type {string} id */ + this.id = id + /** @type {string} url */ + this.url = url + /** @type {ClientType} type */ + this.type = type + /** @type {ClientFrameType} frameType */ + this.frameType = frameType + } + + /** @todo Finish the implementation. */ +} diff --git a/lib/web/service-worker/clients.js b/lib/web/service-worker/clients.js new file mode 100644 index 00000000000..4d78bb543ae --- /dev/null +++ b/lib/web/service-worker/clients.js @@ -0,0 +1,95 @@ +import { isWithinScope } from './utils/isWithinScope.js' + +/** + * @typedef {Object} MatchAllOptions + * @property {boolean} [includeUncontrolled] + * @property {import('./client.js').ClientType} [type] + */ + +export const kAddClient = Symbol('kAddClient') + +export class Clients { + /** @type {import('./service-worker.js').ServiceWorker} */ + #serviceWorker + + /** @type {Map} */ + #clients + + /** + * @param {import('./service-worker.js').ServiceWorker} serviceWorker + */ + constructor (serviceWorker) { + this.#serviceWorker = serviceWorker + } + + /** + * Internal method to add the given client to the list of clients. + * @param {Client} client + * @returns {void} + */ + [kAddClient] (client) { + this.#clients.set(client.id, client) + } + + /** + * @param {string} id + * @returns {Promise} + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Clients/get + */ + async get (id) { + return this.#clients.get(id) + } + + /** + * @param {MatchAllOptions | undefined} options + * @returns {Promise} + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Clients/matchAll + */ + async matchAll (options) { + /** @type {Array} */ + const clients = [] + + for (const [, client] of this.#clients) { + if (options?.type && client.type !== options.type) { + break + } + + if (options?.includeUncontrolled) { + break + } + + clients.push(client) + } + + return clients + } + + /** + * Set the current Service Worker as the controller + * for all the clients within its scope. + * @returns {Promise} + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim + */ + async claim () { + for (const [, client] of this.#clients) { + if (isWithinScope(client.url, this.#serviceWorker.scope)) { + /** + * @todo Set the current worker as the controller + * for all the clients that lie within its scope. + */ + } + } + } + + /** + * @returns {Promise} + * + * @see https://w3c.github.io/ServiceWorker/#clients-openwindow + */ + async openWindow () { + // Browser-specific method, do nothing for compatibility. + } +} diff --git a/lib/web/service-worker/extendable-event.js b/lib/web/service-worker/extendable-event.js new file mode 100644 index 00000000000..d18ad715800 --- /dev/null +++ b/lib/web/service-worker/extendable-event.js @@ -0,0 +1,39 @@ +export const kPendingPromises = Symbol('kPendingPromises') + +/** + * @see https://w3c.github.io/ServiceWorker/#extendableevent-interface + */ +export class ExtendableEvent extends Event { + /** @type {Array>} */ + [kPendingPromises] + + /** @type {number} */ + #pendingPromiseCount + + /** + * @param {string} type + * @param {EventInit} eventInitDict + */ + constructor (type, eventInitDict) { + super(type, eventInitDict) + this[kPendingPromises] = [] + this.#pendingPromiseCount = 0 + } + + /** + * @param {Promise} promise + * @returns {void} + * + * @see https://w3c.github.io/ServiceWorker/#wait-until-method + */ + waitUntil (promise) { + this[kPendingPromises].push(promise) + this.#pendingPromiseCount++ + + promise.finally(() => { + queueMicrotask(() => { + this.#pendingPromiseCount-- + }) + }) + } +} diff --git a/lib/web/service-worker/fetch-event.js b/lib/web/service-worker/fetch-event.js new file mode 100644 index 00000000000..fb46d49188f --- /dev/null +++ b/lib/web/service-worker/fetch-event.js @@ -0,0 +1,113 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { ExtendableEvent } from './extendable-event.js' +import { InvalidStateError } from './utils/errors.js' + +/** + * @typedef {Object} FetchEventInit + * @property {Request} request + * @property {Promise} [preloadResponse] + * @property {string} [clientId] + * @property {string} [resultingClientId] + * @property {string} [replacesClientId] + * @property {Promise} [handled] + */ + +const kRespondWithEntered = Symbol('kRespondWithEntered') +const kWaitToRespond = Symbol('kWaitToRespond') +const kRespondWithError = Symbol('kRespondWithError') +export const kResponsePromise = Symbol('kResponsePromise') + +/** + * @see https://w3c.github.io/ServiceWorker/#fetchevent-interface + */ +export class FetchEvent extends ExtendableEvent { + /** @type {string} */ + clientId + /** @type {Request} */ + request + /** @type {Promise} */ + preloadResponse + /** @type {string} */ + resultingClientId + /** @type {string} */ + replacesClientId + /** @type {Promise;} */ + handled; + + /** @type {boolean} */ + [kRespondWithEntered]; + /** @type {boolean} */ + [kWaitToRespond]; + /** @type {boolean} */ + [kRespondWithError]; + /** @type {DeferredPromise} */ + [kResponsePromise] + + /** + * + * @param {string} type + * @param {FetchEventInit} [options] + */ + constructor (type, options) { + super(type, options) + this.clientId = options.clientId || '' + this.request = options.request + this.preloadResponse = options.preloadResponse || Promise.resolve() + this.resultingClientId = options.resultingClientId || '' + this.replacesClientId = options.replacesClientId || '' + this.handled = options.handled || new DeferredPromise() + + this[kResponsePromise] = new DeferredPromise() + } + + /** + * @param {Response | Promise} response + * @returns {Promise} + * + * @see https://w3c.github.io/ServiceWorker/#fetch-event-respondwith + */ + async respondWith (response) { + if (this[kRespondWithEntered]) { + throw new InvalidStateError('Cannot call respondWith() multiple times') + } + + const innerResponse = Promise.resolve(response) + this.waitUntil(innerResponse) + + // This flag is never unset because a single FetchEvent + // can be responded to only once. + this[kRespondWithEntered] = true + this[kWaitToRespond] = true + + /** + * @note This is a simplified implementation of the spec. + */ + innerResponse + .then((response) => { + // Returning non-Response from ".respondWith()" + // results in a network error. + if (!(response instanceof Response)) { + this[kRespondWithError] = true + this.#handleFetch(Response.error()) + } else { + this.#handleFetch(response) + } + + this[kWaitToRespond] = undefined + }) + .catch(() => { + this[kRespondWithError] = true + this[kWaitToRespond] = undefined + }) + } + + /** + * Resolve the pending response promise with the given response. + * This is used internally. + * @param {Response} response + * @returns {void} + */ + #handleFetch (response) { + this[kResponsePromise].resolve(response) + } +} diff --git a/lib/web/service-worker/index.js b/lib/web/service-worker/index.js new file mode 100644 index 00000000000..aca83b4e50b --- /dev/null +++ b/lib/web/service-worker/index.js @@ -0,0 +1,3 @@ +import { ServiceWorkerContainer } from './service-worker-container.js' + +export const serviceWorker = new ServiceWorkerContainer() diff --git a/lib/web/service-worker/install-event.js b/lib/web/service-worker/install-event.js new file mode 100644 index 00000000000..a273882ec88 --- /dev/null +++ b/lib/web/service-worker/install-event.js @@ -0,0 +1,8 @@ +import { ExtendableEvent } from './extendable-event.js' + +/** + * @see https://w3c.github.io/ServiceWorker/#installevent-interface + */ +export class InstallEvent extends ExtendableEvent { + /** @todo Finish the implementation (`addRoutes`) */ +} diff --git a/lib/web/service-worker/interceptor.js b/lib/web/service-worker/interceptor.js new file mode 100644 index 00000000000..d62a0c904a7 --- /dev/null +++ b/lib/web/service-worker/interceptor.js @@ -0,0 +1,13 @@ +import { BatchInterceptor } from '@mswjs/interceptors' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +export const interceptor = new BatchInterceptor({ + name: 'node-service-worker', + interceptors: [ + new ClientRequestInterceptor(), + new FetchInterceptor(), + new XMLHttpRequestInterceptor() + ] +}) diff --git a/lib/web/service-worker/service-worker-container.js b/lib/web/service-worker/service-worker-container.js new file mode 100644 index 00000000000..06d648dad48 --- /dev/null +++ b/lib/web/service-worker/service-worker-container.js @@ -0,0 +1,201 @@ +import { Worker, MessageChannel } from 'node:worker_threads' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { ServiceWorkerRegistration } from './service-worker-registration.js' +import { ServiceWorker } from './service-worker.js' +import { parseModuleUrlFromStackTrace } from './utils/parseModuleUrlFromStackTrace.js' +import { interceptor } from './interceptor.js' + +/** + * @typedef {Object} WorkerData + * @property {string} scriptUrl + * @property {unknown} options @todo + * @property {import('./client.js').SerializedClient} clientInfo + * @property {MessagePort} clientMessagePort + * @property {MessagePort} interceptorMessagePort + */ + +const clientMessageChannel = new MessageChannel() +const interceptorMessageChannel = new MessageChannel() + +/** + * @see https://w3c.github.io/ServiceWorker/#serviceworkercontainer-interface + */ +export class ServiceWorkerContainer { + /** @type {ServiceWorkerRegistration | undefined} */ + #registration + /** @type {DeferredPromise} */ + #ready + + /** @todo Event handlers (oncontrollerchange, onmessage, onerror) */ + + constructor () { + this.#ready = new DeferredPromise() + } + + /** + * @returns {Promise} + * + * @see https://w3c.github.io/ServiceWorker/#navigator-service-worker-ready + */ + get ready () { + return this.#ready + } + + /** + * @returns {ServiceWorker | null} + * + * @see https://w3c.github.io/ServiceWorker/#navigator-service-worker-controller + */ + get controller () { + return this.#registration?.active || null + } + + /** + * @param {string} scriptUrl + * @param {ServiceWorkerRegistrationOptions} [options] + * @returns {Promise} + * + * @see https://w3c.github.io/ServiceWorker/#navigator-service-worker-register + */ + async register (scriptUrl, options = {}) { + /** @type {SerializedClient} */ + const clientInfo = { + id: process.pid.toString(), + url: parseModuleUrlFromStackTrace(new Error()), + type: 'worker', + frameType: '???' /** @todo */ + } + + const worker = new Worker(new URL('./worker.ts', import.meta.url), { + name: `[worker ${scriptUrl}]`, + workerData: { + scriptUrl, + options, + clientInfo, + clientMessagePort: clientMessageChannel.port2, + interceptorMessagePort: interceptorMessageChannel.port2 + }, + transferList: [ + clientMessageChannel.port2, + interceptorMessageChannel.port2 + ] + }) + + const serviceWorker = this.#createServiceWorker(scriptUrl, worker) + const registration = new ServiceWorkerRegistration(serviceWorker) + this.#registration = registration + + serviceWorker.addEventListener('statechange', () => { + if (serviceWorker.state === 'activating') { + this.#ready.resolve(registration) + } + }) + + this.#enableRequestInterception(interceptorMessageChannel) + + return registration + } + + /** + * @param {string} clientUrl + * @returns {Promise} + * + * @see https://w3c.github.io/ServiceWorker/#navigator-service-worker-getRegistration + */ + getRegistration (clientUrl) { + throw new Error('Not implemented') + } + + /** + * @returns {Promise>} + * + * @see https://w3c.github.io/ServiceWorker/#navigator-service-worker-getRegistrations + */ + getRegistrations () { + throw new Error('Not implemented') + } + + /** + * @param {string} scriptUrl + * @param {Worker} worker + * @returns {ServiceWorker} + */ + #createServiceWorker (scriptUrl, worker) { + const serviceWorker = new ServiceWorker( + scriptUrl, + worker.postMessage.bind(worker) + ) + + // Listen to the Service Worker signaling its events + // and update the main thread Service Worker instance accordingly. + worker.addListener('message', (message) => { + switch (message.type) { + case 'worker/statechange': { + serviceWorker.state = message.state + break + } + case 'worker/error': { + serviceWorker.dispatchEvent(new Event('error')) + break + } + } + }) + + // Forward the messages sent via `client.postMessage()` in the worker + // directly to the Service Worker interface. + clientMessageChannel.port1.addListener('message', (data) => { + serviceWorker.dispatchEvent(new MessageEvent('message', { data })) + }) + + return serviceWorker + } + + /** + * @param {MessageChannel} channel + * @returns {void} + */ + #enableRequestInterception (channel) { + interceptor.apply() + + interceptor.on('request', async ({ requestId, request }) => { + const requestBody = await request.arrayBuffer() + channel.port1.postMessage( + { + type: 'request', + requestId, + request: { + method: request.method, + url: request.url, + headers: Object.fromEntries(request.headers.entries()), + body: + request.method === 'HEAD' || request.method === 'GET' + ? null + : requestBody + } + }, + [requestBody] + ) + + const responsePromise = new DeferredPromise() + + const responseListener = (data) => { + if (requestId === data.requestId) { + /** @todo Response may also be undefined */ + const response = new Response(data.response.body, data.response) + responsePromise.resolve(response) + + // Remove this listener since the request has been handled. + channel.port1.removeListener('message', responseListener) + } + } + channel.port1.addListener('message', responseListener) + + const response = await responsePromise + if (response) { + request.respondWith(response) + } + }) + + /** @todo Dispose of the interceptor */ + } +} diff --git a/lib/web/service-worker/service-worker-global-scope.js b/lib/web/service-worker/service-worker-global-scope.js new file mode 100644 index 00000000000..733cd5bd4df --- /dev/null +++ b/lib/web/service-worker/service-worker-global-scope.js @@ -0,0 +1,84 @@ +import { parentPort } from 'node:worker_threads' +import { Clients, kAddClient } from './clients.js' +import { ServiceWorker } from './service-worker.js' +import { Client } from './client.js' +import { CacheStorage } from '../cache/cachestorage.js' + +/** + * Custom implementation of the `ServiceWorkerGlobalScope` object. + * This acts as `self` in the global scope of the Service Worjer. + */ +export class ServiceWorkerGlobalScope extends EventTarget { + /** @type {import('./service-worker-container.js').WorkerData} */ + #parentData + + /** + * @param {import('./service-worker-container.js').WorkerData} parentData + */ + constructor (parentData) { + super() + + this.#parentData = parentData + this.serviceWorker = this.#createServiceWorker() + this.clients = new Clients(this.serviceWorker) + this.#addClient(parentData.clientInfo) + + this.caches = new CacheStorage() + } + + /** + * Create a representation of this Service Worker + * that will communicate its events to the parent thread. + * @returns {ServiceWorker} + */ + #createServiceWorker () { + const serviceWorker = new ServiceWorker( + this.#parentData.scriptUrl, + (value, transfer) => { + /** + * @todo This should technically post message + * to itself (the same worker thread)? + */ + throw new Error('Not implemented') + } + ) + + process + .once('uncaughtException', () => { + serviceWorker.dispatchEvent(new Event('error')) + }) + .once('unhandledRejection', () => { + serviceWorker.dispatchEvent(new Event('error')) + }) + + // Forward Service Worker events to the client + // so it updates its Service Worker instance accordingly. + serviceWorker.addEventListener('statechange', () => { + parentPort.postMessage({ + type: 'worker/statechange', + state: serviceWorker.state + }) + }) + serviceWorker.addEventListener('error', () => { + parentPort.postMessage({ type: 'worker/error' }) + }) + + return serviceWorker + } + + /** + * @param {import('./client.js').SerializedClient} clientInfo + * @returns {void} + */ + #addClient (clientInfo) { + const { clientMessagePort } = this.#parentData + const client = new Client( + clientInfo.id, + clientInfo.url, + clientInfo.type, + clientInfo.frameType + ) + client.postMessage = clientMessagePort.postMessage.bind(clientMessagePort) + this.clients[kAddClient](client) + } +} diff --git a/lib/web/service-worker/service-worker-registration.js b/lib/web/service-worker/service-worker-registration.js new file mode 100644 index 00000000000..890515c31ee --- /dev/null +++ b/lib/web/service-worker/service-worker-registration.js @@ -0,0 +1,51 @@ +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration + */ +export class ServiceWorkerRegistration extends EventTarget { + /** @type {import('./service-worker.js').ServiceWorker} */ + #serviceWorker + + /** + * @param {import('./service-worker.js').ServiceWorker} serviceWorker + */ + constructor (serviceWorker) { + super() + this.#serviceWorker = serviceWorker + } + + /** + * @returns {ServiceWorker | null} + */ + get installing () { + if (this.#serviceWorker.state === 'installing') { + return this.#serviceWorker + } + + return null + } + + /** + * @returns {ServiceWorker | null} + */ + get waiting () { + if (this.#serviceWorker.state === 'installed') { + return this.#serviceWorker + } + + return null + } + + /** + * @returns {ServiceWorker | null} + */ + get active () { + if ( + this.#serviceWorker.state === 'activating' || + this.#serviceWorker.state === 'activated' + ) { + return this.#serviceWorker + } + + return null + } +} diff --git a/lib/web/service-worker/service-worker.js b/lib/web/service-worker/service-worker.js new file mode 100644 index 00000000000..76d19d775f1 --- /dev/null +++ b/lib/web/service-worker/service-worker.js @@ -0,0 +1,46 @@ +/** + * @typedef { 'parsed' | 'installing' | 'installed' | 'activating' | 'activated' | 'redundant' } ServiceWorkerState + */ + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker + */ +export class ServiceWorker extends EventTarget { + /** @type {ServiceWorkerState} */ + #state + + /** @type {tring} */ + scriptUrl + + /** @type {MessagePort['postMessage']} */ + postMessage + + /** + * @param {string} scriptUrl + * @param {MessagePort['postMessage']} postMessage + */ + constructor (scriptUrl, postMessage) { + super() + this.#state = '' + this.scriptUrl = scriptUrl + this.postMessage = postMessage.bind(this) + } + + /** + * @returns {ServiceWorkerState} + */ + get state () { + return this.#state + } + + /** + * @param {ServiceWorkerState} nextState + */ + set state (nextState) { + this.#state = nextState + + if (nextState !== 'parsed') { + this.dispatchEvent(new Event('statechange')) + } + } +} diff --git a/lib/web/service-worker/worker.js b/lib/web/service-worker/worker.js new file mode 100644 index 00000000000..a9b9937238f --- /dev/null +++ b/lib/web/service-worker/worker.js @@ -0,0 +1,106 @@ +import * as fs from 'node:fs' +import * as vm from 'node:vm' +import { workerData, parentPort as maybeParentPort } from 'node:worker_threads' +import { ServiceWorkerGlobalScope } from './service-worker-global-scope.js' +import { ExtendableEvent, kPendingPromises } from './extendable-event.js' +import { InstallEvent } from './install-event.js' +import { FetchEvent, kResponsePromise } from './fetch-event.js' + +const parentPort = maybeParentPort + +if (!parentPort) { + throw new Error('Failed to run worker: missing parent process') +} + +/** @type {import('./ServiceWorkerContainer.js').WorkerData} */ +const parentData = workerData + +// Create the Service Worker's global scope object (`self`). +const globalScope = new ServiceWorkerGlobalScope(parentData) + +process.once('uncaughtException', (error) => { + console.error(error) + globalScope.serviceWorker.dispatchEvent(new Event('error')) +}) + +const content = fs.readFileSync(parentData.scriptUrl, 'utf8') + +parentPort.postMessage({ + type: 'worker/statechange', + state: 'parsed' +}) + +// Run the worker script within the controller global scope. +const script = new vm.Script(content) + +script.runInNewContext({ + global: globalScope, + globalThis: globalScope, + self: globalScope, + setTimeout, + setInterval, + Blob, + FormData, + Headers, + Request, + Response, + console +}) + +// Forward messages from the parent process +// as the "message" events on the Service Worker. +parentPort.addListener('message', (data) => { + globalScope.dispatchEvent(new MessageEvent('message', { data })) +}) + +parentData.interceptorMessagePort.addListener('message', (data) => { + switch (data.type) { + case 'request': { + const { requestId, request: requestInit } = data + const request = new Request(requestInit.url, requestInit) + const fetchEvent = new FetchEvent('fetch', { + request + /** @todo clientId */ + }) + + globalScope.dispatchEvent(fetchEvent) + + /** @todo Handle the case when FetchEvent doesn't handle the request. */ + + fetchEvent[kResponsePromise].then(async (response) => { + const responseBody = await response.arrayBuffer() + parentData.interceptorMessagePort.postMessage( + { + type: 'response', + requestId, + response: { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: response.body === null ? null : responseBody + } + }, + [responseBody] + ) + }) + break + } + } +}) + +async function startServiceWorkerLifeCycle () { + // Installed event. + globalScope.serviceWorker.state = 'installing' + const installEvent = new InstallEvent('install') + globalScope.dispatchEvent(installEvent) + await Promise.allSettled(installEvent[kPendingPromises]) + globalScope.serviceWorker.state = 'installed' + + // Activated event. + globalScope.serviceWorker.state = 'activating' + const activateEvent = new ExtendableEvent('activate') + globalScope.dispatchEvent(activateEvent) + await Promise.allSettled(activateEvent[kPendingPromises]) + globalScope.serviceWorker.state = 'activated' +} +startServiceWorkerLifeCycle()