diff --git a/packages/fs/README.md b/packages/fs/README.md index 5021d4f..9c86f1b 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -142,7 +142,7 @@ publisher.mkdir('/new-dir') publisher.initFiles({ '/main.js': 'code' }, { '/logo.png': arrayBuffer }) // bulk init (each target gets its own copy of the binary files) ``` -`writeFile()` and `unlink()` accept an operation ID, which is set on the message. A Worker that applies such a message can acknowledge it with a `V_FS_ACK` message (`{ type: 'V_FS_ACK', id, error? }`), so that the main thread knows when the change is applied. `@vrowzer/fs` does not send acknowledgements by itself: the Worker that handles the messages decides when a change counts as applied. +`writeFile()`, `unlink()` and `initFiles()` accept an operation ID, which is set on the message. A Worker that applies such a message can acknowledge it with a `V_FS_ACK` message (`{ type: 'V_FS_ACK', id, error? }`), so that the main thread knows when the change is applied. `@vrowzer/fs` does not send acknowledgements by itself: the Worker that handles the messages decides when a change counts as applied. ```ts publisher.writeFile('/main.js', 'export const x = 2', { id: crypto.randomUUID() }) diff --git a/packages/fs/docs/watcher/interfaces/FSInitMessage.md b/packages/fs/docs/watcher/interfaces/FSInitMessage.md index fad73e0..b40f774 100644 --- a/packages/fs/docs/watcher/interfaces/FSInitMessage.md +++ b/packages/fs/docs/watcher/interfaces/FSInitMessage.md @@ -19,4 +19,5 @@ export interface FSInitMessage | --- | --- | --- | | `binaryFiles` _(optional)_ | `Record` | Binary files: path -> ArrayBuffer content (a copy transferred via postMessage's transfer list) | | `files` _(optional)_ | `Record` | Text files: path -> UTF-8 string content | +| `id` _(optional)_ | `string` | Operation ID. A Worker that applies the message can acknowledge it with [FSAckMessage](/packages/fs/docs/watcher/interfaces/FSAckMessage.md). | | `type` | `"V_FS_INIT"` | | diff --git a/packages/fs/docs/watcher/interfaces/FileSystemPublisher.md b/packages/fs/docs/watcher/interfaces/FileSystemPublisher.md index 9b5f0f8..aaf463c 100644 --- a/packages/fs/docs/watcher/interfaces/FileSystemPublisher.md +++ b/packages/fs/docs/watcher/interfaces/FileSystemPublisher.md @@ -38,7 +38,7 @@ Add a postMessage target. ### initFiles() ```ts -initFiles(files?: Record, binaryFiles?: Record): void; +initFiles(files?: Record, binaryFiles?: Record, options?: FileSystemPublishOptions): void; ``` Initialize files in bulk. @@ -49,6 +49,7 @@ Initialize files in bulk. | --- | --- | --- | | `files` | `Record` | Text files: path -> UTF-8 string content _(optional)_ | | `binaryFiles` | `Record` | Binary files: path -> ArrayBuffer content. Each target receives its own copy via postMessage's transfer list, so the caller's ArrayBuffers stay usable. _(optional)_ | +| `options` | [`FileSystemPublishOptions`](/packages/fs/docs/watcher/interfaces/FileSystemPublishOptions.md) | Options for this operation, such as its ID _(optional)_ | #### Returns diff --git a/packages/fs/src/watcher/protocol.ts b/packages/fs/src/watcher/protocol.ts index 1977005..91d4090 100644 --- a/packages/fs/src/watcher/protocol.ts +++ b/packages/fs/src/watcher/protocol.ts @@ -100,6 +100,10 @@ export interface FSInitMessage { * Binary files: path -> ArrayBuffer content (a copy transferred via postMessage's transfer list) */ binaryFiles?: Record + /** + * Operation ID. A Worker that applies the message can acknowledge it with {@link FSAckMessage}. + */ + id?: string } export type FileSystemSyncMessage = diff --git a/packages/fs/src/watcher/publisher.test.ts b/packages/fs/src/watcher/publisher.test.ts index d49b65d..8026c73 100644 --- a/packages/fs/src/watcher/publisher.test.ts +++ b/packages/fs/src/watcher/publisher.test.ts @@ -320,5 +320,29 @@ describe('FileSystemPublisher', () => { expect([...new Uint8Array(message.binaryFiles['/a.bin'])]).toEqual([1, 2, 3]) expect([...new Uint8Array(message.binaryFiles['/b.bin'])]).toEqual([1, 2, 3]) }) + + test('sets the operation id on the message for every target', () => { + const target1 = createMockTarget() + const target2 = createMockTarget() + const publisher = createFileSystemPublisher([target1, target2]) + + publisher.initFiles( + { '/main.js': 'code' }, + { '/app.wasm': new ArrayBuffer(4) }, + { id: 'op-4' } + ) + + expect(target1.calls[0]!.message.id).toBe('op-4') + expect(target2.calls[0]!.message.id).toBe('op-4') + }) + + test('omits the operation id without the option', () => { + const target = createMockTarget() + const publisher = createFileSystemPublisher([target]) + + publisher.initFiles({ '/main.js': 'code' }) + + expect(target.calls[0]!.message).not.toHaveProperty('id') + }) }) }) diff --git a/packages/fs/src/watcher/publisher.ts b/packages/fs/src/watcher/publisher.ts index e9b0683..4666b49 100644 --- a/packages/fs/src/watcher/publisher.ts +++ b/packages/fs/src/watcher/publisher.ts @@ -76,8 +76,13 @@ export interface FileSystemPublisher { * @param files - Text files: path -> UTF-8 string content * @param binaryFiles - Binary files: path -> ArrayBuffer content. Each target receives its own copy * via postMessage's transfer list, so the caller's ArrayBuffers stay usable. + * @param options - Options for this operation, such as its ID */ - initFiles(files?: Record, binaryFiles?: Record): void + initFiles( + files?: Record, + binaryFiles?: Record, + options?: FileSystemPublishOptions + ): void /** * Add a postMessage target. * @@ -143,11 +148,11 @@ export function createFileSystemPublisher( broadcast({ type: 'V_FS_MKDIR', path }) }, - initFiles(files, binaryFiles) { + initFiles(files, binaryFiles, options) { // Each target gets its own copies of the binary files in the transfer list. // The caller's buffers are never transferred, so they stay usable. for (const target of _targets) { - const msg: FSInitMessage = { type: 'V_FS_INIT' } + const msg: FSInitMessage = { type: 'V_FS_INIT', ...idOf(options) } if (files) { msg.files = files } diff --git a/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.test.ts b/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.test.ts new file mode 100644 index 0000000..823469e --- /dev/null +++ b/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'vite-plus/test' +import { Hono } from 'hono' +import { beforeRequestMiddleware } from './beforeRequest' +import { crossOriginMiddleware } from './crossOrigin' + +function createApp(beforeRequest: (request: Request) => Promise) { + const app = new Hono() + app.use(crossOriginMiddleware()) + app.use(beforeRequestMiddleware(beforeRequest)) + app.get('*', c => c.text('served')) + return app +} + +describe('beforeRequestMiddleware', () => { + test('waits for the hook before handling the request', async () => { + let release!: () => void + const app = createApp(() => new Promise(resolve => { + release = () => resolve(undefined) + })) + + let settled = false + const response = app.request('/main.js').then((res) => { + settled = true + return res + }) + for (let index = 0; index < 10; index++) { + await Promise.resolve() + } + expect(settled).toBe(false) + + release() + const res = await response + expect(res.status).toBe(200) + expect(await res.text()).toBe('served') + }) + + test('handles the request as usual when the hook resolves to undefined', async () => { + const app = createApp(async () => undefined) + + const res = await app.request('/main.js') + + expect(res.status).toBe(200) + expect(await res.text()).toBe('served') + }) + + test('answers with the response of the hook, with the headers of earlier middlewares', async () => { + const app = createApp(async () => new Response('not ready', { + status: 503, + headers: { 'Content-Type': 'text/plain' }, + })) + + const res = await app.request('/main.js') + + expect(res.status).toBe(503) + expect(await res.text()).toBe('not ready') + expect(res.headers.get('Content-Type')).toBe('text/plain') + expect(res.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin') + expect(res.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + expect(res.headers.get('Cross-Origin-Opener-Policy')).toBe('same-origin') + }) + + test('passes the request to the hook', async () => { + const requests: Request[] = [] + const app = createApp(async (request) => { + requests.push(request) + return undefined + }) + + await app.request('http://localhost/__preview__/main.js?import', { + headers: { Accept: 'text/javascript' }, + }) + + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe('http://localhost/__preview__/main.js?import') + expect(requests[0]!.headers.get('Accept')).toBe('text/javascript') + }) +}) diff --git a/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.ts b/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.ts new file mode 100644 index 0000000..d356c7e --- /dev/null +++ b/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.ts @@ -0,0 +1,32 @@ +/** + * Before request Hono middleware + * + * Lets the owner of the Service Worker dev server hold a request until it can be served, + * or answer it instead. + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import type { MiddlewareHandler } from 'hono' +import type { ViteEnv } from '../index' + +/** + * Middleware that waits for `beforeRequest` before the request is handled. + * When the hook resolves to a `Response`, that response is returned instead. + */ +export function beforeRequestMiddleware( + beforeRequest: (request: Request) => Promise, +): MiddlewareHandler { + return async function viteBeforeRequestMiddleware(c, next) { + const response = await beforeRequest(c.req.raw) + if (response) { + // A response returned as it is would lose the headers that earlier middlewares set, + // such as the cross-origin isolation headers, so rebuild it through the context + return c.newResponse(response.body, response) + } + await next() + } +} diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index 9675e1b..0ea0d95 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -28,6 +28,7 @@ import { isResolvedConfig, resolveConfig } from './config' import { syncPublicFiles } from './public-files-sync' import { initPublicFiles } from './publicDir' import { baseMiddleware } from './server/middlewares/base' +import { beforeRequestMiddleware } from './server/middlewares/beforeRequest' import { crossOriginMiddleware } from './server/middlewares/crossOrigin' import { errorMiddleware } from './server/middlewares/error' import { htmlFallbackMiddleware } from './server/middlewares/htmlFallback' @@ -306,6 +307,18 @@ export interface CreateServerOptions { * FSWawtcher factory function to create a custom FSWatcher instance. */ watcherFactory?: (targets: string[], options: WatchOptions) => FSWatcher + /** + * Called before a request within the base path is handled. The request waits until the returned + * promise resolves. When it resolves to a `Response`, that response is returned instead, with the + * headers that the earlier middlewares set. + */ + beforeRequest?: (request: Request) => Promise + /** + * Called each time the channel handshake with a Web Worker (`V_WW_CONNECT_PORT`) completes, + * before the client is told that the connection is established. A later connection replaces + * the previous channel. + */ + onWorkerChannelReady?: () => void /** * @internal */ @@ -547,6 +560,13 @@ export function createServer( // Cross-origin isolation headers (CORP/COEP/COOP) for credentialless iframe + SW middlewares.use(crossOriginMiddleware()) + // Let the owner hold requests until they can be served, e.g. until a restarted Service Worker + // has the project files and the Web Worker channel again. This is registered before the + // configureServer hooks, so that the middlewares of plugins wait as well. + if (options.beforeRequest) { + middlewares.use(beforeRequestMiddleware(options.beforeRequest)) + } + // TODO(kazupon): disable middlewares, after implementing them // middlewares.use(rejectInvalidRequestMiddleware()) // middlewares.use(rejectNoCorsRequestMiddleware()) @@ -722,6 +742,10 @@ export function createServer( } ) + // Requests can be transformed again. Tell the owner before the client hears that the + // connection is established, so that requests held by `beforeRequest` can go on by then. + options.onWorkerChannelReady?.() + // Notify the originating client that the connection is established if (clientId) { const client = await serviceWorkerScope.clients.get(clientId) diff --git a/packages/vite-dev-server/src/node/web-worker.test.ts b/packages/vite-dev-server/src/node/web-worker.test.ts index c1bfaa5..dad6234 100644 --- a/packages/vite-dev-server/src/node/web-worker.test.ts +++ b/packages/vite-dev-server/src/node/web-worker.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test' import { + V_SW_CONNECT_PORT, + V_SW_CONNECT_PORT_ACK, V_WW_READY, V_WW_SETUP, V_WW_SETUP_ACK, @@ -7,7 +9,7 @@ import { } from '../shared/messages' const transformerMocks = vi.hoisted(() => ({ - connectServiceWorkerPort: vi.fn<() => Promise>(), + connectServiceWorkerPort: vi.fn<(...args: unknown[]) => Promise>(), createDevHtmlTransformFn: vi.fn<() => () => void>(() => vi.fn<() => void>()), fs: {}, isServerAccessDeniedForTransform: vi.fn<() => boolean>(() => false), @@ -41,6 +43,7 @@ function createSetupResult() { return { config: { getSortedPluginHooks: vi.fn<() => []>(() => []), + safeModulePaths: new Set(), }, environments: { client: { @@ -303,3 +306,130 @@ describe('Web Worker file changes', () => { expect(waitForFileChange).toHaveBeenCalledExactlyOnceWith('/src/main.ts') }) }) + +describe('Web Worker Service Worker channel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function createPort() { + return { close: vi.fn<() => void>() } + } + + function createRpc() { + return { + $close: vi.fn<(error?: Error) => void>(), + registerSafeModulePaths: vi.fn<(paths: string[]) => Promise>(async () => undefined), + } + } + + function dispatchConnectPort( + workerScope: DedicatedWorkerGlobalScope, + port: ReturnType, + ): Promise { + return Promise.resolve(workerScope.onmessage?.({ + data: { type: V_SW_CONNECT_PORT }, + ports: [port], + } as unknown as MessageEvent) as unknown as Promise) + } + + function connectPortAcks(workerScope: DedicatedWorkerGlobalScope): unknown[] { + return vi.mocked(workerScope.postMessage).mock.calls + .map(([message]) => message) + .filter(message => (message as { type?: string }).type === V_SW_CONNECT_PORT_ACK) + } + + async function readyWorkerScope(safeModulePaths: string[] = []): Promise { + const setupResult = createSetupResult() + setupResult.config.safeModulePaths = new Set(safeModulePaths) + transformerMocks.setupWorker.mockResolvedValue(setupResult) + const workerScope = createWorkerScope() + const server = createServer(workerScope) + const listening = server.listen(0) + await dispatchSetup(workerScope) + await listening + return workerScope + } + + test('closes the previous port and its RPC when a new port connects', async () => { + const firstRpc = createRpc() + const secondRpc = createRpc() + transformerMocks.connectServiceWorkerPort + .mockResolvedValueOnce(firstRpc) + .mockResolvedValueOnce(secondRpc) + const workerScope = await readyWorkerScope() + const firstPort = createPort() + const secondPort = createPort() + + await dispatchConnectPort(workerScope, firstPort) + expect(firstPort.close).not.toHaveBeenCalled() + await dispatchConnectPort(workerScope, secondPort) + + expect(firstPort.close).toHaveBeenCalledOnce() + expect(firstRpc.$close).toHaveBeenCalledOnce() + expect(secondPort.close).not.toHaveBeenCalled() + expect(secondRpc.$close).not.toHaveBeenCalled() + expect(connectPortAcks(workerScope)).toHaveLength(2) + }) + + test('does not acknowledge a port that a new port replaced during the handshake', async () => { + let completeFirstHandshake!: (rpc: unknown) => void + const firstRpc = createRpc() + const secondRpc = createRpc() + transformerMocks.connectServiceWorkerPort + .mockReturnValueOnce(new Promise((resolve) => { + completeFirstHandshake = resolve + })) + .mockResolvedValueOnce(secondRpc) + const workerScope = await readyWorkerScope() + const firstPort = createPort() + const secondPort = createPort() + + const firstConnection = dispatchConnectPort(workerScope, firstPort) + // Send the second port while the first one is in its handshake + await vi.waitFor(() => { + expect(transformerMocks.connectServiceWorkerPort).toHaveBeenCalledOnce() + }) + await dispatchConnectPort(workerScope, secondPort) + completeFirstHandshake(firstRpc) + await firstConnection + + expect(firstPort.close).toHaveBeenCalledOnce() + expect(firstRpc.$close).toHaveBeenCalledOnce() + expect(firstRpc.registerSafeModulePaths).not.toHaveBeenCalled() + expect(secondRpc.$close).not.toHaveBeenCalled() + expect(connectPortAcks(workerScope)).toHaveLength(1) + }) + + test('stops without an acknowledgement when a new port replaces one that is sending its safe paths', async () => { + let rejectRegistration!: (error: Error) => void + const firstRpc = createRpc() + firstRpc.registerSafeModulePaths.mockReturnValueOnce(new Promise((_, reject) => { + rejectRegistration = reject + })) + // Closing a birpc rejects its pending calls + firstRpc.$close.mockImplementation((error) => { + rejectRegistration(error ?? new Error('closed')) + }) + const secondRpc = createRpc() + transformerMocks.connectServiceWorkerPort + .mockResolvedValueOnce(firstRpc) + .mockResolvedValueOnce(secondRpc) + const workerScope = await readyWorkerScope(['/src/main.ts']) + + const firstConnection = dispatchConnectPort(workerScope, createPort()) + await vi.waitFor(() => { + expect(firstRpc.registerSafeModulePaths).toHaveBeenCalledOnce() + }) + await dispatchConnectPort(workerScope, createPort()) + + await expect(firstConnection).resolves.toBeUndefined() + expect(firstRpc.$close).toHaveBeenCalledOnce() + expect(secondRpc.registerSafeModulePaths).toHaveBeenCalledExactlyOnceWith(['/src/main.ts']) + expect(connectPortAcks(workerScope)).toHaveLength(1) + }) +}) diff --git a/packages/vite-dev-server/src/node/web-worker.ts b/packages/vite-dev-server/src/node/web-worker.ts index 03da77d..8701d16 100644 --- a/packages/vite-dev-server/src/node/web-worker.ts +++ b/packages/vite-dev-server/src/node/web-worker.ts @@ -39,6 +39,17 @@ const debug = createDebugger('vrowzer:web-worker') */ type InternalWorkerMessage = SetupWorkerMessage | ConnectServiceWorkerPortMessage +/** + * A channel with the Service Worker, over a port received with `V_SW_CONNECT_PORT`. + */ +interface ServiceWorkerChannel { + port: MessagePort + /** + * The RPC over the port, set once the handshake completes. + */ + rpc: { $close: (error?: Error) => void } | null +} + /** * This module defines the Web Worker server for @vrowzer/vite-dev-server. * It handles the Web Worker side of the protocol for setup and Service Worker communication, @@ -124,6 +135,21 @@ export function createServer( let ws: import('./server/ws').MessageChannelServer | null = null let fileChanges: FileChangeTracker | null = null + // The current channel with the Service Worker. A new port replaces it, e.g. after the browser + // restarted the Service Worker process. + let serviceWorkerChannel: ServiceWorkerChannel | null = null + + function closeServiceWorkerChannel(): void { + const channel = serviceWorkerChannel + if (!channel) { + return + } + serviceWorkerChannel = null + // Reject the calls still waiting for the previous Service Worker, whose replies never come + channel.rpc?.$close(new Error('[vrowzer:web-worker] The Service Worker channel was replaced')) + channel.port.close() + } + // Register onmessage immediately (lightweight, no WASM) workerScope.onmessage = async (event: MessageEvent) => { const { type } = event.data ?? {} as InternalWorkerMessage @@ -236,6 +262,11 @@ export function createServer( break } + // The new port replaces the current channel, so close the previous one + closeServiceWorkerChannel() + const channel: ServiceWorkerChannel = { port, rpc: null } + serviceWorkerChannel = channel + const { connectServiceWorkerPort } = await import('./transformer') const serviceWorkerRpc = await connectServiceWorkerPort(port, { transformRequest: (url, opts) => server!.transformRequest(url, opts), @@ -245,12 +276,30 @@ export function createServer( debug?.('HMR port received from SW, connecting to MessageChannelServer') ws!.handlePort(hmrPort, clientId) }) + if (serviceWorkerChannel !== channel) { + // A newer port replaced this one during the handshake + serviceWorkerRpc.$close() + break + } + channel.rpc = serviceWorkerRpc - await connectSafeModulePathSync( - Object.values(server.environments), - server.config.safeModulePaths, - paths => serviceWorkerRpc.registerSafeModulePaths(paths), - ) + try { + await connectSafeModulePathSync( + Object.values(server.environments), + server.config.safeModulePaths, + paths => serviceWorkerRpc.registerSafeModulePaths(paths), + ) + } catch (error) { + // Replacing the channel rejects the pending registration + if (serviceWorkerChannel !== channel) { + break + } + throw error + } + // The acknowledgement does not name the port, so do not send one for a replaced port + if (serviceWorkerChannel !== channel) { + break + } workerScope.postMessage({ type: V_SW_CONNECT_PORT_ACK }) debug?.('SW<->WW birpc channel established') diff --git a/packages/vite-dev-server/src/shared/messages.ts b/packages/vite-dev-server/src/shared/messages.ts index 78c963c..dd4371d 100644 --- a/packages/vite-dev-server/src/shared/messages.ts +++ b/packages/vite-dev-server/src/shared/messages.ts @@ -81,6 +81,11 @@ export interface SetupWorkerErrorMessage { */ export interface ConnectWebWorkerPortMessage { type: 'V_WW_CONNECT_PORT' + /** + * ID of the runtime that owns the channel. The Service Worker does not use it yet. It marks the + * owner for when several runtimes share one Service Worker. + */ + runtimeId?: string } /** @@ -162,6 +167,28 @@ export interface ServiceWorkerListenReadyPingMessage { */ export interface ServiceWorkerListenReadyMessage { type: 'V_SW_LISTEN_READY' + /** + * ID of the Service Worker instance. A new ID is created each time the Service Worker script is + * evaluated, including after the browser restarts the Service Worker process. + */ + instanceId: string +} + +/** + * Service Worker -> Main Thread: A Service Worker instance has completed `listen()`. + * Sent to every window client that the Service Worker controls, each time the Service Worker + * script is evaluated. + * + * When the browser restarts the Service Worker process, the registration and the controller stay + * the same, and no `activate` or `controllerchange` event fires. A runtime notices the restart by + * an `instanceId` that differs from the one it knows, and restores what the Service Worker lost. + */ +export interface ServiceWorkerInstanceStartedMessage { + type: 'V_SW_INSTANCE_STARTED' + /** + * ID of the Service Worker instance that started. + */ + instanceId: string } // ---- Protocol message type constants ---- @@ -178,4 +205,5 @@ export const V_SW_CONNECT_PORT_ACK = 'V_SW_CONNECT_PORT_ACK' as const export const V_WW_HMR_PORT = 'V_WW_HMR_PORT' as const export const V_SW_LISTEN_READY_PING = 'V_SW_LISTEN_READY_PING' as const export const V_SW_LISTEN_READY = 'V_SW_LISTEN_READY' as const +export const V_SW_INSTANCE_STARTED = 'V_SW_INSTANCE_STARTED' as const export const MC_INIT_EVENT = 'vite:mc:init' as const diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index 471ad7b..efb4c3a 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -149,6 +149,8 @@ Set this option to `0` for an immediate timeout. It does not apply to Service Wo const vrowzer = Vrowzer({ fileSyncTimeout: 30000 }) ``` +The same timeout limits how long Vrowzer takes to restore the project in a restarted Service Worker. See [Service Worker restarts](#service-worker-restarts). + **Options:** | Option | Type | Default | Description | @@ -158,7 +160,7 @@ const vrowzer = Vrowzer({ fileSyncTimeout: 30000 }) | `serviceWorkerScope` | `string` | Plugin value or `'/'` | SW registration scope; must match the plugin value | | `serviceWorkerReadyTimeout` | `number` | `60000` | Milliseconds to wait for the Service Worker page controller | | `webWorkerSetupTimeout` | `number` | `90000` | Milliseconds from Web Worker creation through setup completion | -| `fileSyncTimeout` | `number` | `10000` | Milliseconds to wait for the Workers to apply a file change | +| `fileSyncTimeout` | `number` | `10000` | Milliseconds to wait for the Workers to apply a file change, or to restore a restarted Service Worker | ### Instance Methods @@ -260,6 +262,7 @@ vrowzer.reloadPreview() - `ArrayBuffer` content is copied for each Worker, so the caller's buffer stays usable. - The promise rejects without sending the change before `ready()` resolves to `true`, after it fails, and after `dispose()`. It also rejects when a Worker fails to apply the change (for example, a plugin's `watchChange` hook throws), when the Web Worker reports an error, when the Workers do not reply within `fileSyncTimeout`, or when the instance is disposed first. The error message names the operation, the path and the Worker. - After a rejection, the change may be partly applied. Write the file again, or delete it, to resynchronize. +- While Vrowzer restores the project in a restarted Service Worker, the change is held and sent afterwards, and `fileSyncTimeout` counts from then. The promise rejects if the restoration fails. See [Service Worker restarts](#service-worker-restarts). > [!NOTE] > Up to vrowzer 0.4.x, these methods returned `void` without waiting for the Workers, and calls made before `ready()` completed were dropped or reached only the Web Worker. @@ -290,6 +293,7 @@ With `await using`, the instance is disposed at the end of the scope. ``` - If `ready()` is still in progress, it is aborted and resolves to `false`. +- If Vrowzer is restoring the project in a restarted Service Worker, the restoration stops. - File operations still waiting for the Workers reject. After disposal, `ready()`, `addFile()`, `updateFile()` and `deleteFile()` reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a new instance to start again. - Calling `dispose()` again returns the same promise. If some resources cannot be released, the remaining ones are still released, and the promise rejects with an `AggregateError`. @@ -321,6 +325,44 @@ vrowzer.on('previewLoadError', info => { The session stays mounted and its rendering does not change: an error response body is still shown, and the remaining scripts still run. After fixing the files, reload the session with `reloadPreview(info.id)`; `ready()` does not need to run again. Errors thrown by the application at runtime are not reported by this event. +#### `serviceWorkerRecovered` + +Emitted when Vrowzer has restored the project in a restarted Service Worker. See [Service Worker restarts](#service-worker-restarts). + +```ts +vrowzer.on('serviceWorkerRecovered', () => { + console.info('The Service Worker restarted, and the preview is available again') +}) +``` + +#### `serviceWorkerRecoveryError` + +Emitted with an `Error` when Vrowzer could not restore the project in a restarted Service Worker: the Service Worker failed to apply the files, or the restoration did not finish within `fileSyncTimeout`. See [Service Worker restarts](#service-worker-restarts). + +### Service Worker restarts + +Browsers stop an idle Service Worker and start it again for the next request or message. The registration and the page controller stay the same, but the restarted Service Worker has lost the project files and its channel to the Web Worker. Vrowzer restores them without reloading the host page: + +1. When the Service Worker starts again, it notifies the pages it controls. +2. Vrowzer sends it the latest files, as given to `ready()` and changed with `addFile()`, `updateFile()` and `deleteFile()`, and connects the Web Worker channel again. To do this, Vrowzer keeps a copy of the files in the page. +3. Vrowzer emits `serviceWorkerRecovered`. + +While the project is being restored: + +- Preview requests wait until the Service Worker has the project again, for up to 10 seconds. After that, they get a `503` response, which a loading preview reports with `previewLoadError`. +- `addFile()`, `updateFile()` and `deleteFile()` are held, and sent once the project is restored. +- Mounted previews keep their HMR connection, which does not go through the Service Worker. + +If the restoration does not finish within `fileSyncTimeout`, or the Service Worker fails to apply the files, Vrowzer emits `serviceWorkerRecoveryError`, and the file operations that wait for the Service Worker reject. Later file operations are sent as usual, and Vrowzer tries again when the Service Worker restarts the next time. To start over right away, dispose the instance and create a new one: + +```ts +vrowzer.on('serviceWorkerRecoveryError', async error => { + console.error(error) + await vrowzer.dispose() + // Create a new instance, and call ready() with the files again +}) +``` + ## 🏗️ Architecture ![Architecture](./assets/architecture.svg) diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index a8a3901..0d58cc5 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -32,6 +32,9 @@ when the Web Worker reports an error, when the Workers do not reply within [VrowzerOptions.fileSyncTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-filesynctimeout), or when the instance is disposed first. The change may be partly applied then; write the file again to resynchronize. +While Vrowzer restores the project in a restarted Service Worker, the change is held and sent +afterwards, and the timeout counts from then. It rejects when the restoration fails. + #### Parameters | Name | Type | Description | @@ -76,10 +79,11 @@ dispose(): Promise; Disposes this instance. -An in-progress [Vrowzer.ready](#method-ready) is aborted and resolves to `false`. Every preview session -is unmounted, the Web Worker is terminated, Service Worker controller events are no longer -forwarded, and all event handlers are removed right away. The Service Worker registration and -its virtual filesystem are kept for other clients. +An in-progress [Vrowzer.ready](#method-ready) is aborted and resolves to `false`, and a restoration of a +restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is +terminated, Service Worker controller events are no longer forwarded, and all event handlers are +removed right away. The Service Worker registration and its virtual filesystem are kept for +other clients. File operations still waiting for the Workers reject. After disposal, `ready()` and the file methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md index 79f56a3..885065b 100644 --- a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md +++ b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md @@ -13,7 +13,7 @@ export interface VrowzerOptions | Name | Type | Description | | --- | --- | --- | | `basePath` _(optional)_ | `string` | Preview URL pathname. When `@vrowzer/vite-plugin` is used, its `basePath` is injected and this option can be omitted. If both are provided, their canonical values must match. Without the plugin, this option defaults to `'/__preview__/'`. | -| `fileSyncTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Web Worker and the Service Worker to apply a change made with [Vrowzer.addFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-addfile), [Vrowzer.updateFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-updatefile) or [Vrowzer.deleteFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-deletefile). The Web Worker applies a change after the plugins' `watchChange` hooks finish. This timeout is shorter than the 30 seconds that the Service Worker waits for the Web Worker to transform a request, so with slow plugins or heavy transforms an operation can reject although the change is applied later. Increase it in such environments. Writing the file again resynchronizes it. **Default:** `10000` | +| `fileSyncTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Web Worker and the Service Worker to apply a change made with [Vrowzer.addFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-addfile), [Vrowzer.updateFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-updatefile) or [Vrowzer.deleteFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-deletefile). The Web Worker applies a change after the plugins' `watchChange` hooks finish. This timeout is shorter than the 30 seconds that the Service Worker waits for the Web Worker to transform a request, so with slow plugins or heavy transforms an operation can reject although the change is applied later. Increase it in such environments. Writing the file again resynchronizes it. It also limits how long Vrowzer takes to restore the project in a restarted Service Worker. See [VrowzerEventMap.serviceWorkerRecoveryError](/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md#property-serviceworkerrecoveryerror). **Default:** `10000` | | `serviceWorkerReadyTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Service Worker to become the page controller. This timeout does not apply to Service Worker listen readiness or Web Worker setup. **Default:** `60000` | | `serviceWorkerScope` _(optional)_ | `string` | Service Worker registration scope, independent of `basePath`. When `@vrowzer/vite-plugin` is used, its `serviceWorkerScope` is injected and this option can be omitted. If both are provided, their values must match. Without the plugin, this option defaults to `'/'`. | | `serviceWorkerVersion` _(optional)_ | `string` | Service Worker version for cache management. When `@vrowzer/vite-plugin` is used, its `serviceWorkerVersion` is injected and this option can be omitted. If both are provided, their values must match. Without the plugin, this option defaults to `'vrowzer-v1'`. | diff --git a/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md b/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md index 740262e..17e860f 100644 --- a/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md +++ b/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md @@ -3,12 +3,12 @@ Event map for [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md). Forwards all SvcWorkerControllerEventMap events from the underlying Service Worker controller, -and adds events for preview sessions. +and adds events for preview sessions and for restoring a restarted Service Worker. ## Signature ```ts -export type VrowzerEventMap = SvcWorkerControllerEventMap & { previewLoadError: PreviewLoadErrorInfo } +export type VrowzerEventMap = SvcWorkerControllerEventMap & { previewLoadError: PreviewLoadErrorInfo; serviceWorkerRecovered: void; serviceWorkerRecoveryError: Error } ``` ## Properties @@ -16,3 +16,5 @@ export type VrowzerEventMap = SvcWorkerControllerEventMap & { previewLoadError: | Name | Type | Description | | --- | --- | --- | | `previewLoadError` | [`PreviewLoadErrorInfo`](/packages/vrowzer/docs/default/interfaces/PreviewLoadErrorInfo.md) | Emitted when a preview document fails to load before its application code starts: the preview HTML cannot be fetched or returns an error status, or one of its initial scripts fails to load. Runtime errors thrown by the application are not reported by this event. Payload is [PreviewLoadErrorInfo](/packages/vrowzer/docs/default/interfaces/PreviewLoadErrorInfo.md) | +| `serviceWorkerRecovered` | `void` | Emitted when Vrowzer has restored the project in a restarted Service Worker. The browser stops an idle Service Worker and starts it again for the next request or message. The restarted Service Worker has lost the files and the Web Worker channel, so Vrowzer sends the latest files and connects the channel again, without reloading the host page. File operations called in the meantime are sent after the project is restored. | +| `serviceWorkerRecoveryError` | `Error` | Emitted when Vrowzer could not restore the project in a restarted Service Worker: the Service Worker failed to apply the files, or the recovery did not finish within [VrowzerOptions.fileSyncTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-filesynctimeout). File operations waiting for the Service Worker reject. Preview requests that the Service Worker cannot serve yet wait for up to 10 seconds, and then get a 503 response. Vrowzer tries again when the Service Worker restarts the next time. To start over, dispose the instance and create a new one. Payload is the `Error` that describes the failure. | diff --git a/packages/vrowzer/integration/restart.integration-test.ts b/packages/vrowzer/integration/restart.integration-test.ts new file mode 100644 index 0000000..62dbff0 --- /dev/null +++ b/packages/vrowzer/integration/restart.integration-test.ts @@ -0,0 +1,473 @@ +import { chromium } from '@playwright/test' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { build, preview } from 'vite' +import { afterAll, beforeAll, describe, expect, test } from 'vite-plus/test' + +import type { Browser, BrowserContext, Page } from '@playwright/test' +import type { PreviewServer } from 'vite' + +// Stopping the Service Worker through the DevTools Protocol affects every page that uses it, so these +// tests use their own fixture, server and browser instead of the shared playground of +// vrowzer.integration-test.ts, and a new browser context for each test. +const __dirname = dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = join(__dirname, 'restart') +const PIXEL_BYTES = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff] + +let browser: Browser +let server: PreviewServer +let origin: string + +interface Fixture { + context: BrowserContext + page: Page +} + +interface RecordedEvent { + type: string + id?: string + stage?: string + status?: number + message?: string +} + +interface PreviewResponse { + status: number + body: string +} + +/** + * Opens the fixture in a new browser context, which has a Service Worker of its own, and mounts a + * preview of a ready Vrowzer instance. + */ +async function openFixture(options: Record = {}): Promise { + const context = await browser.newContext() + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.waitForFunction(() => document.body.dataset.fixtureReady === 'true') + const ready = await page.evaluate(async vrowzerOptions => { + const fixture = window as any + const vrowzer = fixture.__createVrowzer__(vrowzerOptions) + fixture.__vrowzer__ = vrowzer + const result = await vrowzer.ready({ files: fixture.__previewFiles__ }) + if (result) { + vrowzer.mount(document.getElementById('preview-container'), { id: 'preview' }) + } + return result + }, options) + expect(ready).toBe(true) + await expectPreviewText(page, 'main v1') + return { context, page } +} + +/** + * Stops the Service Worker process and waits until it has stopped. The registration is kept, and + * the next request or message starts the Service Worker again. + * + * The browser stops the Service Worker only after the requests it is handling end, so + * `whileStopping` can end them. A message sent meanwhile starts the Service Worker again right after + * it stops. + */ +async function stopServiceWorker(page: Page, whileStopping?: () => Promise): Promise { + const cdp = await page.context().newCDPSession(page) + let stopRequested = false + let stopped = false + cdp.on('ServiceWorker.workerVersionUpdated', ({ versions }) => { + if (stopRequested && versions.some(version => version.runningStatus === 'stopped')) { + stopped = true + } + }) + try { + await cdp.send('ServiceWorker.enable') + stopRequested = true + await cdp.send('ServiceWorker.stopAllWorkers') + await whileStopping?.() + await expect.poll(() => stopped, { timeout: 15_000 }).toBe(true) + } finally { + await cdp.detach() + } +} + +/** + * Starts the Service Worker with a message, which it does not answer, so that no request keeps it + * from stopping again. + */ +async function wakeServiceWorkerWithMessage(page: Page): Promise { + await page.evaluate(() => { + navigator.serviceWorker.controller?.postMessage({ type: 'vrowzer-test:wake' }) + }) +} + +/** + * Reads a preview file through the Service Worker from the host page. A narrow `Accept` header keeps + * the SPA fallback from answering a missing file with index.html. + */ +async function fetchFromHost( + page: Page, + requestPath: string, + accept = '*/*' +): Promise { + return page.evaluate( + async ({ path, acceptHeader }) => { + const response = await fetch(`/__preview__${path}`, { headers: { Accept: acceptHeader } }) + return { status: response.status, body: await response.text() } + }, + { path: requestPath, acceptHeader: accept } + ) +} + +/** + * Reads a preview file until it is served, and returns the statuses of every response on the way. + * The request that starts the Service Worker can reach the host before the Service Worker has + * started its server, and get the 503 guard page of `@vrowzer/vite-plugin`. + */ +async function fetchUntilServed( + page: Page, + requestPath: string +): Promise { + const statuses: number[] = [] + let response: PreviewResponse | undefined + await expect + .poll( + async () => { + response = await fetchFromHost(page, requestPath, 'text/javascript') + statuses.push(response.status) + return response.status + }, + { timeout: 30_000 } + ) + .toBe(200) + return { ...response!, statuses } +} + +async function fetchBytes( + page: Page, + requestPath: string, + from: 'host' | 'preview' +): Promise<{ status: number; bytes: number[] }> { + return page.evaluate( + async ({ path, inPreview }) => { + const target = inPreview + ? (document.querySelector('#preview-container iframe') as HTMLIFrameElement).contentWindow! + : window + const response = await target.fetch(`/__preview__${path}`) + return { status: response.status, bytes: [...new Uint8Array(await response.arrayBuffer())] } + }, + { path: requestPath, inPreview: from === 'preview' } + ) +} + +async function recordedEvents(page: Page, type: string): Promise { + return page.evaluate( + eventType => + ((window as any).__events__ as RecordedEvent[]).filter(event => event.type === eventType), + type + ) +} + +async function waitForRecoveries(page: Page, count: number): Promise { + await expect + .poll(async () => (await recordedEvents(page, 'serviceWorkerRecovered')).length, { + timeout: 30_000 + }) + .toBe(count) +} + +async function previewText(page: Page, index: number): Promise { + return page.evaluate( + sessionIndex => + ( + document.querySelectorAll('#preview-container iframe')[sessionIndex] as + | HTMLIFrameElement + | undefined + )?.contentDocument?.querySelector('#app')?.textContent ?? '', + index + ) +} + +async function expectPreviewText(page: Page, text: string, index = 0): Promise { + await expect.poll(() => previewText(page, index), { timeout: 30_000 }).toBe(text) +} + +/** + * Sets a value on the window of a preview document. A reload of the preview drops it, an HMR + * update keeps it. + */ +async function markPreview(page: Page, index: number): Promise { + await page.evaluate(sessionIndex => { + const iframe = document.querySelectorAll('#preview-container iframe')[ + sessionIndex + ] as HTMLIFrameElement + ;(iframe.contentWindow as any).__previewMarker__ = 'kept' + }, index) +} + +async function previewMarker(page: Page, index: number): Promise { + return page.evaluate(sessionIndex => { + const iframe = document.querySelectorAll('#preview-container iframe')[ + sessionIndex + ] as HTMLIFrameElement + return (iframe.contentWindow as any)?.__previewMarker__ + }, index) +} + +async function updateMain(page: Page, text: string): Promise { + await page.evaluate(async content => { + const fixture = window as any + await fixture.__vrowzer__.updateFile('/main.js', fixture.__mainSource__(content)) + }, text) +} + +beforeAll(async () => { + await build({ root: FIXTURE_DIR, logLevel: 'silent' }) + server = await preview({ + root: FIXTURE_DIR, + logLevel: 'silent', + preview: { port: 0, strictPort: false } + }) + const address = server.httpServer?.address() + if (typeof address !== 'object' || !address) { + throw new Error('Failed to get restart integration server address') + } + origin = `http://localhost:${address.port}` + browser = await chromium.launch({ headless: true }) +}, 120_000) + +afterAll(async () => { + await browser?.close() + await server?.close() +}) + +describe('Vrowzer Service Worker restart', () => { + test('restores the latest files after the Service Worker restarts', async () => { + const { context, page } = await openFixture() + try { + await page.evaluate(async () => { + const fixture = window as any + const vrowzer = fixture.__vrowzer__ + await vrowzer.updateFile('/main.js', fixture.__mainSource__('main v2')) + await vrowzer.addFile('/added.js', 'export const added = true') + await vrowzer.deleteFile('/remove-me.js') + await vrowzer.updateFile('/public/hello.txt', 'hello v2') + }) + + await stopServiceWorker(page) + const main = await fetchUntilServed(page, '/main.js') + await waitForRecoveries(page, 1) + + // The restarted Service Worker holds requests until it has the project again, so none of them + // gets a 404 or a 500 + expect(main.statuses.filter(status => status !== 200 && status !== 503)).toEqual([]) + expect(main.body).toContain('main v2') + const html = await fetchFromHost(page, '/', 'text/html') + expect(html.status).toBe(200) + expect(html.body).toContain('
') + expect(html.body).toContain('@vite/client') + const added = await fetchFromHost(page, '/added.js', 'text/javascript') + expect(added.status).toBe(200) + expect(added.body).toContain('added = true') + expect((await fetchFromHost(page, '/remove-me.js', 'text/javascript')).status).toBe(404) + expect(await fetchFromHost(page, '/hello.txt')).toEqual({ status: 200, body: 'hello v2' }) + expect(await fetchBytes(page, '/pixel.png', 'host')).toEqual({ + status: 200, + bytes: PIXEL_BYTES + }) + expect(await recordedEvents(page, 'serviceWorkerRecoveryError')).toEqual([]) + } finally { + await context.close() + } + }, 60_000) + + test('keeps editing, HMR, assets and preview reloads working after a restart', async () => { + const { context, page } = await openFixture() + try { + const hostMarker = await page.evaluate(() => (window as any).__hostMarker__) + await stopServiceWorker(page) + await fetchUntilServed(page, '/main.js') + await waitForRecoveries(page, 1) + + // An HMR update reaches the open preview without reloading it + await markPreview(page, 0) + await updateMain(page, 'main v2') + await expectPreviewText(page, 'main v2') + expect(await previewMarker(page, 0)).toBe('kept') + + // The preview loads assets + expect(await fetchBytes(page, '/pixel.png', 'preview')).toEqual({ + status: 200, + bytes: PIXEL_BYTES + }) + + // An explicit reload loads the latest contents in a new document + await page.evaluate(() => (window as any).__vrowzer__.reloadPreview('preview')) + await expect.poll(() => previewMarker(page, 0), { timeout: 30_000 }).toBeUndefined() + await expectPreviewText(page, 'main v2') + + // A preview mounted after the restart gets its HMR port through the new channel + await page.evaluate(() => + (window as any).__vrowzer__.mount(document.getElementById('preview-container'), { + id: 'second' + }) + ) + await expectPreviewText(page, 'main v2', 1) + await markPreview(page, 0) + await markPreview(page, 1) + await updateMain(page, 'main v3') + await expectPreviewText(page, 'main v3', 0) + await expectPreviewText(page, 'main v3', 1) + expect(await previewMarker(page, 0)).toBe('kept') + expect(await previewMarker(page, 1)).toBe('kept') + + expect(await page.evaluate(() => (window as any).__hostMarker__)).toBe(hostMarker) + expect(await recordedEvents(page, 'serviceWorkerRecoveryError')).toEqual([]) + } finally { + await context.close() + } + }, 90_000) + + test('recovers when the Service Worker stops during a pending request', async () => { + const { context, page } = await openFixture() + try { + // The Web Worker holds the load of /held.js, so the request waits in the Service Worker + await page.evaluate(() => { + ;(window as any).__heldRequest__ = fetch('/__preview__/held.js', { + headers: { Accept: 'text/javascript' } + }).then( + response => ({ status: response.status }), + (error: unknown) => ({ error: String(error) }) + ) + }) + await page.waitForTimeout(1000) + + // The Service Worker stops once the pending request ends, so let the Web Worker answer it + await stopServiceWorker(page, async () => { + await page.evaluate(() => (window as any).__vrowzer__.updateFile('/held-release', '')) + }) + + // The pending request ends, with or without a response, instead of waiting forever + const held = await page.evaluate(() => + Promise.race([ + (window as any).__heldRequest__, + new Promise(resolve => setTimeout(() => resolve({ timedOut: true }), 15_000)) + ]) + ) + expect(held).not.toHaveProperty('timedOut') + const response = await fetchUntilServed(page, '/held.js') + expect(response.body).toContain('held = true') + await waitForRecoveries(page, 1) + expect(await recordedEvents(page, 'serviceWorkerRecoveryError')).toEqual([]) + } finally { + await context.close() + } + }, 90_000) + + test('recovers each time the Service Worker restarts', async () => { + const { context, page } = await openFixture() + try { + for (let restart = 1; restart <= 3; restart++) { + await updateMain(page, `main restart ${restart}`) + await stopServiceWorker(page) + + const main = await fetchUntilServed(page, '/main.js') + await waitForRecoveries(page, restart) + + expect(main.body).toContain(`main restart ${restart}`) + } + expect(await recordedEvents(page, 'serviceWorkerRecoveryError')).toEqual([]) + } finally { + await context.close() + } + }, 90_000) + + test('reports a recovery that cannot finish and answers waiting requests with 503', async () => { + const { context, page } = await openFixture({ fileSyncTimeout: 2000 }) + try { + // Without the Web Worker, the channel cannot be connected again + await page.evaluate(() => (window as any).__terminateWebWorkers__()) + await stopServiceWorker(page) + + // Start the Service Worker, and change a file while it is being restored + void fetchFromHost(page, '/main.js').catch(() => {}) + const update = page.evaluate(() => + (window as any).__vrowzer__.updateFile('/main.js', 'export {}').then( + () => 'resolved', + (error: Error) => error.message + ) + ) + await expect + .poll(async () => (await recordedEvents(page, 'serviceWorkerRecoveryError')).length, { + timeout: 15_000 + }) + .toBe(1) + const [error] = await recordedEvents(page, 'serviceWorkerRecoveryError') + expect(error!.message).toContain('timed out after 2000ms') + expect(await update).not.toBe('resolved') + expect(await recordedEvents(page, 'serviceWorkerRecovered')).toEqual([]) + + // A request waits for the project for 10 seconds at most + const startedAt = Date.now() + const response = await fetchFromHost(page, '/main.js', 'text/javascript') + expect(Date.now() - startedAt).toBeLessThan(15_000) + expect(response.status).toBe(503) + expect(response.body).toContain('did not receive the project within 10000ms') + + // The preview reports the failure when it loads again + await page.evaluate(() => (window as any).__vrowzer__.reloadPreview('preview')) + await expect + .poll(async () => (await recordedEvents(page, 'previewLoadError')).length, { + timeout: 20_000 + }) + .toBeGreaterThan(0) + const [loadError] = await recordedEvents(page, 'previewLoadError') + expect(loadError).toMatchObject({ id: 'preview', stage: 'html', status: 503 }) + } finally { + await context.close() + } + }, 90_000) + + test('does not restore an instance disposed during the recovery', async () => { + const { context, page } = await openFixture() + try { + // The listener runs after the one of the runtime, which starts the recovery + await page.evaluate(() => { + const fixture = window as any + fixture.__startNotices__ = 0 + navigator.serviceWorker.addEventListener('message', event => { + if (event.data?.type !== 'V_SW_INSTANCE_STARTED') { + return + } + fixture.__startNotices__++ + if (fixture.__disposeMark__ === undefined) { + fixture.__disposeMark__ = fixture.__serviceWorkerMessages__.length + fixture.__disposed__ = fixture.__vrowzer__.dispose() + } + }) + }) + + await stopServiceWorker(page) + await wakeServiceWorkerWithMessage(page) + await expect + .poll(() => page.evaluate(() => (window as any).__startNotices__), { timeout: 30_000 }) + .toBe(1) + await page.evaluate(() => (window as any).__disposed__) + + // A later restart does not restore it either + await stopServiceWorker(page) + await wakeServiceWorkerWithMessage(page) + await expect + .poll(() => page.evaluate(() => (window as any).__startNotices__), { timeout: 30_000 }) + .toBe(2) + await page.waitForTimeout(500) + + const sentAfterDispose = await page.evaluate(() => { + const fixture = window as any + return (fixture.__serviceWorkerMessages__ as string[]).slice(fixture.__disposeMark__) + }) + expect(sentAfterDispose).not.toContain('V_FS_INIT') + expect(sentAfterDispose).not.toContain('V_WW_CONNECT_PORT') + expect(await recordedEvents(page, 'serviceWorkerRecovered')).toEqual([]) + } finally { + await context.close() + } + }, 90_000) +}) diff --git a/packages/vrowzer/integration/restart/index.html b/packages/vrowzer/integration/restart/index.html new file mode 100644 index 0000000..738cea0 --- /dev/null +++ b/packages/vrowzer/integration/restart/index.html @@ -0,0 +1,12 @@ + + + + + + Vrowzer restart integration + + +
+ + + diff --git a/packages/vrowzer/integration/restart/main.ts b/packages/vrowzer/integration/restart/main.ts new file mode 100644 index 0000000..05de298 --- /dev/null +++ b/packages/vrowzer/integration/restart/main.ts @@ -0,0 +1,94 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Vrowzer } from 'vrowzer' + +import type { VrowzerOptions } from 'vrowzer' + +// Track Vrowzer's Web Workers, so that a test can terminate them. +// Worker is replaced before any Vrowzer instance creates one. +const webWorkers = new Set() + +class TrackedWorker extends Worker { + constructor(scriptURL: string | URL, options?: WorkerOptions) { + super(scriptURL, options) + webWorkers.add(this) + } +} + +window.Worker = TrackedWorker + +// Record the type of each message sent to the Service Worker, in order +const serviceWorkerMessages: string[] = [] +const postMessage = ServiceWorker.prototype.postMessage as (...args: unknown[]) => void +ServiceWorker.prototype.postMessage = function (this: ServiceWorker, ...args: unknown[]) { + const type = (args[0] as { type?: unknown } | null)?.type + serviceWorkerMessages.push(String(type)) + postMessage.apply(this, args) +} as typeof ServiceWorker.prototype.postMessage + +interface RecordedEvent { + type: string + id?: string + stage?: string + status?: number + message?: string +} + +const events: RecordedEvent[] = [] + +function createVrowzer(options?: VrowzerOptions) { + const vrowzer = Vrowzer(options) + vrowzer.on('serviceWorkerRecovered', () => { + events.push({ type: 'serviceWorkerRecovered' }) + }) + vrowzer.on('serviceWorkerRecoveryError', error => { + events.push({ type: 'serviceWorkerRecoveryError', message: error.message }) + }) + vrowzer.on('previewLoadError', info => { + events.push({ + type: 'previewLoadError', + id: info.id, + stage: info.stage, + status: info.status, + message: info.message + }) + }) + return vrowzer +} + +function mainSource(text: string): string { + return `document.querySelector('#app').innerHTML = '

${text}

' +if (import.meta.hot) { import.meta.hot.accept() } +` +} + +// No /index.html: the runtime adds its default, which a restarted Service Worker needs as well +const previewFiles = { + '/main.js': mainSource('main v1'), + '/remove-me.js': 'export const removed = false', + '/held.js': 'export const held = true', + '/public/hello.txt': 'hello v1', + '/public/pixel.png': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff]) + .buffer +} + +Object.assign(window, { + __createVrowzer__: createVrowzer, + __mainSource__: mainSource, + __previewFiles__: previewFiles, + __events__: events, + __serviceWorkerMessages__: serviceWorkerMessages, + __terminateWebWorkers__: () => { + for (const worker of webWorkers) { + worker.terminate() + } + webWorkers.clear() + }, + // Stays as long as the host page is not reloaded + __hostMarker__: crypto.randomUUID() +}) + +document.body.dataset.fixtureReady = 'true' diff --git a/packages/vrowzer/integration/restart/vite.config.ts b/packages/vrowzer/integration/restart/vite.config.ts new file mode 100644 index 0000000..5f9c0e2 --- /dev/null +++ b/packages/vrowzer/integration/restart/vite.config.ts @@ -0,0 +1,59 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Vrowzer as VrowzerPlugin } from '@vrowzer/vite-plugin' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite-plus' + +import type { Plugin } from 'vite-plus' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * Holds the load of `/held.js` in the Web Worker until `/held-release` is written, so that a test + * can stop the Service Worker while a request for it waits for the Web Worker. + */ +function heldModuleWebWorkerPlugin(): Plugin { + let isWebWorker = false + let release: (() => void) | null = null + const held = new Promise(resolve => { + release = resolve + }) + + return { + name: 'vrowzer-test:held-module-web-worker', + apply: 'serve', + configureServer(server) { + const middlewares = (server as { middlewares?: unknown }).middlewares + if (server.config.root !== '/' || middlewares) { + return + } + + isWebWorker = true + }, + watchChange(id) { + if (isWebWorker && id === '/held-release') { + release?.() + } + }, + async load(id) { + if (isWebWorker && id === '/held.js') { + // The default loader reads the file afterwards + await held + } + } + } +} + +export default defineConfig({ + plugins: [ + heldModuleWebWorkerPlugin(), + VrowzerPlugin({ + auto: false, + serviceWorkerEntry: resolve(__dirname, '../../dist/service-worker.ts') + }) + ] +}) diff --git a/packages/vrowzer/src/controller-ready-timeout.test.ts b/packages/vrowzer/src/controller-ready-timeout.test.ts index 48a0316..82880c1 100644 --- a/packages/vrowzer/src/controller-ready-timeout.test.ts +++ b/packages/vrowzer/src/controller-ready-timeout.test.ts @@ -12,7 +12,7 @@ vi.mock('@vrowzer/service-worker/controller', () => ({ createSvcWorkerController: createSvcWorkerControllerMock })) -import { initServiceWorker } from './controller.ts' +import { getServiceWorkerInstanceId, initServiceWorker } from './controller.ts' const options = { scriptURL: new URL('https://example.com/service-worker.js'), @@ -27,6 +27,25 @@ function mockReady(): ReturnType> { return ready } +function mockListeningController() { + const postMessage = vi.fn<(message: unknown) => void>() + const listeners = new Set<(event: MessageEvent) => void>() + const container = { + addEventListener(_type: string, listener: (event: MessageEvent) => void) { + listeners.add(listener) + }, + removeEventListener(_type: string, listener: (event: MessageEvent) => void) { + listeners.delete(listener) + } + } + createSvcWorkerControllerMock.mockReturnValue({ + ready: vi.fn(async () => true), + serviceWorker: { postMessage }, + container + } as unknown as SvcWorkerController) + return { postMessage, listeners } +} + describe('initServiceWorker ready timeout', () => { beforeEach(() => { createSvcWorkerControllerMock.mockReset() @@ -57,25 +76,6 @@ describe('initServiceWorker ready timeout', () => { describe('initServiceWorker abort', () => { const reason = new Error('disposed') - function mockListeningController() { - const postMessage = vi.fn<(message: unknown) => void>() - const listeners = new Set<(event: MessageEvent) => void>() - const container = { - addEventListener(_type: string, listener: (event: MessageEvent) => void) { - listeners.add(listener) - }, - removeEventListener(_type: string, listener: (event: MessageEvent) => void) { - listeners.delete(listener) - } - } - createSvcWorkerControllerMock.mockReturnValue({ - ready: vi.fn(async () => true), - serviceWorker: { postMessage }, - container - } as unknown as SvcWorkerController) - return { postMessage, listeners } - } - beforeEach(() => { createSvcWorkerControllerMock.mockReset() vi.useFakeTimers() @@ -140,3 +140,37 @@ describe('initServiceWorker abort', () => { await expect(initialization).resolves.toBeDefined() }) }) + +describe('initServiceWorker instance id', () => { + beforeEach(() => { + createSvcWorkerControllerMock.mockReset() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + async function initializeWithReply(reply: Record): Promise { + const { listeners } = mockListeningController() + const initialization = initServiceWorker(options) + await vi.advanceTimersByTimeAsync(0) + for (const listener of listeners) { + listener({ data: { type: V_SW_LISTEN_READY, ...reply } } as MessageEvent) + } + await initialization + } + + test('remembers the id of the Service Worker instance that answered', async () => { + await initializeWithReply({ instanceId: 'sw-1' }) + + expect(getServiceWorkerInstanceId()).toBe('sw-1') + }) + + test('has no id when the answer does not carry one', async () => { + await initializeWithReply({ instanceId: 'sw-1' }) + await initializeWithReply({}) + + expect(getServiceWorkerInstanceId()).toBeNull() + }) +}) diff --git a/packages/vrowzer/src/controller.ts b/packages/vrowzer/src/controller.ts index d131ea7..d7b34df 100644 --- a/packages/vrowzer/src/controller.ts +++ b/packages/vrowzer/src/controller.ts @@ -18,6 +18,7 @@ import { abortable } from './abort.ts' import type { SvcWorkerController } from '@vrowzer/service-worker/controller' let controller: SvcWorkerController | null = null +let serviceWorkerInstanceId: string | null = null /** * Get the active Service Worker Controller @@ -33,6 +34,15 @@ export function getServiceWorker(): ServiceWorker | null { return controller?.serviceWorker ?? null } +/** + * Get the ID of the Service Worker instance that answered the last {@link initServiceWorker}. + * The Service Worker creates a new ID each time its script is evaluated, so a different ID means + * that the browser restarted the Service Worker process. + */ +export function getServiceWorkerInstanceId(): string | null { + return serviceWorkerInstanceId +} + /** * Initialize Service Worker and wait for it to be fully ready. * @@ -111,6 +121,8 @@ export async function initServiceWorker(options: { const handler = (event: MessageEvent) => { if (event.data?.type === V_SW_LISTEN_READY) { + const { instanceId } = event.data + serviceWorkerInstanceId = typeof instanceId === 'string' ? instanceId : null stop() resolve() } diff --git a/packages/vrowzer/src/index-dispose.test.ts b/packages/vrowzer/src/index-dispose.test.ts index 77a7980..747ab27 100644 --- a/packages/vrowzer/src/index-dispose.test.ts +++ b/packages/vrowzer/src/index-dispose.test.ts @@ -20,6 +20,7 @@ const runtime = vi.hoisted(() => ({ vi.mock('./controller.ts', () => ({ getController: () => runtime.controller, getServiceWorker: () => runtime.serviceWorker, + getServiceWorkerInstanceId: () => null, initServiceWorker: (options: { signal?: AbortSignal }) => runtime.initServiceWorker(options) })) vi.mock('@vrowzer/fs/watcher', () => ({ @@ -333,7 +334,8 @@ describe('Vrowzer dispose', () => { await waitForChannelHandshake(worker) expect(controller.subscriptionCount).toBe(6) expect(publisher.targets.size).toBe(2) - expect(controller.container.listeners.size).toBe(1) + // The handshake and the Service Worker instances that start + expect(controller.container.listeners.size).toBe(2) await vrowzer.dispose() @@ -370,7 +372,8 @@ describe('Vrowzer dispose', () => { test('rejects the file operations that wait for the Workers', async () => { const vrowzer = Vrowzer() const worker = await readyFully(vrowzer) - expect(controller.container.listeners.size).toBe(1) + // The file sync acknowledgements and the Service Worker instances that start + expect(controller.container.listeners.size).toBe(2) const updating = vrowzer.updateFile('/a.js', 'updated') const deleting = vrowzer.deleteFile('/b.js') diff --git a/packages/vrowzer/src/index-file-sync.test.ts b/packages/vrowzer/src/index-file-sync.test.ts index 66c1594..e36719a 100644 --- a/packages/vrowzer/src/index-file-sync.test.ts +++ b/packages/vrowzer/src/index-file-sync.test.ts @@ -33,6 +33,7 @@ const controllerMocks = vi.hoisted(() => ({ vi.mock('./controller.ts', () => ({ getController: () => controllerMocks, getServiceWorker: () => ({ postMessage: controllerMocks.postMessage }), + getServiceWorkerInstanceId: () => 'sw-1', initServiceWorker: controllerMocks.initServiceWorker })) vi.mock('@vrowzer/vite-dev-server/dist/client/client.mjs?raw', () => ({ default: 'client code' })) @@ -184,7 +185,7 @@ describe('Vrowzer file synchronization', () => { ]) expect(controllerMocks.postMessage.mock.calls.map(([message]) => message)).toEqual([ { type: 'V_FS_INIT', files: initialFiles }, - { type: V_WW_CONNECT_PORT } + { type: V_WW_CONNECT_PORT, runtimeId: expect.any(String) } ]) }) diff --git a/packages/vrowzer/src/index-ready-timeout.test.ts b/packages/vrowzer/src/index-ready-timeout.test.ts index 3450077..5a45528 100644 --- a/packages/vrowzer/src/index-ready-timeout.test.ts +++ b/packages/vrowzer/src/index-ready-timeout.test.ts @@ -4,6 +4,7 @@ import { V_WW_READY, V_WW_SETUP, V_WW_SETUP_ACK } from '@vrowzer/vite-dev-server const controllerMocks = vi.hoisted(() => ({ getController: vi.fn<() => null>(() => null), getServiceWorker: vi.fn<() => null>(() => null), + getServiceWorkerInstanceId: vi.fn<() => null>(() => null), initServiceWorker: vi.fn<() => Promise>(async () => undefined) })) diff --git a/packages/vrowzer/src/index-recovery.test.ts b/packages/vrowzer/src/index-recovery.test.ts new file mode 100644 index 0000000..0f7093f --- /dev/null +++ b/packages/vrowzer/src/index-recovery.test.ts @@ -0,0 +1,524 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test' +import { + V_SW_CONNECT_PORT, + V_SW_CONNECT_PORT_ACK, + V_SW_INSTANCE_STARTED, + V_WW_CONNECT_PORT, + V_WW_CONNECT_PORT_ACK, + V_WW_READY, + V_WW_SETUP, + V_WW_SETUP_ACK +} from '@vrowzer/vite-dev-server/messages' + +interface TestMessage { + type: string + id?: string + path?: string + content?: unknown + files?: Record + binaryFiles?: Record + runtimeId?: string +} + +interface AckError { + name: string + message: string +} + +const controllerMocks = vi.hoisted(() => ({ + container: new EventTarget(), + on: vi.fn<(...args: unknown[]) => () => void>(() => () => {}), + postMessage: vi.fn<(message: TestMessage, transfer?: Transferable[]) => void>(), + initServiceWorker: vi.fn<() => Promise>(async () => undefined) +})) + +vi.mock('./controller.ts', () => ({ + getController: () => controllerMocks, + getServiceWorker: () => ({ postMessage: controllerMocks.postMessage }), + getServiceWorkerInstanceId: () => 'sw-1', + initServiceWorker: controllerMocks.initServiceWorker +})) +vi.mock('@vrowzer/vite-dev-server/dist/client/client.mjs?raw', () => ({ default: 'client code' })) +vi.mock('@vrowzer/vite-dev-server/dist/client/env.mjs?raw', () => ({ default: 'env code' })) + +import { Vrowzer, type VrowzerOptions } from './index.ts' + +let workers: TestWorker[] +// What the fake Workers answer by themselves +let replies: { webWorkerAcks: boolean; serviceWorkerAcks: boolean; channel: boolean } + +class TestWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly messages: TestMessage[] = [] + + constructor() { + workers.push(this) + this.reply({ type: V_WW_READY }) + } + + postMessage(message: TestMessage): void { + this.messages.push(message) + if (message.type === V_WW_SETUP) { + this.reply({ type: V_WW_SETUP_ACK }) + } else if (message.type === V_SW_CONNECT_PORT) { + if (replies.channel) { + this.reply({ type: V_SW_CONNECT_PORT_ACK }) + } + } else if (replies.webWorkerAcks && message.id !== undefined) { + this.reply({ type: 'V_FS_ACK', id: message.id }) + } + } + + private reply(data: object): void { + queueMicrotask(() => this.onmessage?.({ data } as MessageEvent)) + } + + terminate(): void {} +} + +function fromServiceWorker(data: object): void { + controllerMocks.container.dispatchEvent(new MessageEvent('message', { data })) +} + +function ackFromServiceWorker(id: string, error?: AckError): void { + fromServiceWorker({ type: 'V_FS_ACK', id, ...(error ? { error } : {}) }) +} + +function startServiceWorkerInstance(instanceId: string): void { + fromServiceWorker({ type: V_SW_INSTANCE_STARTED, instanceId }) +} + +function serviceWorkerMessages(type?: string): TestMessage[] { + return controllerMocks.postMessage.mock.calls + .map(([message]) => message) + .filter(message => type === undefined || message.type === type) +} + +function webWorkerMessages(worker: TestWorker, type: string): TestMessage[] { + return worker.messages.filter(message => message.type === type) +} + +function fileChanges(messages: TestMessage[]): TestMessage[] { + return messages.filter(message => message.type === 'V_FS_WRITE' || message.type === 'V_FS_UNLINK') +} + +/** + * The V_FS_INIT messages that restore the Service Worker. The one of ready() has no id. + */ +function recoveryInits(): (TestMessage & { id: string })[] { + return serviceWorkerMessages('V_FS_INIT').filter( + (message): message is TestMessage & { id: string } => message.id !== undefined + ) +} + +async function flush(): Promise { + for (let index = 0; index < 20; index++) { + await Promise.resolve() + } +} + +async function isPending(promise: Promise): Promise { + let settled = false + promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await flush() + return !settled +} + +async function readyInstance( + files: Record = {}, + options?: VrowzerOptions +) { + const vrowzer = Vrowzer(options) + const recovered = vi.fn<() => void>() + const recoveryErrors = vi.fn<(error: Error) => void>() + vrowzer.on('serviceWorkerRecovered', recovered) + vrowzer.on('serviceWorkerRecoveryError', recoveryErrors) + await expect(vrowzer.ready({ files })).resolves.toBe(true) + return { vrowzer, worker: workers.at(-1)!, recovered, recoveryErrors } +} + +beforeEach(() => { + vi.clearAllMocks() + workers = [] + replies = { webWorkerAcks: true, serviceWorkerAcks: true, channel: true } + controllerMocks.container = new EventTarget() + vi.stubGlobal('Worker', TestWorker) + vi.stubGlobal( + 'MessageChannel', + class { + port1 = {} + port2 = {} + } + ) + controllerMocks.postMessage.mockImplementation(message => { + if (message.type === V_WW_CONNECT_PORT) { + if (replies.channel) { + queueMicrotask(() => fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK })) + } + return + } + const id = message.id + if (replies.serviceWorkerAcks && id !== undefined) { + queueMicrotask(() => ackFromServiceWorker(id)) + } + }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Vrowzer Service Worker recovery', () => { + describe('detection', () => { + test('restores the project when a Service Worker instance with another id starts', async () => { + const { worker, recovered, recoveryErrors } = await readyInstance({ '/main.js': 'export {}' }) + controllerMocks.postMessage.mockClear() + worker.messages.length = 0 + + startServiceWorkerInstance('sw-2') + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + expect(recoveryErrors).not.toHaveBeenCalled() + expect(serviceWorkerMessages().map(message => message.type)).toEqual([ + 'V_FS_INIT', + V_WW_CONNECT_PORT + ]) + expect(worker.messages.map(message => message.type)).toEqual([V_SW_CONNECT_PORT]) + }) + + test('ignores a start notice of the instance it knows', async () => { + const { recovered } = await readyInstance() + controllerMocks.postMessage.mockClear() + + startServiceWorkerInstance('sw-1') + await flush() + + expect(serviceWorkerMessages()).toEqual([]) + expect(recovered).not.toHaveBeenCalled() + }) + + test('ignores start notices after ready() failed', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + controllerMocks.postMessage.mockImplementationOnce(() => { + throw new Error('cannot post') + }) + const vrowzer = Vrowzer() + await expect(vrowzer.ready({ files: {} })).resolves.toBe(false) + controllerMocks.postMessage.mockClear() + + startServiceWorkerInstance('sw-2') + await flush() + + expect(serviceWorkerMessages()).toEqual([]) + }) + + test('ignores start notices after dispose()', async () => { + const { vrowzer } = await readyInstance() + await vrowzer.dispose() + controllerMocks.postMessage.mockClear() + + startServiceWorkerInstance('sw-2') + await flush() + + expect(serviceWorkerMessages()).toEqual([]) + }) + + test('restores the project after ready() when an instance started during ready()', async () => { + const postMessage = controllerMocks.postMessage.getMockImplementation()! + controllerMocks.postMessage.mockImplementation((message, transfer) => { + // The Service Worker restarts right after it got the files of ready() + if (message.type === 'V_FS_INIT' && message.id === undefined) { + startServiceWorkerInstance('sw-2') + } + postMessage(message, transfer) + }) + + const { recovered } = await readyInstance() + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + expect(recoveryInits()).toHaveLength(1) + }) + }) + + describe('restored contents', () => { + test('sends the latest files to the Service Worker only, with an id', async () => { + const { vrowzer, worker, recovered } = await readyInstance({ + '/a.js': 'a', + '/b.bin': new Uint8Array([1, 2]).buffer, + '/c.js': 'c' + }) + await vrowzer.addFile('/d.js', 'd') + await vrowzer.updateFile('/a.js', 'a2') + await vrowzer.deleteFile('/c.js') + await vrowzer.updateFile('/b.bin', new Uint8Array([3, 4]).buffer) + + startServiceWorkerInstance('sw-2') + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + const [init] = recoveryInits() + expect(init).toEqual({ + type: 'V_FS_INIT', + id: expect.any(String), + files: { + '/a.js': 'a2', + '/d.js': 'd', + '/index.html': expect.stringContaining('
'), + '/dist/client/client.mjs': 'client code', + '/dist/client/env.mjs': 'env code' + }, + binaryFiles: { '/b.bin': expect.any(ArrayBuffer) } + }) + expect([...new Uint8Array(init!.binaryFiles!['/b.bin']!)]).toEqual([3, 4]) + expect(webWorkerMessages(worker, 'V_FS_INIT')).toEqual([]) + }) + + test('connects the Web Worker channel again after the Service Worker applied the files', async () => { + const { worker, recovered } = await readyInstance() + const [firstConnection] = serviceWorkerMessages(V_WW_CONNECT_PORT) + replies.serviceWorkerAcks = false + + startServiceWorkerInstance('sw-2') + await flush() + expect(recoveryInits()).toHaveLength(1) + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) + expect(webWorkerMessages(worker, V_SW_CONNECT_PORT)).toHaveLength(1) + + ackFromServiceWorker(recoveryInits()[0]!.id) + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + const connections = serviceWorkerMessages(V_WW_CONNECT_PORT) + expect(connections).toHaveLength(2) + expect(firstConnection!.runtimeId).toEqual(expect.any(String)) + expect(connections[1]!.runtimeId).toBe(firstConnection!.runtimeId) + expect(webWorkerMessages(worker, V_SW_CONNECT_PORT)).toHaveLength(2) + }) + }) + + describe('file operations', () => { + test('settles an operation sent before the restart when the restored files are applied', async () => { + const { vrowzer, recovered } = await readyInstance() + replies.serviceWorkerAcks = false + const updating = vrowzer.updateFile('/a.js', 'v1') + expect(await isPending(updating)).toBe(true) + + startServiceWorkerInstance('sw-2') + await flush() + const [init] = recoveryInits() + expect(init!.files!['/a.js']).toBe('v1') + expect(await isPending(updating)).toBe(true) + + ackFromServiceWorker(init!.id) + + await expect(updating).resolves.toBeUndefined() + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + }) + + test('sends the operations called during the recovery after it, in order', async () => { + const { vrowzer, worker, recovered } = await readyInstance({ '/a.js': 'v1', '/b.js': 'b' }) + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + + const updating = vrowzer.updateFile('/a.js', 'v2') + const deleting = vrowzer.deleteFile('/b.js') + await flush() + expect(fileChanges(serviceWorkerMessages())).toEqual([]) + expect(fileChanges(worker.messages)).toEqual([]) + const [init] = recoveryInits() + expect(init!.files).toMatchObject({ '/a.js': 'v1', '/b.js': 'b' }) + + replies.serviceWorkerAcks = true + ackFromServiceWorker(init!.id) + + await expect(updating).resolves.toBeUndefined() + await expect(deleting).resolves.toBeUndefined() + expect(recovered).toHaveBeenCalledOnce() + const expected = [ + expect.objectContaining({ type: 'V_FS_WRITE', path: '/a.js', content: 'v2' }), + expect.objectContaining({ type: 'V_FS_UNLINK', path: '/b.js' }) + ] + expect(fileChanges(serviceWorkerMessages())).toEqual(expected) + expect(fileChanges(worker.messages)).toEqual(expected) + }) + + test('counts the timeout of a held operation from when it is sent', async () => { + vi.useFakeTimers() + const { vrowzer } = await readyInstance({}, { fileSyncTimeout: 1000 }) + replies.serviceWorkerAcks = false + replies.webWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + const updating = vrowzer.updateFile('/a.js', 'v2') + const result = updating.catch((error: unknown) => error) + + await vi.advanceTimersByTimeAsync(900) + ackFromServiceWorker(recoveryInits()[0]!.id) + await flush() + expect(fileChanges(serviceWorkerMessages())).toHaveLength(1) + await vi.advanceTimersByTimeAsync(999) + expect(await isPending(updating)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + + expect(await result).toEqual( + expect.objectContaining({ message: expect.stringContaining('timed out after 1000ms') }) + ) + }) + + test('copies binary content when the operation is called', async () => { + const { vrowzer, recovered } = await readyInstance() + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + const buffer = new Uint8Array([1, 2, 3]).buffer + + const writing = vrowzer.updateFile('/data.bin', buffer) + new Uint8Array(buffer).fill(9) + replies.serviceWorkerAcks = true + ackFromServiceWorker(recoveryInits()[0]!.id) + + await expect(writing).resolves.toBeUndefined() + const [message] = fileChanges(serviceWorkerMessages()) + expect([...new Uint8Array(message!.content as ArrayBuffer)]).toEqual([1, 2, 3]) + // The copy kept to restore the Service Worker has the same bytes + startServiceWorkerInstance('sw-3') + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledTimes(2) + }) + expect([...new Uint8Array(recoveryInits()[1]!.binaryFiles!['/data.bin']!)]).toEqual([1, 2, 3]) + }) + }) + + describe('failures', () => { + test('reports a recovery that does not finish within fileSyncTimeout', async () => { + vi.useFakeTimers() + const { vrowzer, recovered, recoveryErrors } = await readyInstance( + {}, + { fileSyncTimeout: 1000 } + ) + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + const result = vrowzer.updateFile('/a.js', 'v2').catch((error: unknown) => error) + + await vi.advanceTimersByTimeAsync(1000) + + expect(recoveryErrors).toHaveBeenCalledOnce() + const [error] = recoveryErrors.mock.calls[0]! + expect(error).toBeInstanceOf(Error) + expect(error.message).toContain('timed out after 1000ms') + expect(recovered).not.toHaveBeenCalled() + expect(await result).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/updateFile\("\/a\.js"\).*restarted Service Worker/) + }) + ) + expect(fileChanges(serviceWorkerMessages())).toEqual([]) + }) + + test('reports a Service Worker that fails to apply the restored files', async () => { + const { vrowzer, recovered, recoveryErrors } = await readyInstance() + replies.serviceWorkerAcks = false + const sent = vrowzer.updateFile('/a.js', 'v1') + startServiceWorkerInstance('sw-2') + await flush() + const held = vrowzer.updateFile('/a.js', 'v2') + + ackFromServiceWorker(recoveryInits()[0]!.id, { name: 'Error', message: 'disk full' }) + + await expect(sent).rejects.toThrow('disk full') + await expect(held).rejects.toThrow('disk full') + expect(recoveryErrors).toHaveBeenCalledOnce() + expect(recoveryErrors.mock.calls[0]![0].message).toContain('disk full') + expect(recovered).not.toHaveBeenCalled() + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) + }) + + test('sends operations as usual after a failed recovery, and tries again on the next start', async () => { + const { vrowzer, recovered, recoveryErrors } = await readyInstance() + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + ackFromServiceWorker(recoveryInits()[0]!.id, { name: 'Error', message: 'disk full' }) + await vi.waitFor(() => { + expect(recoveryErrors).toHaveBeenCalledOnce() + }) + + replies.serviceWorkerAcks = true + await expect(vrowzer.updateFile('/a.js', 'after the failure')).resolves.toBeUndefined() + startServiceWorkerInstance('sw-3') + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + expect(recoveryInits()[1]!.files!['/a.js']).toBe('after the failure') + }) + }) + + describe('overlaps', () => { + test('restores the newest instance when another one starts during a recovery', async () => { + const { vrowzer, recovered, recoveryErrors } = await readyInstance() + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + startServiceWorkerInstance('sw-3') + await flush() + const [first, second] = recoveryInits() + expect(second).toBeDefined() + + ackFromServiceWorker(first!.id) + await flush() + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) + ackFromServiceWorker(second!.id) + + await vi.waitFor(() => { + expect(recovered).toHaveBeenCalledOnce() + }) + expect(recoveryErrors).not.toHaveBeenCalled() + // The acknowledgements of the Web Worker still arrive + replies.serviceWorkerAcks = true + await expect(vrowzer.updateFile('/a.js', 'v2')).resolves.toBeUndefined() + }) + }) + + describe('dispose()', () => { + test('stops the recovery and sends nothing more', async () => { + const { vrowzer } = await readyInstance() + replies.serviceWorkerAcks = false + startServiceWorkerInstance('sw-2') + await flush() + const held = vrowzer.updateFile('/a.js', 'v2') + + const disposing = vrowzer.dispose() + await expect(held).rejects.toThrow('cancelled by dispose()') + ackFromServiceWorker(recoveryInits()[0]!.id) + await disposing + await flush() + + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) + expect(fileChanges(serviceWorkerMessages())).toEqual([]) + startServiceWorkerInstance('sw-3') + await flush() + expect(recoveryInits()).toHaveLength(1) + }) + }) +}) diff --git a/packages/vrowzer/src/index-web-worker-timeout.test.ts b/packages/vrowzer/src/index-web-worker-timeout.test.ts index 83c6ba2..d09d713 100644 --- a/packages/vrowzer/src/index-web-worker-timeout.test.ts +++ b/packages/vrowzer/src/index-web-worker-timeout.test.ts @@ -9,6 +9,7 @@ import { const controllerMocks = vi.hoisted(() => ({ getController: vi.fn<() => null>(() => null), getServiceWorker: vi.fn<() => null>(() => null), + getServiceWorkerInstanceId: vi.fn<() => null>(() => null), initServiceWorker: vi.fn<() => Promise>(async () => undefined) })) diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 1bf58e0..035aef0 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -50,10 +50,16 @@ import { V_WW_CONNECT_PORT, V_SW_CONNECT_PORT, V_WW_CONNECT_PORT_ACK, - V_SW_CONNECT_PORT_ACK + V_SW_CONNECT_PORT_ACK, + V_SW_INSTANCE_STARTED } from '@vrowzer/vite-dev-server/messages' import { abortable } from './abort.ts' -import { getServiceWorker, getController, initServiceWorker } from './controller.ts' +import { + getServiceWorker, + getController, + getServiceWorkerInstanceId, + initServiceWorker +} from './controller.ts' import { resolvePreviewBasePath } from './preview-base.ts' import { resolveServiceWorkerScope } from './service-worker-scope.ts' import { resolveServiceWorkerVersion, withServiceWorkerVersion } from './service-worker-version.ts' @@ -102,6 +108,11 @@ type FileOperation = 'addFile' | 'updateFile' | 'deleteFile' type FileSyncTarget = 'Web Worker' | 'Service Worker' +/** + * Sends a file operation to the Workers, with its operation id. + */ +type SendFileOperation = (options: { id: string }) => void + /** * VrowzerOptions defines the configuration options for {@link Vrowzer}. */ @@ -157,6 +168,9 @@ export interface VrowzerOptions { * request, so with slow plugins or heavy transforms an operation can reject although the change * is applied later. Increase it in such environments. Writing the file again resynchronizes it. * + * It also limits how long Vrowzer takes to restore the project in a restarted Service Worker. See + * {@link VrowzerEventMap.serviceWorkerRecoveryError}. + * * @default 10000 */ fileSyncTimeout?: number @@ -280,7 +294,7 @@ declare global { * Event map for {@link Vrowzer}. * * Forwards all {@link SvcWorkerControllerEventMap} events from the underlying Service Worker controller, - * and adds events for preview sessions. + * and adds events for preview sessions and for restoring a restarted Service Worker. */ export type VrowzerEventMap = SvcWorkerControllerEventMap & { /** @@ -292,6 +306,28 @@ export type VrowzerEventMap = SvcWorkerControllerEventMap & { * Payload is {@link PreviewLoadErrorInfo} */ previewLoadError: PreviewLoadErrorInfo + /** + * Emitted when Vrowzer has restored the project in a restarted Service Worker. + * + * The browser stops an idle Service Worker and starts it again for the next request or message. + * The restarted Service Worker has lost the files and the Web Worker channel, so Vrowzer sends the + * latest files and connects the channel again, without reloading the host page. File operations + * called in the meantime are sent after the project is restored. + */ + serviceWorkerRecovered: void + /** + * Emitted when Vrowzer could not restore the project in a restarted Service Worker: the Service + * Worker failed to apply the files, or the recovery did not finish within + * {@link VrowzerOptions.fileSyncTimeout}. + * + * File operations waiting for the Service Worker reject. Preview requests that the Service Worker + * cannot serve yet wait for up to 10 seconds, and then get a 503 response. Vrowzer tries again when + * the Service Worker restarts the next time. To start over, dispose the instance and create a new + * one. + * + * Payload is the `Error` that describes the failure. + */ + serviceWorkerRecoveryError: Error } /** @@ -359,6 +395,9 @@ export interface Vrowzer extends Emittable { * {@link VrowzerOptions.fileSyncTimeout}, or when the instance is disposed first. The change may * be partly applied then; write the file again to resynchronize. * + * While Vrowzer restores the project in a restarted Service Worker, the change is held and sent + * afterwards, and the timeout counts from then. It rejects when the restoration fails. + * * @param filePath - The path of the file to be added. * @param content - The content of the file, which can be a string or an ArrayBuffer. An * ArrayBuffer is copied for the Workers and stays usable. @@ -386,10 +425,11 @@ export interface Vrowzer extends Emittable { /** * Disposes this instance. * - * An in-progress {@link Vrowzer.ready} is aborted and resolves to `false`. Every preview session - * is unmounted, the Web Worker is terminated, Service Worker controller events are no longer - * forwarded, and all event handlers are removed right away. The Service Worker registration and - * its virtual filesystem are kept for other clients. + * An in-progress {@link Vrowzer.ready} is aborted and resolves to `false`, and a restoration of a + * restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is + * terminated, Service Worker controller events are no longer forwarded, and all event handlers are + * removed right away. The Service Worker registration and its virtual filesystem are kept for + * other clients. * * File operations still waiting for the Workers reject. After disposal, `ready()` and the file * methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a @@ -427,7 +467,15 @@ interface PendingFileOperation { waitingFor: Set resolve: () => void reject: (error: Error) => void - timer: ReturnType + /** + * Whether the operation has been sent. An operation called while a restarted Service Worker is + * being restored is sent after the recovery. + */ + sent: boolean + /** + * Started when the operation is sent. + */ + timer?: ReturnType } interface PreviewSessionRecord { @@ -635,6 +683,20 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { // File operations waiting for acknowledgements, by operation id const pendingFileOperations = new Map() let stopListeningServiceWorkerAcks: (() => void) | null = null + // Marks this instance as the owner of the Web Worker channel, for when several instances share + // one Service Worker. The Service Worker does not use it yet. Created when ready() starts. + let runtimeId: string | null = null + // The latest files given to the Workers, to restore a restarted Service Worker + const projectFiles = new Map() + // The Service Worker instance that has the project of this instance + let serviceWorkerInstanceId: string | null = null + // A Service Worker instance that started while ready() was in progress + let instanceStartedDuringInit: string | null = null + let stopListeningServiceWorkerStarts: (() => void) | null = null + // The recovery of a restarted Service Worker in progress + let recovery: AbortController | null = null + // File operations called during a recovery, sent in order after it + const heldFileOperations: { id: string; send: SendFileOperation }[] = [] function cleanupWebWorker(): void { const worker = webWorker @@ -659,6 +721,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { for (const stop of controllerSubscriptions.splice(0)) { attempt(errors, stop) } + const stopListeningStarts = stopListeningServiceWorkerStarts + stopListeningServiceWorkerStarts = null + if (stopListeningStarts) { + attempt(errors, stopListeningStarts) + } const stopListeningAcks = stopListeningServiceWorkerAcks stopListeningServiceWorkerAcks = null if (stopListeningAcks) { @@ -758,13 +825,23 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } } + function couldNotSend(operation: FileOperation, path: string, error: unknown): Error { + return new Error( + `${describeFileOperation(operation, path)} could not be sent: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ) + } + /** * Sends a file operation to the Workers and waits until both have applied it. + * + * `prepare` runs right away and returns the step that sends the operation. While a restarted + * Service Worker is being restored, that step runs after the recovery. */ function syncFile( operation: FileOperation, path: string, - send: (options: { id: string }) => void + prepare: () => SendFileOperation ): Promise { if (readyState === 'disposed') { return Promise.reject(new Error(`[Vrowzer] ${operation}() cannot be called after dispose()`)) @@ -777,6 +854,13 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { ) } + let send: SendFileOperation + try { + send = prepare() + } catch (error) { + return Promise.reject(couldNotSend(operation, path, error)) + } + const waitingFor = new Set() if (webWorker) { waitingFor.add('Web Worker') @@ -785,37 +869,232 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { waitingFor.add('Service Worker') } const id = crypto.randomUUID() - const timeout = resolved.fileSyncTimeout return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const pending = pendingFileOperations.get(id) - if (pending) { - const targets = [...pending.waitingFor].map(target => `the ${target}`).join(' and ') - settleFileOperation( - id, - new Error( - `${describeFileOperation(operation, path)} timed out after ${timeout}ms waiting for ${targets}` - ) - ) - } - }, timeout) - pendingFileOperations.set(id, { operation, path, waitingFor, resolve, reject, timer }) + pendingFileOperations.set(id, { operation, path, waitingFor, resolve, reject, sent: false }) + if (recovery) { + // Sent after the recovery, in the order the operations were called + heldFileOperations.push({ id, send }) + return + } + sendFileOperation(id, send) + }) + } - try { - send({ id }) - } catch (error) { + /** + * Sends a pending file operation, and waits for the replies up to `fileSyncTimeout`. + */ + function sendFileOperation(id: string, send: SendFileOperation): void { + const pending = pendingFileOperations.get(id) + // dispose() may have rejected an operation held during a recovery + if (!pending) { + return + } + const { operation, path } = pending + const timeout = resolved.fileSyncTimeout + pending.timer = setTimeout(() => { + const current = pendingFileOperations.get(id) + if (current) { + const targets = [...current.waitingFor].map(target => `the ${target}`).join(' and ') settleFileOperation( id, new Error( - `${describeFileOperation(operation, path)} could not be sent: ${error instanceof Error ? error.message : String(error)}`, - { cause: error } + `${describeFileOperation(operation, path)} timed out after ${timeout}ms waiting for ${targets}` + ) + ) + } + }, timeout) + pending.sent = true + + try { + send({ id }) + } catch (error) { + settleFileOperation(id, couldNotSend(operation, path, error)) + } + } + + /** + * Copies the content of a write now, since a write called during a recovery is sent later. The + * returned step sends the copy and keeps it, to restore a restarted Service Worker. + */ + function prepareWrite(path: string, content: string | ArrayBuffer): SendFileOperation { + const copied = typeof content === 'string' ? content : content.slice(0) + return options => { + publisher.writeFile(path, copied, options) + projectFiles.set(path, copied) + } + } + + /** + * Listens for the Service Worker instances that start, to restore the project in a restarted one. + */ + function listenServiceWorkerStarts(): void { + const container = getController()?.container + if (!container) { + return + } + const handler = (event: MessageEvent) => { + const data: unknown = event.data + if ( + isRecord(data) && + data.type === V_SW_INSTANCE_STARTED && + typeof data.instanceId === 'string' + ) { + handleServiceWorkerStarted(data.instanceId) + } + } + container.addEventListener('message', handler) + stopListeningServiceWorkerStarts = () => container.removeEventListener('message', handler) + } + + function handleServiceWorkerStarted(instanceId: string): void { + if (readyState === 'initializing') { + // initialize() restores the project once ready() completes + instanceStartedDuringInit = instanceId + return + } + if (readyState === 'ready' && instanceId !== serviceWorkerInstanceId) { + recoverServiceWorker(instanceId) + } + } + + /** + * Restores the project of this instance in a restarted Service Worker, which has lost the files + * and the Web Worker channel. File operations called in the meantime are held and sent after the + * recovery. A recovery that does not finish within `fileSyncTimeout` fails, and is tried again + * when the Service Worker restarts the next time. + */ + function recoverServiceWorker(instanceId: string): void { + serviceWorkerInstanceId = instanceId + // A newer Service Worker instance started, so stop restoring the previous one + recovery?.abort(new Error('[Vrowzer] A newer Service Worker instance started')) + const controller = new AbortController() + recovery = controller + const timeout = resolved.fileSyncTimeout + const timer = setTimeout(() => { + controller.abort(new Error(`timed out after ${timeout}ms`)) + }, timeout) + + void restoreServiceWorker(controller.signal) + .then( + () => { + if (recovery !== controller) { + return + } + recovery = null + for (const { id, send } of heldFileOperations.splice(0)) { + sendFileOperation(id, send) + } + _emitter.emit('serviceWorkerRecovered', undefined) + }, + (error: unknown) => { + // A newer instance or dispose() stopped this recovery + if (recovery !== controller) { + return + } + recovery = null + heldFileOperations.length = 0 + const reason = error instanceof Error ? error.message : String(error) + // The held operations wait for the Service Worker as well + rejectFileOperations( + pending => + new Error( + `${describeFileOperation(pending.operation, pending.path)} failed because the restarted Service Worker could not be restored: ${reason}`, + { cause: error } + ), + 'Service Worker' ) + _emitter.emit( + 'serviceWorkerRecoveryError', + new Error( + `[Vrowzer] Could not restore the project in the restarted Service Worker: ${reason}`, + { cause: error } + ) + ) + } + ) + .finally(() => clearTimeout(timer)) + } + + /** + * Sends the latest files to the restarted Service Worker with V_FS_INIT, and connects the Web + * Worker channel again once it has applied them. + */ + async function restoreServiceWorker(signal: AbortSignal): Promise { + const serviceWorker = getServiceWorker() + if (!serviceWorker) { + throw new Error('the Service Worker is not available') + } + // The operations sent so far are in the copy of the files, so the restarted Service Worker + // applies them with V_FS_INIT. The previous instance can no longer acknowledge them. + const coveredOperations = [...pendingFileOperations] + .filter(([, pending]) => pending.sent && pending.waitingFor.has('Service Worker')) + .map(([id]) => id) + const id = crypto.randomUUID() + serviceWorker.postMessage({ ...createFSInitMessage(Object.fromEntries(projectFiles)), id }) + await waitForServiceWorkerAck(id, signal) + for (const operationId of coveredOperations) { + handleFileSyncAck('Service Worker', { type: V_FS_ACK, id: operationId }) + } + + await establishChannel(signal) + // The recovery may have been stopped while the last acknowledgement was being delivered + signal.throwIfAborted() + } + + /** + * Waits for the Service Worker to acknowledge the message with `id`. + */ + function waitForServiceWorkerAck(id: string, signal: AbortSignal): Promise { + const container = getController()?.container + if (!container) { + return Promise.reject(new Error('the Service Worker is not available')) + } + if (signal.aborted) { + return Promise.reject(signal.reason) + } + return new Promise((resolve, reject) => { + const stop = () => { + container.removeEventListener('message', handler) + signal.removeEventListener('abort', onAbort) + } + const onAbort = () => { + stop() + reject(signal.reason) + } + const handler = (event: MessageEvent) => { + const data: unknown = event.data + if (!isRecord(data) || data.type !== V_FS_ACK || data.id !== id) { + return + } + stop() + if (data.error === undefined) { + resolve() + return + } + const cause = toWorkerError(data.error) + reject( + new Error(`the Service Worker failed to apply the restored files: ${cause.message}`, { + cause + }) ) } + container.addEventListener('message', handler) + signal.addEventListener('abort', onAbort, { once: true }) }) } + /** + * Stops the recovery in progress without reporting it. The held file operations are rejected by + * the caller. + */ + function stopRecovery(reason: Error): void { + const current = recovery + recovery = null + heldFileOperations.length = 0 + current?.abort(reason) + } + /** * Establish MessageChannel between Service Worker and Web Worker. * Creates a MessageChannel, sends one port to each side, @@ -867,7 +1146,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { }) // Transfer ports - serviceWorker.postMessage({ type: V_WW_CONNECT_PORT }, [channel.port1]) + serviceWorker.postMessage({ type: V_WW_CONNECT_PORT, runtimeId }, [channel.port1]) worker.postMessage({ type: V_SW_CONNECT_PORT }, [channel.port2]) try { @@ -1108,6 +1387,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } async function initialize(config: VrowzerConfig, signal: AbortSignal): Promise { + runtimeId = crypto.randomUUID() try { // The files are sent later, so copy them as they are when ready() is called const initialFiles = copyInitialFiles(config.files) @@ -1215,6 +1495,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { signal ) + // 5. Remember the Service Worker instance, and listen for the instances that start later. + // A restarted Service Worker has lost the project of this instance. + serviceWorkerInstanceId = getServiceWorkerInstanceId() + listenServiceWorkerStarts() + // 6. Forward controller events to Vrowzer emitter const controller = getController() if (controller) { @@ -1235,7 +1520,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } } - // 7. Initialize Service Worker files and subscribe it to later changes. + // 7. Initialize Service Worker files and subscribe it to later changes. Keep the files, to + // restore a restarted Service Worker. + for (const [path, content] of Object.entries(allFiles)) { + projectFiles.set(path, content) + } const serviceWorker = getServiceWorker() if (serviceWorker) { serviceWorkerTarget = { @@ -1256,6 +1545,12 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { listenFileSyncAcks(currentWebWorker) readyState = 'ready' + // A Service Worker instance that started during ready() may not have the project + const startedDuringInit = instanceStartedDuringInit + instanceStartedDuringInit = null + if (startedDuringInit !== null && startedDuringInit !== serviceWorkerInstanceId) { + recoverServiceWorker(startedDuringInit) + } return true } catch (error) { const releaseErrors: unknown[] = [] @@ -1290,6 +1585,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { `${describeFileOperation(pending.operation, pending.path)} was cancelled by dispose()` ) ) + stopRecovery(new Error('[Vrowzer] The instance was disposed')) disposePromise = (async () => { // An aborted ready() releases what it created and resolves to false @@ -1422,19 +1718,18 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { }, addFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('addFile', filePath, options => - publisher.writeFile(filePath, content, options) - ) + return syncFile('addFile', filePath, () => prepareWrite(filePath, content)) }, updateFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('updateFile', filePath, options => - publisher.writeFile(filePath, content, options) - ) + return syncFile('updateFile', filePath, () => prepareWrite(filePath, content)) }, deleteFile(filePath: string): Promise { - return syncFile('deleteFile', filePath, options => publisher.unlink(filePath, options)) + return syncFile('deleteFile', filePath, () => options => { + publisher.unlink(filePath, options) + projectFiles.delete(filePath) + }) }, dispose, diff --git a/packages/vrowzer/src/service-worker-core.test.ts b/packages/vrowzer/src/service-worker-core.test.ts index 50529e4..cfa1c9d 100644 --- a/packages/vrowzer/src/service-worker-core.test.ts +++ b/packages/vrowzer/src/service-worker-core.test.ts @@ -1,10 +1,16 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test' -type MessageListener = (event: { data: unknown; source: unknown }) => void +type Listener = (event: any) => void + +interface ServerOptions { + beforeRequest: (request: Request) => Promise + onWorkerChannelReady: () => void +} const mocks = vi.hoisted(() => ({ handleMessage: vi.fn<(message: unknown) => void>(), - listen: vi.fn<() => Promise>() + listen: vi.fn<() => Promise>(), + serverOptions: undefined as ServerOptions | undefined })) vi.mock('@vrowzer/fs', () => ({ @@ -23,45 +29,105 @@ vi.mock('@vrowzer/fs/watcher', () => ({ vi.mock('@vrowzer/vite-dev-server/dist/client/client.mjs?raw', () => ({ default: 'client code' })) vi.mock('@vrowzer/vite-dev-server/dist/client/env.mjs?raw', () => ({ default: 'env code' })) vi.mock('@vrowzer/vite-dev-server/service-worker', () => ({ - createServer: () => mocks.listen + createServer: (_scope: unknown, _config: unknown, options: ServerOptions) => { + mocks.serverOptions = options + return mocks.listen + } })) import { initServiceWorker } from './service-worker-core.ts' -let listeners: Map +type TestClient = ReturnType -function createClient() { - return { id: 'client-1', postMessage: vi.fn<(message: unknown) => void>() } +let listeners: Map +let windowClients: TestClient[] +let matchAllClients: ReturnType + +function createClient(id = 'client-1') { + return { id, postMessage: vi.fn<(message: unknown) => void>() } } function receive(data: unknown, source: unknown): void { listeners.get('message')!({ data, source }) } +function createMatchAll() { + return vi.fn<(options?: ClientQueryOptions) => Promise>(async () => windowClients) +} + +function stubServiceWorkerScope(): void { + listeners = new Map() + matchAllClients = createMatchAll() + vi.stubGlobal('self', { + location: { href: 'https://vrowzer.test/service-worker.js' }, + addEventListener: (type: string, listener: Listener) => { + listeners.set(type, listener) + }, + clients: { + get: async (id: string) => windowClients.find(client => client.id === id), + matchAll: matchAllClients + } + }) +} + +function serverOptions(): ServerOptions { + if (!mocks.serverOptions) { + throw new Error('createServer() was not called') + } + return mocks.serverOptions +} + +function previewRequest(): Request { + return new Request('https://vrowzer.test/__preview__/main.js') +} + +async function isPending(promise: Promise): Promise { + let settled = false + promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + for (let index = 0; index < 10; index++) { + await Promise.resolve() + } + return !settled +} + +function messagesOfType(client: TestClient, type: string): { type: string; instanceId?: string }[] { + return client.postMessage.mock.calls + .map(([message]) => message as { type: string; instanceId?: string }) + .filter(message => message.type === type) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.handleMessage.mockReset() + mocks.serverOptions = undefined + windowClients = [] +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + describe('initServiceWorker file synchronization', () => { beforeEach(async () => { - vi.clearAllMocks() - mocks.handleMessage.mockReset() // Keep the server starting, so that only message handling runs mocks.listen.mockReturnValue(new Promise(() => {})) - listeners = new Map() - vi.stubGlobal('self', { - location: { href: 'https://vrowzer.test/service-worker.js' }, - addEventListener: (type: string, listener: MessageListener) => { - listeners.set(type, listener) - } - }) + stubServiceWorkerScope() await initServiceWorker() }) - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - test.each([ { type: 'V_FS_WRITE', id: 'op-1', path: '/main.ts', encoding: 'text', content: 'test' }, - { type: 'V_FS_UNLINK', id: 'op-1', path: '/main.ts' } + { type: 'V_FS_UNLINK', id: 'op-1', path: '/main.ts' }, + { type: 'V_FS_INIT', id: 'op-1', files: { '/main.ts': 'test' } } ])('acknowledges $type to the sending client after applying it', message => { const client = createClient() mocks.handleMessage.mockImplementation(() => { @@ -98,3 +164,159 @@ describe('initServiceWorker file synchronization', () => { expect(client.postMessage).not.toHaveBeenCalled() }) }) + +describe('initServiceWorker instance', () => { + test('tells its window clients that it started once listen() completes', async () => { + let completeListen!: () => void + mocks.listen.mockReturnValue( + new Promise(resolve => { + completeListen = resolve + }) + ) + windowClients = [createClient('host-1'), createClient('host-2')] + stubServiceWorkerScope() + await initServiceWorker() + await Promise.resolve() + expect(matchAllClients).not.toHaveBeenCalled() + + completeListen() + + await vi.waitFor(() => { + for (const client of windowClients) { + expect(messagesOfType(client, 'V_SW_INSTANCE_STARTED')).toHaveLength(1) + } + }) + expect(matchAllClients).toHaveBeenCalledExactlyOnceWith({ type: 'window' }) + const [first] = messagesOfType(windowClients[0]!, 'V_SW_INSTANCE_STARTED') + const [second] = messagesOfType(windowClients[1]!, 'V_SW_INSTANCE_STARTED') + expect(first!.instanceId).toEqual(expect.any(String)) + expect(second!.instanceId).toBe(first!.instanceId) + }) + + test('answers the listen-ready ping and the activation with the same instance id', async () => { + mocks.listen.mockResolvedValue() + const client = createClient() + windowClients = [client] + stubServiceWorkerScope() + await initServiceWorker() + await vi.waitFor(() => { + expect(messagesOfType(client, 'V_SW_INSTANCE_STARTED')).toHaveLength(1) + }) + const [started] = messagesOfType(client, 'V_SW_INSTANCE_STARTED') + + receive({ type: 'V_SW_LISTEN_READY_PING' }, client) + let activated!: Promise + listeners.get('activate')!({ + waitUntil: (promise: Promise) => { + activated = promise + } + }) + await activated + + await vi.waitFor(() => { + expect(messagesOfType(client, 'V_SW_LISTEN_READY')).toEqual([ + { type: 'V_SW_LISTEN_READY', instanceId: started!.instanceId }, + { type: 'V_SW_LISTEN_READY', instanceId: started!.instanceId } + ]) + }) + }) + + test('creates a new instance id each time the script is evaluated', async () => { + mocks.listen.mockResolvedValue() + const client = createClient() + windowClients = [client] + + for (let evaluation = 0; evaluation < 2; evaluation++) { + stubServiceWorkerScope() + await initServiceWorker() + await vi.waitFor(() => { + expect(messagesOfType(client, 'V_SW_INSTANCE_STARTED')).toHaveLength(evaluation + 1) + }) + } + + const [first, second] = messagesOfType(client, 'V_SW_INSTANCE_STARTED') + expect(second!.instanceId).not.toBe(first!.instanceId) + }) +}) + +describe('initServiceWorker project readiness', () => { + beforeEach(async () => { + mocks.listen.mockResolvedValue() + stubServiceWorkerScope() + await initServiceWorker() + }) + + test.each([ + { order: 'the files first', steps: ['files', 'channel'] }, + { order: 'the channel first', steps: ['channel', 'files'] } + ])( + 'holds requests until it has the project files and the Web Worker channel ($order)', + async ({ steps }) => { + const response = serverOptions().beforeRequest(previewRequest()) + + for (const step of steps) { + expect(await isPending(response)).toBe(true) + if (step === 'files') { + receive({ type: 'V_FS_INIT', files: { '/main.js': 'code' } }, createClient()) + } else { + serverOptions().onWorkerChannelReady() + } + } + + await expect(response).resolves.toBeUndefined() + } + ) + + test('lets requests go on right away once the project is ready', async () => { + receive({ type: 'V_FS_INIT', id: 'init-1', files: {} }, createClient()) + serverOptions().onWorkerChannelReady() + + expect(await isPending(serverOptions().beforeRequest(previewRequest()))).toBe(false) + await expect(serverOptions().beforeRequest(previewRequest())).resolves.toBeUndefined() + }) + + test.each([ + { received: 'nothing', steps: [] }, + { received: 'only the files', steps: ['files'] }, + { received: 'only the channel', steps: ['channel'] } + ])('answers with 503 after 10 seconds when it has received $received', async ({ steps }) => { + vi.useFakeTimers() + for (const step of steps) { + if (step === 'files') { + receive({ type: 'V_FS_INIT', files: {} }, createClient()) + } else { + serverOptions().onWorkerChannelReady() + } + } + + const response = serverOptions().beforeRequest(previewRequest()) + await vi.advanceTimersByTimeAsync(9_999) + expect(await isPending(response)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + + const answer = await response + expect(answer?.status).toBe(503) + expect(answer?.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') + expect(await answer?.text()).toContain('within 10000ms') + }) + + test('does not count project files that fail to apply', async () => { + vi.useFakeTimers() + const client = createClient() + mocks.handleMessage.mockImplementation(() => { + throw new Error('broken file') + }) + receive({ type: 'V_FS_INIT', id: 'init-2', files: {} }, client) + serverOptions().onWorkerChannelReady() + + const response = serverOptions().beforeRequest(previewRequest()) + await vi.advanceTimersByTimeAsync(10_000) + + expect(client.postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'V_FS_ACK', + id: 'init-2', + error: { name: 'Error', message: 'broken file' } + }) + expect((await response)?.status).toBe(503) + }) +}) diff --git a/packages/vrowzer/src/service-worker-core.ts b/packages/vrowzer/src/service-worker-core.ts index d9872e3..da629d4 100644 --- a/packages/vrowzer/src/service-worker-core.ts +++ b/packages/vrowzer/src/service-worker-core.ts @@ -24,7 +24,11 @@ import { V_FS_ACK, createFileSystemSubscriber } from '@vrowzer/fs/watcher' import client from '@vrowzer/vite-dev-server/dist/client/client.mjs?raw' // oxlint-disable-line import/default -- ignore for raw import import env from '@vrowzer/vite-dev-server/dist/client/env.mjs?raw' import { createServer } from '@vrowzer/vite-dev-server/service-worker' -import { V_SW_LISTEN_READY, V_SW_LISTEN_READY_PING } from '@vrowzer/vite-dev-server/messages' +import { + V_SW_INSTANCE_STARTED, + V_SW_LISTEN_READY, + V_SW_LISTEN_READY_PING +} from '@vrowzer/vite-dev-server/messages' import { resolvePreviewBasePath } from './preview-base.ts' import { resolveServiceWorkerVersionForWorker } from './service-worker-version.ts' @@ -34,6 +38,13 @@ import type { Plugin } from '@vrowzer/vite-dev-server/vite' declare const self: ServiceWorkerGlobalScope +/** + * How long a request within the base path waits for the project files and the Web Worker channel. + * Same as the default `fileSyncTimeout` of the runtime, which also bounds its recovery after a + * Service Worker restart. + */ +const PROJECT_WAIT_TIMEOUT = 10_000 + function toAckError(error: unknown): NonNullable { return error instanceof Error ? { name: error.name, message: error.message } @@ -41,6 +52,11 @@ function toAckError(error: unknown): NonNullable { } export async function initServiceWorker(options?: { plugins?: Plugin[] }) { + // A new ID each time this script is evaluated. When the browser restarts the Service Worker + // process, the registration and the controller stay the same, so the runtime tells the instances + // apart by this ID. + const instanceId = crypto.randomUUID() + // Initial volume setup: client files + public dir vol.fromJSON({ '/dist/client/client.mjs': client, @@ -52,11 +68,67 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { const subscriber = createFileSystemSubscriber(fs) const previewBase = resolvePreviewBasePath() const serviceWorkerVersion = resolveServiceWorkerVersionForWorker(self.location.href) + + // This instance serves the project only after it has received the project files (V_FS_INIT) and + // the Web Worker channel. ready() sends both before the preview opens. After a restart, the + // runtime sends them again, and requests made in the meantime wait for them. + let filesReceived = false + let channelReady = false + let projectReady = false + let resolveProjectReady!: () => void + const projectReadyPromise = new Promise(resolve => { + resolveProjectReady = resolve + }) + + function updateProjectReady(): void { + if (filesReceived && channelReady && !projectReady) { + projectReady = true + resolveProjectReady() + } + } + + async function waitForProject(): Promise { + if (projectReady) { + return undefined + } + let timer: ReturnType | undefined + const timedOut = await Promise.race([ + projectReadyPromise.then(() => false), + new Promise(resolve => { + timer = setTimeout(() => resolve(true), PROJECT_WAIT_TIMEOUT) + }) + ]) + clearTimeout(timer) + if (!timedOut) { + return undefined + } + return new Response( + `[Vrowzer] The preview is not ready: the Service Worker did not receive the project within ${PROJECT_WAIT_TIMEOUT}ms.`, + { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } } + ) + } + + /** + * Applies a file sync message. V_FS_INIT gives this instance the project files. + */ + function applyFileSyncMessage(message: FileSystemSyncMessage): void { + subscriber.handleMessage(message) + if (message.type === 'V_FS_INIT') { + filesReceived = true + updateProjectReady() + } + } + const serverOptions = { version: serviceWorkerVersion, basePath: previewBase, ...(options?.plugins ? { plugins: options.plugins } : {}), - watcherFactory: () => subscriber.watcher as any + watcherFactory: () => subscriber.watcher as any, + beforeRequest: waitForProject, + onWorkerChannelReady: () => { + channelReady = true + updateProjectReady() + } } as unknown as CreateServerOptions const listen = createServer( @@ -85,8 +157,14 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { const listenPromise = listen() let listenReady = false // oxlint-disable-next-line typescript/no-floating-promises -- ignore for service worker timing - listenPromise.then(() => { + listenPromise.then(async () => { listenReady = true + // Tell the pages that this instance started. A runtime that knows another instance has lost + // its project here, and sends the files and the Web Worker channel again. + const clients = await self.clients.matchAll({ type: 'window' }) + for (const client of clients) { + client.postMessage({ type: V_SW_INSTANCE_STARTED, instanceId }) + } }) // Message Handling from Main Thread @@ -99,7 +177,7 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { if (clientId) { // oxlint-disable-next-line typescript/no-floating-promises -- ignore for vrowzer preview system negotiation timing self.clients.get(clientId).then(client => { - client?.postMessage({ type: V_SW_LISTEN_READY }) + client?.postMessage({ type: V_SW_LISTEN_READY, instanceId }) }) } return @@ -113,12 +191,9 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { // V_FS_* messages: update virtual FS via subscriber if (typeof message?.type === 'string' && message.type.startsWith('V_FS_')) { const syncMessage = message as FileSystemSyncMessage - const id = - syncMessage.type === 'V_FS_WRITE' || syncMessage.type === 'V_FS_UNLINK' - ? syncMessage.id - : undefined + const id = syncMessage.type === 'V_FS_MKDIR' ? undefined : syncMessage.id if (id === undefined) { - subscriber.handleMessage(syncMessage) + applyFileSyncMessage(syncMessage) return } @@ -126,7 +201,7 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { // as soon as handleMessage() returns. Acknowledge it to the client that sent it. let ack: FSAckMessage = { type: V_FS_ACK, id } try { - subscriber.handleMessage(syncMessage) + applyFileSyncMessage(syncMessage) } catch (error) { ack = { type: V_FS_ACK, id, error: toAckError(error) } } @@ -145,7 +220,7 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { // Signal main thread that the server is ready const clients = await self.clients.matchAll({ includeUncontrolled: true }) for (const client of clients) { - client.postMessage({ type: V_SW_LISTEN_READY }) + client.postMessage({ type: V_SW_LISTEN_READY, instanceId }) } }) )