From 5535b4488df7fe4ae6f348e934c1e1a557172451 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 11:59:47 +0900 Subject: [PATCH 01/10] feat(vite-dev-server): let the Web Worker answer preview requests The Web Worker has the project files, the module graph and the plugins, so it can answer a whole request with the Vite middlewares. This is the first step of #45, where the Service Worker only forwards requests to the Web Worker that owns the project. - createRequestPipeline() builds the Vite middlewares in the Web Worker, in the order of Vite's dev server: base, public files, transform, raw fs, static files, the HTML fallback, the functions that the configureServer hooks return, index.html, 404 and the error handler. - The configureServer hooks get these middlewares as server.middlewares. - setupHMR() returns the public file list that its watcher handlers keep up to date, and the public file middleware uses it. - The new handleRequest RPC answers a forwarded request. Requests and responses cross the port as plain data, and their bodies are transferred instead of copied. - The test plugins of the integration fixtures find the Web Worker's dev server by its environments, since it has middlewares now. The Service Worker still answers requests itself. It forwards them in the next commit. --- .../src/node/server/requestPipeline.test.ts | 188 ++++++++++++++++ .../src/node/server/requestPipeline.ts | 124 +++++++++++ .../src/node/transformer.test.ts | 27 ++- .../vite-dev-server/src/node/transformer.ts | 27 ++- .../src/node/web-worker.test.ts | 112 +++++++++- .../vite-dev-server/src/node/web-worker.ts | 23 +- .../src/shared/requestTransport.test.ts | 200 ++++++++++++++++++ .../src/shared/requestTransport.ts | 85 ++++++++ packages/vite-dev-server/src/shared/rpc.ts | 34 +++ .../integration/playground/vite.config.ts | 20 +- .../integration/restart/vite.config.ts | 5 +- 11 files changed, 820 insertions(+), 25 deletions(-) create mode 100644 packages/vite-dev-server/src/node/server/requestPipeline.test.ts create mode 100644 packages/vite-dev-server/src/node/server/requestPipeline.ts create mode 100644 packages/vite-dev-server/src/shared/requestTransport.test.ts create mode 100644 packages/vite-dev-server/src/shared/requestTransport.ts diff --git a/packages/vite-dev-server/src/node/server/requestPipeline.test.ts b/packages/vite-dev-server/src/node/server/requestPipeline.test.ts new file mode 100644 index 00000000..91efcdc3 --- /dev/null +++ b/packages/vite-dev-server/src/node/server/requestPipeline.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, test, vi } from 'vite-plus/test' +import type { Context, Next } from 'hono' +import type { SerializedRequest } from '../../shared/rpc' +import type { ViteDevServer } from './index' + +const middlewareMocks = vi.hoisted(() => { + // The names of the middlewares that a request passed, in order + const calls: string[] = [] + const passing = (name: string) => async (_c: Context, next: Next) => { + calls.push(name) + await next() + } + return { + calls, + passing, + baseMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('base')), + errorMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => (error: Error, c: Context) => + c.text(`error page: ${error.message}`, 500)), + htmlFallbackMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('htmlFallback')), + indexHtmlMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('indexHtml')), + notFoundMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => async (c: Context) => { + calls.push('notFound') + return c.body(null, 404) + }), + servePublicMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('public')), + serveRawFsMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('rawFs')), + serveStaticMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('static')), + transformMiddleware: vi.fn<(...args: unknown[]) => unknown>(() => passing('transform')), + } +}) + +vi.mock('./middlewares/base', () => ({ baseMiddleware: middlewareMocks.baseMiddleware })) +vi.mock('./middlewares/error', () => ({ errorMiddleware: middlewareMocks.errorMiddleware })) +vi.mock('./middlewares/htmlFallback', () => ({ + htmlFallbackMiddleware: middlewareMocks.htmlFallbackMiddleware, +})) +vi.mock('./middlewares/indexHtml', () => ({ indexHtmlMiddleware: middlewareMocks.indexHtmlMiddleware })) +vi.mock('./middlewares/notFound', () => ({ notFoundMiddleware: middlewareMocks.notFoundMiddleware })) +vi.mock('./middlewares/static', () => ({ + servePublicMiddleware: middlewareMocks.servePublicMiddleware, + serveRawFsMiddleware: middlewareMocks.serveRawFsMiddleware, + serveStaticMiddleware: middlewareMocks.serveStaticMiddleware, +})) +vi.mock('./middlewares/transform', () => ({ transformMiddleware: middlewareMocks.transformMiddleware })) + +import { createRequestPipeline } from './requestPipeline' + +const { calls, passing } = middlewareMocks + +function createServer(config: Record = {}): ViteDevServer { + return { + config: { + root: '/', + base: '/__preview__/', + rawBase: '/__preview__/', + publicDir: '/public', + appType: 'spa', + server: {}, + ...config, + }, + } as unknown as ViteDevServer +} + +function request(path: string, init: Partial = {}): SerializedRequest { + return { + url: `https://example.com${path}`, + method: 'GET', + headers: [], + body: null, + ...init, + } +} + +function text(buffer: ArrayBuffer | null): string | null { + return buffer === null ? null : new TextDecoder().decode(buffer) +} + +describe('request pipeline', () => { + beforeEach(() => { + calls.length = 0 + vi.clearAllMocks() + }) + + test('runs the middlewares of plugins and the internal middlewares in the order of the Vite dev server', async () => { + const server = createServer() + const publicFiles = new Set(['/hello.txt']) + const pipeline = createRequestPipeline('/__preview__') + // A plugin adds a middleware in configureServer, and another one in the function it returns + pipeline.middlewares.use(passing('plugin')) + const postHook = () => { + pipeline.middlewares.use(passing('plugin post')) + } + + pipeline.applyInternalMiddlewares(server, publicFiles, [postHook, undefined]) + const response = await pipeline.handleRequest(request('/__preview__/missing.txt')) + + expect(calls).toEqual([ + 'plugin', + 'base', + 'public', + 'transform', + 'rawFs', + 'static', + 'htmlFallback', + 'plugin post', + 'indexHtml', + 'notFound', + ]) + expect(response.status).toBe(404) + expect(middlewareMocks.baseMiddleware).toHaveBeenCalledWith('/__preview__/', false) + expect(middlewareMocks.servePublicMiddleware).toHaveBeenCalledWith(server, publicFiles) + expect(middlewareMocks.transformMiddleware).toHaveBeenCalledWith(server) + expect(middlewareMocks.serveRawFsMiddleware).toHaveBeenCalledWith(server) + expect(middlewareMocks.serveStaticMiddleware).toHaveBeenCalledWith(server) + expect(middlewareMocks.htmlFallbackMiddleware).toHaveBeenCalledWith('/', true) + expect(middlewareMocks.indexHtmlMiddleware).toHaveBeenCalledWith('/', server, { isDev: true }) + expect(middlewareMocks.errorMiddleware).toHaveBeenCalledWith(server, false) + }) + + test('leaves out the middlewares that the config does not use', async () => { + const pipeline = createRequestPipeline() + + pipeline.applyInternalMiddlewares( + createServer({ base: '/', rawBase: '/', publicDir: '', appType: 'custom' }), + undefined, + [], + ) + const response = await pipeline.handleRequest(request('/missing.txt')) + + expect(calls).toEqual(['transform', 'rawFs', 'static']) + expect(response.status).toBe(404) + }) + + test('falls back to index.html without the SPA fallback in an MPA', async () => { + const pipeline = createRequestPipeline('/__preview__') + + pipeline.applyInternalMiddlewares(createServer({ appType: 'mpa' }), undefined, []) + await pipeline.handleRequest(request('/__preview__/about/')) + + expect(middlewareMocks.htmlFallbackMiddleware).toHaveBeenCalledWith('/', false) + expect(calls).toContain('indexHtml') + }) + + test('answers with the response of a middleware', async () => { + const pipeline = createRequestPipeline('/__preview__') + // Paths are relative to the base path, as in the Service Worker + pipeline.middlewares.use('/api', async c => { + const body = `${c.req.method} ${c.req.header('x-input')} ${await c.req.text()}` + return c.text(body, 201, { 'x-plugin': 'yes' }) + }) + pipeline.applyInternalMiddlewares(createServer(), undefined, []) + + const response = await pipeline.handleRequest(request('/__preview__/api', { + method: 'POST', + headers: [['x-input', 'header']], + body: new TextEncoder().encode('body').buffer as ArrayBuffer, + })) + + expect(response.status).toBe(201) + expect(response.headers).toContainEqual(['x-plugin', 'yes']) + expect(text(response.body)).toBe('POST header body') + // The internal middlewares come after the plugin's + expect(calls).toEqual([]) + }) + + test('answers an error with the error middleware', async () => { + const pipeline = createRequestPipeline('/__preview__') + pipeline.middlewares.use(async () => { + throw new Error('Transform failed') + }) + pipeline.applyInternalMiddlewares(createServer(), undefined, []) + + const response = await pipeline.handleRequest(request('/__preview__/src/main.ts')) + + expect(response.status).toBe(500) + expect(text(response.body)).toBe('error page: Transform failed') + }) + + test('does not run the middlewares for a request outside the base path', async () => { + const pipeline = createRequestPipeline('/__preview__') + pipeline.applyInternalMiddlewares(createServer(), undefined, []) + + const response = await pipeline.handleRequest(request('/other/index.html')) + + expect(response.status).toBe(404) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/vite-dev-server/src/node/server/requestPipeline.ts b/packages/vite-dev-server/src/node/server/requestPipeline.ts new file mode 100644 index 00000000..7a8d081e --- /dev/null +++ b/packages/vite-dev-server/src/node/server/requestPipeline.ts @@ -0,0 +1,124 @@ +/** + * The Vite middlewares of the Web Worker + * + * The Service Worker forwards each request within the base path to the Web Worker, which has the + * project files, the module graph and the plugins. The Web Worker answers the request with the + * middlewares that Vite's dev server registers in `_createServer()`, in the same order. + * + * @module node/server/requestPipeline + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Hono } from 'hono' +import { deserializeRequest, serializeResponse } from '../../shared/requestTransport' +import { baseMiddleware } from './middlewares/base' +import { errorMiddleware } from './middlewares/error' +import { htmlFallbackMiddleware } from './middlewares/htmlFallback' +import { indexHtmlMiddleware } from './middlewares/indexHtml' +import { notFoundMiddleware } from './middlewares/notFound' +import { servePublicMiddleware, serveRawFsMiddleware, serveStaticMiddleware } from './middlewares/static' +import { transformMiddleware } from './middlewares/transform' + +import type { BlankSchema } from 'hono/types' +import type { SerializedRequest, SerializedResponse } from '../../shared/rpc' +import type { ViteDevServer, ViteEnv } from './index' + +/** + * The Vite middlewares of the Web Worker, which answer the requests that the Service Worker + * forwards. + */ +export interface RequestPipeline { + /** + * The app that the middlewares are registered on. Plugins add their middlewares to it in + * `configureServer`, as `server.middlewares`. + */ + readonly middlewares: Hono + /** + * Register the internal middlewares, the functions that the `configureServer` hooks returned, and + * the error handler. Call it once, after the `configureServer` hooks. + * + * @param server - The dev server whose requests the middlewares answer + * @param publicFiles - The files in the public directory, which the watcher keeps up to date + * @param postHooks - The functions that the `configureServer` hooks returned + */ + applyInternalMiddlewares( + server: ViteDevServer, + publicFiles: Set | undefined, + postHooks: ((() => void) | void)[], + ): void + /** + * Answer a request that the Service Worker forwarded. + */ + handleRequest(request: SerializedRequest): Promise +} + +/** + * Create the Vite middlewares of the Web Worker. + * + * @param basePath - The base path of the requests that the Service Worker forwards, e.g. `/__preview__` + */ +export function createRequestPipeline(basePath = '/'): RequestPipeline { + let middlewares = new Hono() + // `basePath()` returns a clone with its own error handler, so `onError()` is registered on the + // same app that answers the requests. + if (basePath !== '/') { + middlewares = middlewares.basePath(basePath) + } + + return { + middlewares, + + applyInternalMiddlewares(server, publicFiles, postHooks) { + const { config } = server + const { root, publicDir } = config + + // base + if (config.base !== '/') { + middlewares.use(baseMiddleware(config.rawBase, !!config.server.middlewareMode)) + } + + // serve static files under /public + // this applies before the transform middleware so that these files are served + // as-is without transforms. + if (publicDir) { + middlewares.use(servePublicMiddleware(server, publicFiles)) + } + + // main transform middleware + middlewares.use('*', transformMiddleware(server)) + + // serve static files + middlewares.use(serveRawFsMiddleware(server)) + middlewares.use(serveStaticMiddleware(server)) + + // html fallback + if (config.appType === 'spa' || config.appType === 'mpa') { + middlewares.use(htmlFallbackMiddleware(root, config.appType === 'spa')) + } + + // apply configureServer post hooks + // This is applied before the html middleware so that user middleware can + // serve custom content instead of index.html. + postHooks.forEach((fn) => fn && fn()) + + if (config.appType === 'spa' || config.appType === 'mpa') { + // transform index.html + middlewares.use(indexHtmlMiddleware(root, server, { isDev: true })) + + // handle 404s + middlewares.use(notFoundMiddleware()) + } + + // error handler + middlewares.onError(errorMiddleware(server, false)) + }, + + async handleRequest(request) { + return serializeResponse(await middlewares.fetch(deserializeRequest(request))) + }, + } +} diff --git a/packages/vite-dev-server/src/node/transformer.test.ts b/packages/vite-dev-server/src/node/transformer.test.ts index 521a2fef..7a9507ab 100644 --- a/packages/vite-dev-server/src/node/transformer.test.ts +++ b/packages/vite-dev-server/src/node/transformer.test.ts @@ -8,6 +8,10 @@ const hmrMocks = vi.hoisted(() => ({ >(async () => undefined), })) +const publicDirMocks = vi.hoisted(() => ({ + initPublicFiles: vi.fn<() => Promise | undefined>>(async () => undefined), +})) + // transformer.ts is also a runtime barrel. Stub its re-export graph so this // test exercises setupWorker, setupHMR and updateFile without initializing browser WASM. vi.mock('@vrowzer/fs', () => ({ @@ -55,7 +59,7 @@ vi.mock('./plugins/esbuild', () => ({ })) vi.mock('./publicDir', () => ({ - initPublicFiles: async () => undefined, + initPublicFiles: publicDirMocks.initPublicFiles, })) vi.mock('./server/environment', () => ({ @@ -112,6 +116,10 @@ vi.mock('./server/middlewares/indexHtml', () => ({ createDevHtmlTransformFn: () => undefined, })) +vi.mock('./server/requestPipeline', () => ({ + createRequestPipeline: () => undefined, +})) + vi.mock('./optimizer', () => ({ isDepOptimizationDisabled: () => true, })) @@ -452,6 +460,23 @@ describe('setupHMR file change tracking', () => { await expect(taken).resolves.toBeUndefined() }) + test('keeps the public files that it returns up to date', async () => { + const { server, watcher } = createHMRServer() + const config = server.config as unknown as { publicDir: string } + config.publicDir = '/public' + const client = server.environments.client as unknown as { moduleGraph: Record } + client.moduleGraph.getModuleByUrl = vi.fn<(url: string) => Promise>(async () => undefined) + publicDirMocks.initPublicFiles.mockResolvedValueOnce(new Set(['/old.txt'])) + const { publicFiles, waitForFileChange } = await setupHMR(server) + + watcher.notify('add', '/public/new.txt') + await waitForFileChange('/public/new.txt') + watcher.notify('unlink', '/public/old.txt') + await waitForFileChange('/public/old.txt') + + expect(publicFiles).toEqual(new Set(['/new.txt'])) + }) + test('forgets an event that settled without being taken', async () => { const { logError, server, watchChange, watcher } = createHMRServer() const error = new Error('change failed') diff --git a/packages/vite-dev-server/src/node/transformer.ts b/packages/vite-dev-server/src/node/transformer.ts index 61f1b703..0c690a8e 100644 --- a/packages/vite-dev-server/src/node/transformer.ts +++ b/packages/vite-dev-server/src/node/transformer.ts @@ -17,6 +17,7 @@ import { fs, vol } from '@vrowzer/fs' import { rolldown } from '@vrowzer/rolldown' import { memfs } from '@vrowzer/rolldown/experimental' import { createBirpc } from 'birpc' +import { getRpcTransferList } from '../shared/requestTransport' import { deserializeRpcMessage, serializeRpcMessage } from '../shared/rpc' import { isResolvedConfig, resolveConfig } from './config' import { reloadOnTsconfigChange } from './plugins/esbuild' @@ -50,13 +51,15 @@ const debug = createDebugger('vrowzer:transformer') /** * Subset of ViteDevServer for Web Worker environment. * - * The full ViteDevServer includes SW-specific properties (middlewares, httpServer, etc.) - * that are not available in the Web Worker. This type picks only the properties - * needed by Environment APIs (warmupFiles, createDevHtmlTransformFn, etc.). + * The full ViteDevServer includes properties (httpServer, etc.) that are not available in the + * Web Worker. This type picks only the properties needed by Environment APIs (warmupFiles, + * createDevHtmlTransformFn, etc.) and by the Vite middlewares, which answer the requests that + * the Service Worker forwards. */ export type ViteDevServerForWorker = Pick | undefined /** * Takes the processing of the latest watcher event for `file`, up to module graph invalidation. * @@ -400,6 +409,7 @@ export async function setupHMR(server: ViteDevServer): Promise): void { * Connect a MessagePort from the Service Worker and establish birpc RPC. * * Performs the V_WW_SW_CHANNEL_READY handshake on the port, then - * creates a birpc RPC server that handles transform requests from the SW. + * creates a birpc RPC server that handles the requests from the SW. * * After birpc is established, the same port is also used to receive * V_WW_HMR_PORT messages (iframe HMR ports forwarded from SW). * * @param port - MessagePort received via V_SW_CONNECT_PORT message - * @param handlers - WorkerFunctions handlers (transformRequest, transformIndexHtml) + * @param handlers - WorkerFunctions handlers (handleRequest, transformRequest, transformIndexHtml) * @param onHmrPort - Callback when an iframe HMR port is received via V_WW_HMR_PORT * @returns Promise that resolves with the birpc instance after handshake completes */ @@ -451,7 +461,8 @@ export function connectServiceWorkerPort( const rpc = createBirpc( handlers, { - post: data => port.postMessage(data), + // Transfer the response bodies of handleRequest instead of copying them + post: data => port.postMessage(data, getRpcTransferList(data)), on: fn => { port.onmessage = (ev: MessageEvent) => { // Intercept: HMR port forwarded from SW @@ -586,6 +597,10 @@ export type { // === HTML transform === export { createDevHtmlTransformFn } from './server/middlewares/indexHtml' +// === Vite middlewares === +export { createRequestPipeline } from './server/requestPipeline' +export type { RequestPipeline } from './server/requestPipeline' + // === Optimizer === export { isDepOptimizationDisabled 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 dad6234e..d9e680e4 100644 --- a/packages/vite-dev-server/src/node/web-worker.test.ts +++ b/packages/vite-dev-server/src/node/web-worker.test.ts @@ -11,10 +11,18 @@ import { const transformerMocks = vi.hoisted(() => ({ connectServiceWorkerPort: vi.fn<(...args: unknown[]) => Promise>(), createDevHtmlTransformFn: vi.fn<() => () => void>(() => vi.fn<() => void>()), + createRequestPipeline: vi.fn<(basePath?: string) => unknown>(() => ({ + middlewares: {}, + applyInternalMiddlewares: vi.fn<(...args: unknown[]) => void>(), + handleRequest: vi.fn<(request: unknown) => Promise>(), + })), fs: {}, isServerAccessDeniedForTransform: vi.fn<() => boolean>(() => false), - setupHMR: vi.fn<() => Promise<{ waitForFileChange: (file: string) => Promise }>>( - async () => ({ waitForFileChange: async () => undefined }), + setupHMR: vi.fn<() => Promise<{ + publicFiles: Set | undefined + waitForFileChange: (file: string) => Promise + }>>( + async () => ({ publicFiles: undefined, waitForFileChange: async () => undefined }), ), setupWorker: vi.fn<() => Promise>(), })) @@ -68,12 +76,16 @@ function createWorkerScope() { } as unknown as DedicatedWorkerGlobalScope } -function dispatchSetup(workerScope: DedicatedWorkerGlobalScope, config: Record = {}): Promise { +function dispatchSetup( + workerScope: DedicatedWorkerGlobalScope, + config: Record = {}, + options: Record = {}, +): Promise { return Promise.resolve(workerScope.onmessage?.({ data: { type: V_WW_SETUP, config, - options: {}, + options, files: {}, }, } as MessageEvent) as unknown as Promise) @@ -282,6 +294,96 @@ describe('Web Worker transform requests', () => { }) }) +describe('Web Worker request pipeline', () => { + interface Pipeline { + middlewares: object + applyInternalMiddlewares: ReturnType void>> + handleRequest: ReturnType Promise>> + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + function createdPipeline(): Pipeline { + return transformerMocks.createRequestPipeline.mock.results[0]!.value as Pipeline + } + + test('builds the Vite middlewares around the configureServer hooks', async () => { + const setupResult = createSetupResult() + const servers: unknown[] = [] + const postHook = vi.fn<() => void>() + const hooks = [ + vi.fn<(server: unknown) => () => void>((server) => { + servers.push(server) + return postHook + }), + vi.fn<(server: unknown) => void>((server) => { + servers.push(server) + }), + ] + setupResult.config.getSortedPluginHooks = vi.fn<() => []>(() => hooks as unknown as []) + transformerMocks.setupWorker.mockResolvedValue(setupResult) + const publicFiles = new Set(['/hello.txt']) + transformerMocks.setupHMR.mockResolvedValueOnce({ + publicFiles, + waitForFileChange: async () => undefined, + }) + const workerScope = createWorkerScope() + const server = createServer(workerScope) + const listening = server.listen(0) + + await dispatchSetup(workerScope, {}, { basePath: '/__preview__' }) + const readyServer = await listening + + expect(transformerMocks.createRequestPipeline).toHaveBeenCalledExactlyOnceWith('/__preview__') + const pipeline = createdPipeline() + // Plugins add their middlewares to the pipeline in configureServer + expect(readyServer.middlewares).toBe(pipeline.middlewares) + expect(servers).toEqual([readyServer, readyServer]) + // The functions that the hooks return come after the internal middlewares + expect(pipeline.applyInternalMiddlewares) + .toHaveBeenCalledExactlyOnceWith(readyServer, publicFiles, [postHook, undefined]) + const applied = pipeline.applyInternalMiddlewares.mock.invocationCallOrder[0]! + expect(applied).toBeGreaterThan(hooks[1]!.mock.invocationCallOrder[0]!) + expect(applied) + .toBeLessThan(setupResult.environments.client.pluginContainer.buildStart.mock.invocationCallOrder[0]!) + expect(postHook).not.toHaveBeenCalled() + }) + + test('answers the requests that the Service Worker forwards with the Vite middlewares', async () => { + transformerMocks.setupWorker.mockResolvedValue(createSetupResult()) + const workerScope = createWorkerScope() + const server = createServer(workerScope) + const listening = server.listen(0) + await dispatchSetup(workerScope) + await listening + const response = { status: 200, statusText: 'OK', headers: [], body: null } + createdPipeline().handleRequest.mockResolvedValueOnce(response) + transformerMocks.connectServiceWorkerPort.mockResolvedValueOnce({ + $close: vi.fn<() => void>(), + registerSafeModulePaths: vi.fn<(paths: string[]) => Promise>(async () => undefined), + }) + + await Promise.resolve(workerScope.onmessage?.({ + data: { type: V_SW_CONNECT_PORT }, + ports: [{ close: vi.fn<() => void>() }], + } as unknown as MessageEvent)) + const [, handlers] = transformerMocks.connectServiceWorkerPort.mock.calls[0]! as [ + unknown, + { handleRequest: (request: unknown) => Promise }, + ] + const request = { url: 'https://example.com/__preview__/', method: 'GET', headers: [], body: null } + + await expect(handlers.handleRequest(request)).resolves.toBe(response) + expect(createdPipeline().handleRequest).toHaveBeenCalledExactlyOnceWith(request) + }) +}) + describe('Web Worker file changes', () => { beforeEach(() => { vi.clearAllMocks() @@ -293,7 +395,7 @@ describe('Web Worker file changes', () => { test('waits for file changes with the tracker from setupHMR', async () => { const waitForFileChange = vi.fn<(file: string) => Promise>(async () => undefined) - transformerMocks.setupHMR.mockResolvedValueOnce({ waitForFileChange }) + transformerMocks.setupHMR.mockResolvedValueOnce({ publicFiles: undefined, waitForFileChange }) transformerMocks.setupWorker.mockResolvedValue(createSetupResult()) const workerScope = createWorkerScope() const server = createServer(workerScope) diff --git a/packages/vite-dev-server/src/node/web-worker.ts b/packages/vite-dev-server/src/node/web-worker.ts index 8701d161..f0733b1e 100644 --- a/packages/vite-dev-server/src/node/web-worker.ts +++ b/packages/vite-dev-server/src/node/web-worker.ts @@ -28,7 +28,7 @@ import { V_WW_READY, V_WW_SETUP_ACK, V_WW_SETUP_ERROR, V_SW_CONNECT_PORT_ACK } f import type { ConnectServiceWorkerPortMessage, SetupWorkerMessage, WorkerReadyMessage } from '../shared/messages' import type { ViteDevServer } from './server/index' import type { Plugin } from './plugin' -import type { FileChangeTracker, ViteDevServerForWorker } from './transformer' +import type { FileChangeTracker, RequestPipeline, ViteDevServerForWorker } from './transformer' const debug = createDebugger('vrowzer:web-worker') @@ -134,6 +134,8 @@ export function createServer( let server: ViteDevServerForWorker | null = null let ws: import('./server/ws').MessageChannelServer | null = null let fileChanges: FileChangeTracker | null = null + // The Vite middlewares, which answer the requests that the Service Worker forwards + let requestPipeline: RequestPipeline | null = null // The current channel with the Service Worker. A new port replaces it, e.g. after the browser // restarted the Service Worker process. @@ -182,12 +184,18 @@ export function createServer( ws = result.ws const clientEnv = environments.client const devHtmlTransformFn = transformer.createDevHtmlTransformFn(config) + const basePath = typeof setupMsg.options?.basePath === 'string' + ? setupMsg.options.basePath + : undefined + const pipeline = transformer.createRequestPipeline(basePath) + requestPipeline = pipeline // Build ViteDevServerForWorker with DevEnvironment server = { config, environments, fileSystem: transformer.fs, + middlewares: pipeline.middlewares, moduleGraph, watcher, ws, @@ -220,12 +228,20 @@ export function createServer( // Plugins like @vitejs/plugin-vue store the server reference in configureServer // to enable HMR code injection in SFC transforms. Without this, Vue SFCs won't // have import.meta.hot.accept() and all changes trigger full page reloads. + // Plugins also add their middlewares to `server.middlewares`, before the internal ones. + const postHooks: ((() => void) | void)[] = [] for (const hook of config.getSortedPluginHooks('configureServer')) { - await hook.call( + postHooks.push(await hook.call( clientEnv!.pluginContainer.minimalContext, server as unknown as ViteDevServer, - ) + )) } + // The functions that the hooks returned add their middlewares after the internal ones + pipeline.applyInternalMiddlewares( + server as unknown as ViteDevServer, + fileChanges.publicFiles, + postHooks, + ) // Match Vite's dev-server initialization order: configureServer // completes before the client buildStart lifecycle begins. @@ -269,6 +285,7 @@ export function createServer( const { connectServiceWorkerPort } = await import('./transformer') const serviceWorkerRpc = await connectServiceWorkerPort(port, { + handleRequest: (request) => requestPipeline!.handleRequest(request), transformRequest: (url, opts) => server!.transformRequest(url, opts), transformIndexHtml: (url, html, originalUrl) => server!.transformIndexHtml(url, html, originalUrl), warmupRequest: (url) => server!.warmupRequest(url), diff --git a/packages/vite-dev-server/src/shared/requestTransport.test.ts b/packages/vite-dev-server/src/shared/requestTransport.test.ts new file mode 100644 index 00000000..120f9ecc --- /dev/null +++ b/packages/vite-dev-server/src/shared/requestTransport.test.ts @@ -0,0 +1,200 @@ +import { createBirpc } from 'birpc' +import { describe, expect, it } from 'vite-plus/test' +import { + deserializeRequest, + getRpcTransferList, + serializeRequest, + serializeResponse, +} from './requestTransport' +import type { SerializedRequest, SerializedResponse, WorkerFunctions } from './rpc' + +function bytes(text: string): ArrayBuffer { + return new TextEncoder().encode(text).buffer as ArrayBuffer +} + +function text(buffer: ArrayBuffer | null): string | null { + return buffer === null ? null : new TextDecoder().decode(buffer) +} + +describe('request serialization', () => { + it('keeps the method, URL and headers of a request without a body', async () => { + const request = new Request('https://example.com/__preview__/src/main.ts?t=1', { + headers: { accept: 'text/javascript', 'x-custom': 'yes' }, + }) + + const serialized = await serializeRequest(request) + + expect(serialized).toEqual({ + url: 'https://example.com/__preview__/src/main.ts?t=1', + method: 'GET', + headers: [['accept', 'text/javascript'], ['x-custom', 'yes']], + body: null, + }) + }) + + it('has no body for a HEAD request', async () => { + const serialized = await serializeRequest( + new Request('https://example.com/__preview__/', { method: 'HEAD' }), + ) + + expect(serialized.method).toBe('HEAD') + expect(serialized.body).toBeNull() + }) + + it('reads the body of other requests', async () => { + const serialized = await serializeRequest( + new Request('https://example.com/__preview__/api', { method: 'POST', body: 'payload' }), + ) + + expect(serialized.method).toBe('POST') + expect(serialized.body).toBeInstanceOf(ArrayBuffer) + expect(text(serialized.body)).toBe('payload') + }) + + it('restores a request', async () => { + const request = deserializeRequest({ + url: 'https://example.com/__preview__/api?x=1', + method: 'PUT', + headers: [['content-type', 'text/plain'], ['accept', '*/*']], + body: bytes('updated'), + }) + + expect(request.url).toBe('https://example.com/__preview__/api?x=1') + expect(request.method).toBe('PUT') + expect(request.headers.get('content-type')).toBe('text/plain') + expect(request.headers.get('accept')).toBe('*/*') + expect(await request.text()).toBe('updated') + }) + + it('restores a request without a body', () => { + const request = deserializeRequest({ + url: 'https://example.com/__preview__/', + method: 'GET', + headers: [], + body: null, + }) + + expect(request.method).toBe('GET') + expect(request.body).toBeNull() + }) +}) + +describe('response serialization', () => { + it('keeps the status, headers and body of a response', async () => { + const response = new Response('export default 1', { + status: 201, + statusText: 'Created', + headers: { 'content-type': 'text/javascript', etag: 'W/"1"' }, + }) + + const serialized = await serializeResponse(response) + + expect(serialized).toMatchObject({ + status: 201, + statusText: 'Created', + headers: expect.arrayContaining([ + ['content-type', 'text/javascript'], + ['etag', 'W/"1"'], + ]), + }) + expect(text(serialized.body)).toBe('export default 1') + }) + + it('has no body for a response without one', async () => { + const serialized = await serializeResponse(new Response(null, { status: 304 })) + + expect(serialized.status).toBe(304) + expect(serialized.body).toBeNull() + }) + + it('keeps binary bodies', async () => { + const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]) + + const serialized = await serializeResponse(new Response(binary)) + + expect([...new Uint8Array(serialized.body!)]).toEqual([...binary]) + }) +}) + +describe('RPC transfer list', () => { + const request: SerializedRequest = { + url: 'https://example.com/__preview__/api', + method: 'POST', + headers: [], + body: bytes('payload'), + } + const response: SerializedResponse = { + status: 200, + statusText: 'OK', + headers: [], + body: bytes('answer'), + } + + it('lists the request body of a handleRequest call', () => { + expect(getRpcTransferList({ t: 'q', i: 'id', m: 'handleRequest', a: [request] })) + .toEqual([request.body]) + }) + + it('lists nothing for a handleRequest call without a body', () => { + expect(getRpcTransferList({ t: 'q', i: 'id', m: 'handleRequest', a: [{ ...request, body: null }] })) + .toEqual([]) + }) + + it('lists the body of a result', () => { + expect(getRpcTransferList({ t: 's', i: 'id', r: response })).toEqual([response.body]) + }) + + it('lists nothing for other messages', () => { + expect(getRpcTransferList({ t: 'q', i: 'id', m: 'transformRequest', a: ['/src/main.ts'] })) + .toEqual([]) + expect(getRpcTransferList({ t: 's', i: 'id', r: { code: 'export default 1', etag: 'W/"1"' } })) + .toEqual([]) + expect(getRpcTransferList({ t: 's', i: 'id', r: null })).toEqual([]) + expect(getRpcTransferList({ t: 's', i: 'id', e: new Error('failed') })).toEqual([]) + expect(getRpcTransferList({ type: 'V_WW_SW_CHANNEL_READY', source: 'ww' })).toEqual([]) + expect(getRpcTransferList(null)).toEqual([]) + }) + + it('transfers the bodies of handleRequest through a birpc MessageChannel', async () => { + const channel = new MessageChannel() + const sentResponses: SerializedResponse[] = [] + const client = createBirpc>({}, { + post: data => channel.port1.postMessage(data, getRpcTransferList(data)), + on: fn => { + channel.port1.onmessage = event => fn(event.data) + }, + }) + const server = createBirpc, Pick>({ + async handleRequest(received) { + const answer: SerializedResponse = { + status: 200, + statusText: 'OK', + headers: [['content-type', 'text/plain']], + body: bytes(`echo: ${text(received.body)}`), + } + sentResponses.push(answer) + return answer + }, + }, { + post: data => channel.port2.postMessage(data, getRpcTransferList(data)), + on: fn => { + channel.port2.onmessage = event => fn(event.data) + }, + }) + + try { + const sentRequest: SerializedRequest = { ...request, body: bytes('payload') } + const received = await client.handleRequest(sentRequest) + + expect(text(received.body)).toBe('echo: payload') + // A transferred ArrayBuffer is detached in the sender + expect(sentRequest.body!.byteLength).toBe(0) + expect(sentResponses[0]!.body!.byteLength).toBe(0) + } finally { + client.$close() + server.$close() + channel.port1.close() + channel.port2.close() + } + }) +}) diff --git a/packages/vite-dev-server/src/shared/requestTransport.ts b/packages/vite-dev-server/src/shared/requestTransport.ts new file mode 100644 index 00000000..008e9ed3 --- /dev/null +++ b/packages/vite-dev-server/src/shared/requestTransport.ts @@ -0,0 +1,85 @@ +/** + * Requests that the Service Worker forwards to the Web Worker + * + * The Service Worker forwards each request within the base path to the Web Worker, which answers + * it with the Vite middlewares. Requests and responses cross the `MessagePort` between them as + * plain data, and their bodies are transferred instead of copied. + * + * @module shared/requestTransport + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import type { SerializedRequest, SerializedResponse } from './rpc' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** + * Convert a request to the data that the Service Worker forwards. The body of a request other + * than `GET` and `HEAD` is read. + */ +export async function serializeRequest(request: Request): Promise { + return { + url: request.url, + method: request.method, + headers: [...request.headers], + body: request.method === 'GET' || request.method === 'HEAD' + ? null + : await request.arrayBuffer(), + } +} + +/** + * Restore a request that the Service Worker forwarded. + */ +export function deserializeRequest(request: SerializedRequest): Request { + return new Request(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }) +} + +/** + * Convert a response to the data that the Web Worker answers with. The body is read. + */ +export async function serializeResponse(response: Response): Promise { + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers], + body: response.body === null ? null : await response.arrayBuffer(), + } +} + +function bodyOf(value: unknown): Transferable[] { + return isRecord(value) && value.body instanceof ArrayBuffer ? [value.body] : [] +} + +/** + * List the request and response bodies in a birpc message, for the transfer list of + * `postMessage()`, so that they are moved instead of copied. + * + * A call of `handleRequest` carries the request in its arguments (`{ t: 'q', m, a }`), and the + * result carries the response (`{ t: 's', r }`). No other RPC passes an `ArrayBuffer` body. A + * transferred body cannot be read by the sender afterwards, and neither Worker reads it again. + * + * @param message - A birpc message, after `serializeRpcMessage()` + */ +export function getRpcTransferList(message: unknown): Transferable[] { + if (!isRecord(message)) { + return [] + } + if (message.t === 'q' && message.m === 'handleRequest' && Array.isArray(message.a)) { + return bodyOf(message.a[0]) + } + if (message.t === 's') { + return bodyOf(message.r) + } + return [] +} diff --git a/packages/vite-dev-server/src/shared/rpc.ts b/packages/vite-dev-server/src/shared/rpc.ts index 99b0e66e..359953b9 100644 --- a/packages/vite-dev-server/src/shared/rpc.ts +++ b/packages/vite-dev-server/src/shared/rpc.ts @@ -128,6 +128,40 @@ export interface WorkerFunctions { * Best-effort: never throws, handles and reports errors internally. */ warmupRequest: (url: string) => Promise + + /** + * Answer a request within the base path with the Vite middlewares. + * Delegates to the request pipeline in the Web Worker, which has the project files. + * Errors are answered with the error page, as Vite's dev server does. + */ + handleRequest: (request: SerializedRequest) => Promise +} + +/** + * A request that the Service Worker forwards to the Web Worker, as data that a `MessagePort` can + * carry. + */ +export interface SerializedRequest { + url: string + method: string + headers: [string, string][] + /** + * The body, or `null` for a `GET` or `HEAD` request. + */ + body: ArrayBuffer | null +} + +/** + * The response of the Web Worker to a {@link SerializedRequest}. + */ +export interface SerializedResponse { + status: number + statusText: string + headers: [string, string][] + /** + * The body, or `null` for a response without one, e.g. a `304` response. + */ + body: ArrayBuffer | null } /** diff --git a/packages/vrowzer/integration/playground/vite.config.ts b/packages/vrowzer/integration/playground/vite.config.ts index 4cbbc157..8ade402a 100644 --- a/packages/vrowzer/integration/playground/vite.config.ts +++ b/packages/vrowzer/integration/playground/vite.config.ts @@ -17,8 +17,9 @@ function trailingSlashWebWorkerPlugin(): Plugin { name: 'vrowzer-test:trailing-slash-web-worker', apply: 'serve', configureServer(server) { - const middlewares = (server as { middlewares?: unknown }).middlewares - if (server.config.root !== '/' || middlewares) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { return } @@ -90,8 +91,9 @@ function fsHtmlProxyWebWorkerPlugin(): Plugin { name: 'vrowzer-test:fs-html-proxy-web-worker', apply: 'serve', configureServer(server) { - const middlewares = (server as { middlewares?: unknown }).middlewares - if (server.config.root !== '/' || middlewares) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { return } @@ -176,8 +178,9 @@ function hmrClientTrackingWebWorkerPlugin(): Plugin { name: 'vrowzer-test:hmr-client-tracking-web-worker', apply: 'serve', configureServer(server) { - const middlewares = (server as { middlewares?: unknown }).middlewares - if (server.config.root !== '/' || middlewares) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { return } @@ -237,8 +240,9 @@ function fileSyncWebWorkerPlugin(): Plugin { name: 'vrowzer-test:file-sync-web-worker', apply: 'serve', configureServer(server) { - const middlewares = (server as { middlewares?: unknown }).middlewares - if (server.config.root !== '/' || middlewares) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { return } diff --git a/packages/vrowzer/integration/restart/vite.config.ts b/packages/vrowzer/integration/restart/vite.config.ts index 5f9c0e2d..032dec4e 100644 --- a/packages/vrowzer/integration/restart/vite.config.ts +++ b/packages/vrowzer/integration/restart/vite.config.ts @@ -27,8 +27,9 @@ function heldModuleWebWorkerPlugin(): Plugin { name: 'vrowzer-test:held-module-web-worker', apply: 'serve', configureServer(server) { - const middlewares = (server as { middlewares?: unknown }).middlewares - if (server.config.root !== '/' || middlewares) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { return } From 72a079d44b7f5d78ab29847d1a7f1eba826a7d7b Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:24:26 +0900 Subject: [PATCH 02/10] refactor(vite-dev-server): make the Service Worker forward requests to the Web Worker The Service Worker no longer runs the Vite middlewares. It forwards each request within the base path to the Web Worker with handleRequest, and answers with the Web Worker's response and the cross-origin isolation headers. It still forwards the HMR ports of the previews. createServer() of @vrowzer/vite-dev-server/service-worker takes only the version, the base path and ownerWaitTimeout now. The Service Worker does not resolve the Vite config, and has neither plugins nor the public file list: - A request that comes before a Web Worker connects, e.g. right after the browser restarted the Service Worker, waits for ownerWaitTimeout (10 s by default), then gets a 503. This replaces the beforeRequest and onWorkerChannelReady hooks of #46, and vrowzer's wait for the project files and the channel. - An error on the way to the Web Worker gets a plain 500. The Web Worker answers the errors of the project with Vite's error page. Only the Web Worker, which has environments, uses the error middleware now, so logError() is back to Vite's form. - The Web Worker no longer sends the safe module paths to the Service Worker, which serves no files. - initServiceWorker() of vrowzer/service-worker-core no longer takes plugins. Plugins run in the Web Worker. - The old browser test of the Service Worker server, which no test project ran, is removed. The Service Worker still receives the project files. The next commit stops sending them. --- .../src/node/public-files-sync.test.ts | 63 - .../src/node/public-files-sync.ts | 47 - .../src/node/server/environment.test.ts | 36 +- .../src/node/server/environment.ts | 8 - .../src/node/server/index.browser-test.ts | 306 ----- .../server/middlewares/beforeRequest.test.ts | 77 -- .../node/server/middlewares/beforeRequest.ts | 32 - .../src/node/server/middlewares/error.test.ts | 26 - .../src/node/server/middlewares/error.ts | 8 +- .../src/node/server/safeModulePaths.ts | 7 - .../src/node/service-worker.test.ts | 387 +++++++ .../src/node/service-worker.ts | 1012 ++++------------- .../src/node/web-worker.test.ts | 42 +- .../vite-dev-server/src/node/web-worker.ts | 22 +- .../vite-dev-server/src/shared/rpc.test.ts | 90 +- packages/vite-dev-server/src/shared/rpc.ts | 53 +- .../vite-dev-server/test-public/test-sw.ts | 96 -- .../integration/restart.integration-test.ts | 4 +- .../vrowzer/src/service-worker-core.test.ts | 109 +- packages/vrowzer/src/service-worker-core.ts | 103 +- 20 files changed, 633 insertions(+), 1895 deletions(-) delete mode 100644 packages/vite-dev-server/src/node/public-files-sync.test.ts delete mode 100644 packages/vite-dev-server/src/node/public-files-sync.ts delete mode 100644 packages/vite-dev-server/src/node/server/index.browser-test.ts delete mode 100644 packages/vite-dev-server/src/node/server/middlewares/beforeRequest.test.ts delete mode 100644 packages/vite-dev-server/src/node/server/middlewares/beforeRequest.ts create mode 100644 packages/vite-dev-server/src/node/service-worker.test.ts delete mode 100644 packages/vite-dev-server/test-public/test-sw.ts diff --git a/packages/vite-dev-server/src/node/public-files-sync.test.ts b/packages/vite-dev-server/src/node/public-files-sync.test.ts deleted file mode 100644 index 401c89a4..00000000 --- a/packages/vite-dev-server/src/node/public-files-sync.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { createVirtualFSWatcher } from '@vrowzer/fs/watcher' -import { describe, expect, onTestFinished, test } from 'vite-plus/test' -import { syncPublicFiles } from './public-files-sync' - -import type { FSWatcher } from '#dep-types/chokidar' - -function setup(initialFiles: string[] = []) { - const watcher = createVirtualFSWatcher() - onTestFinished(() => watcher.close()) - const publicFiles = new Set(initialFiles) - syncPublicFiles(watcher as unknown as FSWatcher, '/public', publicFiles) - return { watcher, publicFiles } -} - -describe('syncPublicFiles', () => { - test('adds files under the public directory as soon as the event is emitted', () => { - const { watcher, publicFiles } = setup() - - watcher.notify('add', '/public/logo.svg') - watcher.notify('add', '/public/images/a.png') - - // No await: the list is already updated when notify() returns - expect([...publicFiles]).toEqual(['/logo.svg', '/images/a.png']) - }) - - test('removes deleted files', () => { - const { watcher, publicFiles } = setup(['/.gitkeep', '/logo.svg']) - - watcher.notify('unlink', '/public/logo.svg') - - expect([...publicFiles]).toEqual(['/.gitkeep']) - }) - - test('ignores files outside the public directory', () => { - const { watcher, publicFiles } = setup() - - watcher.notify('add', '/src/main.ts') - // Vite's `startsWith(publicDir)` check would take this sibling for a public file - watcher.notify('add', '/publicity.js') - watcher.notify('add', '/public') - watcher.notify('add', '/public/../secret.txt') - - expect([...publicFiles]).toEqual([]) - }) - - test('keeps the list on change events', () => { - const { watcher, publicFiles } = setup(['/logo.svg']) - - watcher.notify('change', '/public/logo.svg') - watcher.notify('change', '/public/new.svg') - - expect([...publicFiles]).toEqual(['/logo.svg']) - }) - - test('normalizes paths before checking them', () => { - const { watcher, publicFiles } = setup() - - watcher.notify('add', '/public/./b.svg') - watcher.notify('add', '/public/images/../c.svg') - - expect([...publicFiles]).toEqual(['/b.svg', '/c.svg']) - }) -}) diff --git a/packages/vite-dev-server/src/node/public-files-sync.ts b/packages/vite-dev-server/src/node/public-files-sync.ts deleted file mode 100644 index 9a21d723..00000000 --- a/packages/vite-dev-server/src/node/public-files-sync.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Keep the Service Worker's list of public files in sync with its virtual file system. - * - * Vite reads the public directory once when the dev server starts, and updates the list in the - * watcher handlers of `_createServer()`. The Service Worker's dev server does not register those - * handlers, and it receives the project files only after it has started, so without this the list - * would stay as it was at startup. - * - * @author kazuya kawaguchi (a.k.a. kazupon) - * @license MIT - */ - -import { withTrailingSlash } from '../shared/utils' -import { normalizePath } from './utils' - -import type { FSWatcher } from '#dep-types/chokidar' - -/** - * Keeps `publicFiles` up to date with the watcher's `add` and `unlink` events, as Vite does in - * `onFileAddUnlink`. The list is updated synchronously, so it is current as soon as an event has - * been emitted. - * - * Unlike Vite, the public directory is checked with a trailing slash, so that a sibling such as - * `/publicity.js` is not taken for a public file. A module with the same path keeps its transform - * result, because the Service Worker has no module graph. - * - * @param watcher - The watcher that reports the changes of the virtual file system - * @param publicDir - The resolved public directory, e.g. `/public` - * @param publicFiles - The list that `servePublicMiddleware` checks, with paths relative to `publicDir` - */ -export function syncPublicFiles( - watcher: FSWatcher, - publicDir: string, - publicFiles: Set, -): void { - const publicDirPrefix = withTrailingSlash(publicDir) - const update = (file: string, isUnlink: boolean) => { - file = normalizePath(file) - if (!file.startsWith(publicDirPrefix)) { - return - } - publicFiles[isUnlink ? 'delete' : 'add'](file.slice(publicDir.length)) - } - - watcher.on('add', (file) => update(file, false)) - watcher.on('unlink', (file) => update(file, true)) -} diff --git a/packages/vite-dev-server/src/node/server/environment.test.ts b/packages/vite-dev-server/src/node/server/environment.test.ts index ae2ca306..cbb2a851 100644 --- a/packages/vite-dev-server/src/node/server/environment.test.ts +++ b/packages/vite-dev-server/src/node/server/environment.test.ts @@ -225,12 +225,9 @@ function createRegistrationContext( } | null | undefined>, ) { const safeModulePaths = new Set() - const syncSafeModulePaths = vi.fn<(paths: string[]) => Promise>( - async () => undefined, - ) const resolver = vi.fn(resolveId) - return { input, resolver, safeModulePaths, syncSafeModulePaths } + return { input, resolver, safeModulePaths } } function createLifecycleEnvironment(plugins: Plugin[]) { @@ -274,7 +271,7 @@ describe('DevEnvironment input registration', () => { external: 'virtual:external', relative: 'virtual:relative', } - const { resolver, safeModulePaths, syncSafeModulePaths } = + const { resolver, safeModulePaths } = createRegistrationContext(input, async (id) => { if (id === 'virtual:external') { return { id: '/outside/external.ts', external: true } @@ -289,7 +286,6 @@ describe('DevEnvironment input registration', () => { input, resolver, safeModulePaths, - syncSafeModulePaths, ) expect(resolver).toHaveBeenCalledTimes(4) @@ -301,12 +297,10 @@ describe('DevEnvironment input registration', () => { ) } expect(safeModulePaths).toEqual(new Set(['/outside/main.ts'])) - expect(syncSafeModulePaths).toHaveBeenCalledOnce() - expect(syncSafeModulePaths).toHaveBeenCalledWith(['/outside/main.ts']) }) test('silently ignores fallback index resolution errors', async () => { - const { resolver, safeModulePaths, syncSafeModulePaths } = + const { resolver, safeModulePaths } = createRegistrationContext(undefined, async () => { throw new Error('index.html is not available') }) @@ -316,7 +310,6 @@ describe('DevEnvironment input registration', () => { undefined, resolver, safeModulePaths, - syncSafeModulePaths, ), ).resolves.toBeUndefined() expect(resolver).toHaveBeenCalledWith( @@ -325,30 +318,10 @@ describe('DevEnvironment input registration', () => { { isEntry: true, scan: true }, ) expect(safeModulePaths).toEqual(new Set()) - expect(syncSafeModulePaths).not.toHaveBeenCalled() - }) - - test('syncs an input path that is already registered locally', async () => { - const resolvedInput = '/outside/main.ts' - const { resolver, safeModulePaths, syncSafeModulePaths } = - createRegistrationContext('virtual:entry', async () => ({ - id: resolvedInput, - })) - safeModulePaths.add(resolvedInput) - - await registerInputsAsSafeModules( - 'virtual:entry', - resolver, - safeModulePaths, - syncSafeModulePaths, - ) - - expect(safeModulePaths).toEqual(new Set([resolvedInput])) - expect(syncSafeModulePaths).toHaveBeenCalledWith([resolvedInput]) }) test('propagates explicit input resolution errors', async () => { - const { resolver, safeModulePaths, syncSafeModulePaths } = + const { resolver, safeModulePaths } = createRegistrationContext( 'virtual:entry', async () => { @@ -361,7 +334,6 @@ describe('DevEnvironment input registration', () => { 'virtual:entry', resolver, safeModulePaths, - syncSafeModulePaths, ), ).rejects.toThrow('explicit input failed') }) diff --git a/packages/vite-dev-server/src/node/server/environment.ts b/packages/vite-dev-server/src/node/server/environment.ts index 808f9a30..b88952d5 100644 --- a/packages/vite-dev-server/src/node/server/environment.ts +++ b/packages/vite-dev-server/src/node/server/environment.ts @@ -87,13 +87,6 @@ export class DevEnvironment extends BaseEnvironment { */ _pluginContainer: EnvironmentPluginContainer | undefined - /** - * @internal Vrowzer synchronizes safe paths to the Service Worker. - */ - _syncSafeModulePaths: - | ((paths: string[]) => Promise) - | undefined - /** * @internal */ @@ -245,7 +238,6 @@ export class DevEnvironment extends BaseEnvironment { (id, importer, options) => this.pluginContainer.resolveId(id, importer, options), this.getTopLevelConfig().safeModulePaths, - this._syncSafeModulePaths, ) } diff --git a/packages/vite-dev-server/src/node/server/index.browser-test.ts b/packages/vite-dev-server/src/node/server/index.browser-test.ts deleted file mode 100644 index 25d8ef9a..00000000 --- a/packages/vite-dev-server/src/node/server/index.browser-test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from 'vite-plus/test' -import { createSvcWorkerController } from '@vrowzer/service-worker/controller' -import type { SvcWorkerController } from '@vrowzer/service-worker/controller' - -// Clean up all service worker registrations -async function cleanupServiceWorkers(): Promise { - const registrations = await navigator.serviceWorker.getRegistrations() - await Promise.all(registrations.map(r => r.unregister())) -} - -// Send message to service worker and wait for response -async function sendMessage(sw: ServiceWorker, message: unknown): Promise { - return new Promise((resolve) => { - const channel = new MessageChannel() - channel.port1.onmessage = (event) => resolve(event.data) - sw.postMessage(message, [channel.port2]) - }) -} - -// Wait for SW's Vite dev server to finish initialization (listen() completion) -async function waitForServerReady(controller: SvcWorkerController, timeout = 10000): Promise { - await controller.ready() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - // Before rejecting, try to get server status for debugging - const sw = controller.serviceWorker - if (sw) { - sendMessage(sw, { type: 'GET_SERVER_STATUS' }).then(status => { - console.error('[waitForServerReady] timeout - server status:', status) - }).catch(() => {}) - } - reject(new Error('Server listen timeout')) - }, timeout) - const handler = (event: MessageEvent) => { - if (event.data?.type === 'V_SW_LISTEN_READY') { - clearTimeout(timer) - navigator.serviceWorker.removeEventListener('message', handler) - resolve() - } - } - navigator.serviceWorker.addEventListener('message', handler) - controller.serviceWorker?.postMessage({ type: 'V_SW_LISTEN_READY_PING' }) - }) -} - -// Track controller for cleanup -let currentController: SvcWorkerController | null = null - -describe('createServer', () => { - beforeEach(async () => { - await cleanupServiceWorkers() - }) - - afterEach(() => { - // Dispose controller to clear singleton cache - if (currentController) { - currentController.dispose() - currentController = null - } - }) - - describe('basic behavior', () => { - test('createServer should correctly create ViteDevServer', async () => { - // NOTE: scriptURL must use new URL(..., import.meta.url) directly here - // for unplugin-service-worker to detect and bundle the service worker - // The browser project must serve this package's test-public directory at the root - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - // Check server status via message - const status = await sendMessage(sw!, { type: 'GET_SERVER_STATUS' }) - expect(status.hasServer).toBe(true) - expect(status.hasMiddlewares).toBe(true) - expect(status.hasHttpServer).toBe(true) - }) - - test('server.middlewares should be a Hono app instance', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - const status = await sendMessage(sw!, { type: 'GET_SERVER_STATUS' }) - expect(status.hasMiddlewares).toBe(true) - }) - - test('server.httpServer should be a SvcWorkerServer instance', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - const status = await sendMessage(sw!, { type: 'GET_SERVER_STATUS' }) - expect(status.hasHttpServer).toBe(true) - }) - }) - - describe('server.listen() behavior', () => { - test('server.listen() should set up Service Worker fetch event handler', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - // After listen(), the SW should be able to handle fetch requests - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - const status = await sendMessage(sw!, { type: 'GET_SERVER_STATUS' }) - expect(status.hasServer).toBe(true) - }) - - test('server.listen() should return ViteDevServer', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - // If we got here, listen() returned successfully in the SW - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - const status = await sendMessage(sw!, { type: 'GET_SERVER_STATUS' }) - expect(status.hasServer).toBe(true) - }) - }) - - describe('built-in route behavior', () => { - test('GET /hello should return correct response', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const response = await fetch('/hello') - expect(response.ok).toBe(true) - - const text = await response.text() - expect(text).toBe('Vite Dev Server on Service Worker says hello!') - }) - - test('fetch requests should be correctly handled via Hono', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const response = await fetch('/hello') - expect(response.ok).toBe(true) - expect(response.status).toBe(200) - }) - - test('fetch requests from iframe should be intercepted by service worker', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - // Create an iframe and perform fetch from within it - const iframe = document.createElement('iframe') - iframe.srcdoc = ` - - - Test iframe - - - - - ` - document.body.appendChild(iframe) - - try { - // Wait for iframe to be ready - await new Promise((resolve) => { - const handler = (event: MessageEvent) => { - if (event.data.type === 'IFRAME_READY') { - window.removeEventListener('message', handler) - resolve() - } - } - window.addEventListener('message', handler) - }) - - // Request fetch from iframe - const fetchResult = await new Promise<{ ok: boolean; status: number; text: string }>((resolve, reject) => { - const handler = (event: MessageEvent) => { - if (event.data.type === 'FETCH_RESPONSE') { - window.removeEventListener('message', handler) - if (event.data.error) { - reject(new Error(event.data.error)) - } else { - resolve(event.data) - } - } - } - window.addEventListener('message', handler) - iframe.contentWindow!.postMessage({ type: 'FETCH_REQUEST', url: '/hello' }, '*') - }) - - expect(fetchResult.ok).toBe(true) - expect(fetchResult.status).toBe(200) - expect(fetchResult.text).toBe('Vite Dev Server on Service Worker says hello!') - } finally { - // Cleanup iframe - document.body.removeChild(iframe) - } - }) - }) - - describe('server.close() behavior', () => { - test('server.close() should correctly stop the server', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - const result = await sendMessage(sw!, { type: 'CLOSE_SERVER' }) - expect(result.success).toBe(true) - }) - - test('calling close() multiple times should be safe', async () => { - currentController = createSvcWorkerController({ - scriptURL: new URL('/test-sw.ts', import.meta.url), - version: 'test-v1', - scope: '/', - }) - - await waitForServerReady(currentController) - - const sw = currentController.serviceWorker - expect(sw).not.toBeNull() - - // First close - const result1 = await sendMessage(sw!, { type: 'CLOSE_SERVER' }) - expect(result1.success).toBe(true) - - // Second close - should still be safe (close is idempotent) - const result2 = await sendMessage(sw!, { type: 'CLOSE_SERVER' }) - // server.close() is idempotent - returns same resolved promise - expect(result2.success).toBe(true) - }) - }) -}) 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 deleted file mode 100644 index 823469e6..00000000 --- a/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index d356c7eb..00000000 --- a/packages/vite-dev-server/src/node/server/middlewares/beforeRequest.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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/server/middlewares/error.test.ts b/packages/vite-dev-server/src/node/server/middlewares/error.test.ts index 85aadea8..f3419151 100644 --- a/packages/vite-dev-server/src/node/server/middlewares/error.test.ts +++ b/packages/vite-dev-server/src/node/server/middlewares/error.test.ts @@ -178,30 +178,4 @@ describe('errorMiddleware', () => { }), ) }) - - test('should return 500 status with HTML when the server has no environments', async () => { - // The Service Worker-side server object (`ViteDevServerForServiceWorker`) has no `environments` - const server = { - config: { - base: '/', - logger: { - error: vi.fn<() => void>(), - }, - }, - } as unknown as ViteDevServer - const app = new Hono() - - app.get('/test', () => { - throw new Error('test error') - }) - app.onError(errorMiddleware(server, false)) - - const res = await app.request('/test') - - expect(res.status).toBe(500) - const body = await res.text() - expect(body).toContain('') - expect(body).toContain('test error') - expect(server.config.logger.error).toHaveBeenCalled() - }) }) diff --git a/packages/vite-dev-server/src/node/server/middlewares/error.ts b/packages/vite-dev-server/src/node/server/middlewares/error.ts index 202dd444..4c314536 100644 --- a/packages/vite-dev-server/src/node/server/middlewares/error.ts +++ b/packages/vite-dev-server/src/node/server/middlewares/error.ts @@ -74,13 +74,7 @@ export function logError(server: ViteDevServer, err: RollupError): void { error: err, }) - // NOTE(kazupon): The Service Worker-side server object (`ViteDevServerForServiceWorker`) has no - // `environments`, so skip the HMR error payload there instead of throwing from the error handler. - // server.environments.client.hot.send({ - // type: 'error', - // err: prepareError(err), - // }) - server.environments?.client.hot.send({ + server.environments.client.hot.send({ type: 'error', err: prepareError(err), }) diff --git a/packages/vite-dev-server/src/node/server/safeModulePaths.ts b/packages/vite-dev-server/src/node/server/safeModulePaths.ts index 40f2ecc0..e350625e 100644 --- a/packages/vite-dev-server/src/node/server/safeModulePaths.ts +++ b/packages/vite-dev-server/src/node/server/safeModulePaths.ts @@ -20,7 +20,6 @@ export async function registerInputsAsSafeModules( input: InputOption | undefined, resolveId: ResolveInput, safeModulePaths: Set, - syncSafeModulePaths?: (paths: string[]) => Promise, ): Promise { const entries = input == null @@ -30,7 +29,6 @@ export async function registerInputsAsSafeModules( : Array.isArray(input) ? input : Object.values(input) - const registeredPaths = new Set() const resolveEntries = async () => { const resolvedEntries = await Promise.all( @@ -48,7 +46,6 @@ export async function registerInputsAsSafeModules( const resolvedId = cleanUrl(resolved.id) if (path.isAbsolute(resolvedId)) { safeModulePaths.add(resolvedId) - registeredPaths.add(resolvedId) } } } @@ -59,8 +56,4 @@ export async function registerInputsAsSafeModules( } else { await resolveEntries() } - - if (registeredPaths.size > 0) { - await syncSafeModulePaths?.([...registeredPaths]) - } } diff --git a/packages/vite-dev-server/src/node/service-worker.test.ts b/packages/vite-dev-server/src/node/service-worker.test.ts new file mode 100644 index 00000000..aa915db2 --- /dev/null +++ b/packages/vite-dev-server/src/node/service-worker.test.ts @@ -0,0 +1,387 @@ +import { createBirpc } from 'birpc' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test' +import { getRpcTransferList } from '../shared/requestTransport' +import { deserializeRpcMessage, serializeRpcMessage } from '../shared/rpc' +import type { SerializedRequest, SerializedResponse, WorkerFunctions } from '../shared/rpc' + +type Listener = (event: any) => void + +interface FakeServer { + fetchHandler: ((event: FetchEvent) => void) | null + listeners: Map + listenOptions: unknown + emit(type: string, event: unknown): void +} + +const svcMocks = vi.hoisted(() => ({ + server: null as FakeServer | null, +})) + +vi.mock('@vrowzer/service-worker-server', () => ({ + createSvcWorkerServer: () => { + const listeners = new Map() + const server = { + fetchHandler: null as ((event: FetchEvent) => void) | null, + listeners, + listenOptions: undefined as unknown, + emit(type: string, event: unknown) { + for (const listener of listeners.get(type) ?? []) { + listener(event) + } + }, + setFetchHandler(handler: (event: FetchEvent) => void) { + server.fetchHandler = handler + }, + on(type: string, listener: Listener) { + listeners.set(type, [...(listeners.get(type) ?? []), listener]) + return server + }, + once(type: string, listener: Listener) { + return server.on(type, listener) + }, + off() { + return server + }, + listen(options: unknown) { + server.listenOptions = options + return server + }, + close(cb?: (error?: Error) => void) { + cb?.() + return server + }, + } + svcMocks.server = server + return server + }, +})) + +import { createServer } from './service-worker' + +const ORIGIN = 'https://vrowzer.test' + +interface WindowClient { + id: string + postMessage: ReturnType void>> +} + +interface FetchResult { + /** `undefined` when the Service Worker left the request to the browser */ + response: Promise | undefined +} + +let clients: WindowClient[] +let openPorts: MessagePort[] + +function createScope(): ServiceWorkerGlobalScope { + return { + location: { href: `${ORIGIN}/service-worker.js` }, + clients: { + get: async (id: string) => clients.find(client => client.id === id), + }, + } as unknown as ServiceWorkerGlobalScope +} + +function fakeServer(): FakeServer { + if (!svcMocks.server) { + throw new Error('createSvcWorkerServer() was not called') + } + return svcMocks.server +} + +async function startServer(options: Parameters[1] = { basePath: '/__preview__/' }) { + const listen = createServer(createScope(), options) + return listen() +} + +function dispatchFetch(path: string, init?: RequestInit): FetchResult { + const result: FetchResult = { response: undefined } + const event = { + request: new Request(`${ORIGIN}${path}`, init), + respondWith(response: Promise) { + result.response = response + }, + } + fakeServer().fetchHandler!(event as unknown as FetchEvent) + return result +} + +function bytes(text: string): ArrayBuffer { + return new TextEncoder().encode(text).buffer as ArrayBuffer +} + +function okResponse(body: string, headers: [string, string][] = []): SerializedResponse { + return { status: 200, statusText: 'OK', headers, body: bytes(body) } +} + +interface ConnectedWorker { + port: MessagePort + handleRequest: ReturnType Promise>> + hmrPorts: { clientId?: string, port: MessagePort }[] +} + +/** + * Connects a Web Worker as the runtime does: the client sends `V_WW_CONNECT_PORT` with one end of + * a channel, and the Web Worker completes the handshake on the other end. + */ +async function connectWorker( + clientId = 'host-1', + handleRequest = vi.fn<(request: SerializedRequest) => Promise>( + async () => okResponse('answer'), + ), +): Promise { + const channel = new MessageChannel() + openPorts.push(channel.port1, channel.port2) + const worker: ConnectedWorker = { port: channel.port2, handleRequest, hmrPorts: [] } + const connected = new Promise((resolve) => { + channel.port2.onmessage = (event) => { + if (event.data?.type !== 'V_WW_SW_CHANNEL_READY' || event.data.source !== 'sw') { + return + } + createBirpc, Pick>({ handleRequest }, { + post: data => channel.port2.postMessage(data, getRpcTransferList(data)), + on: fn => { + channel.port2.onmessage = (message) => { + if (message.data?.type === 'V_WW_HMR_PORT') { + worker.hmrPorts.push({ clientId: message.data.clientId, port: message.ports[0]! }) + return + } + fn(message.data) + } + }, + serialize: serializeRpcMessage, + deserialize: deserializeRpcMessage, + }) + resolve() + } + }) + fakeServer().emit('connection', { + data: { type: 'V_WW_CONNECT_PORT' }, + ports: [channel.port1], + clientId, + }) + channel.port2.postMessage({ type: 'V_WW_SW_CHANNEL_READY', source: 'ww' }) + await connected + return worker +} + +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 +} + +beforeEach(() => { + svcMocks.server = null + clients = [{ id: 'host-1', postMessage: vi.fn<(message: unknown) => void>() }] + openPorts = [] +}) + +afterEach(() => { + for (const port of openPorts) { + port.close() + } + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('Service Worker request forwarding', () => { + test('starts listening for fetch events and connections', async () => { + await startServer() + + expect(fakeServer().fetchHandler).toEqual(expect.any(Function)) + expect(fakeServer().listenOptions).toMatchObject({ enableListenConnections: true }) + }) + + test('forwards a request within the base path to the Web Worker and answers with its response', async () => { + await startServer() + const worker = await connectWorker() + worker.handleRequest.mockResolvedValueOnce({ + status: 201, + statusText: 'Created', + headers: [['content-type', 'text/javascript'], ['etag', 'W/"1"']], + body: bytes('export default 1'), + }) + + const { response } = dispatchFetch('/__preview__/src/main.ts?t=1', { + headers: { accept: 'text/javascript' }, + }) + const answer = await response! + + expect(worker.handleRequest).toHaveBeenCalledExactlyOnceWith({ + url: `${ORIGIN}/__preview__/src/main.ts?t=1`, + method: 'GET', + headers: [['accept', 'text/javascript']], + body: null, + }) + expect(answer.status).toBe(201) + expect(answer.statusText).toBe('Created') + expect(answer.headers.get('content-type')).toBe('text/javascript') + expect(answer.headers.get('etag')).toBe('W/"1"') + expect(await answer.text()).toBe('export default 1') + }) + + test('adds the cross-origin isolation headers to the response', async () => { + await startServer() + await connectWorker() + + const answer = await dispatchFetch('/__preview__/').response! + + expect(answer.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin') + expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + expect(answer.headers.get('Cross-Origin-Opener-Policy')).toBe('same-origin') + }) + + test('forwards the body of a request', async () => { + await startServer() + const worker = await connectWorker() + + await dispatchFetch('/__preview__/api', { method: 'POST', body: 'payload' }).response + + const [request] = worker.handleRequest.mock.calls[0]! + expect(request.method).toBe('POST') + expect(new TextDecoder().decode(request.body!)).toBe('payload') + }) + + test('answers a response without a body', async () => { + await startServer() + const worker = await connectWorker() + worker.handleRequest.mockResolvedValueOnce({ status: 304, statusText: '', headers: [], body: null }) + + const answer = await dispatchFetch('/__preview__/src/main.ts').response! + + expect(answer.status).toBe(304) + expect(answer.body).toBeNull() + }) + + test('leaves a request outside the base path to the browser', async () => { + await startServer() + const worker = await connectWorker() + + expect(dispatchFetch('/index.html').response).toBeUndefined() + expect(dispatchFetch('/__preview__other/main.js').response).toBeUndefined() + expect(worker.handleRequest).not.toHaveBeenCalled() + }) + + test('answers with 500 when the Web Worker fails to answer', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + await startServer() + const worker = await connectWorker() + worker.handleRequest.mockRejectedValueOnce(new Error('handler failed')) + + const answer = await dispatchFetch('/__preview__/src/main.ts').response! + + expect(answer.status).toBe(500) + expect(await answer.text()).toContain('handler failed') + expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + }) + + test('tells the client that sent the port that the connection is established', async () => { + await startServer() + + await connectWorker('host-1') + + await vi.waitFor(() => { + expect(clients[0]!.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'V_WW_CONNECT_PORT_ACK' }) + }) + }) + + test('forwards requests to the Web Worker that connected last', async () => { + await startServer() + const first = await connectWorker() + const second = await connectWorker() + + await dispatchFetch('/__preview__/src/main.ts').response + + expect(first.handleRequest).not.toHaveBeenCalled() + expect(second.handleRequest).toHaveBeenCalledOnce() + }) +}) + +describe('Service Worker without a Web Worker', () => { + test('holds a request until a Web Worker connects', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) + + const { response } = dispatchFetch('/__preview__/src/main.ts') + await vi.advanceTimersByTimeAsync(999) + expect(await isPending(response!)).toBe(true) + const worker = await connectWorker() + + const answer = await response! + expect(answer.status).toBe(200) + expect(await answer.text()).toBe('answer') + expect(worker.handleRequest).toHaveBeenCalledOnce() + }) + + test('answers with 503 when no Web Worker connects in time', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) + + const { response } = dispatchFetch('/__preview__/src/main.ts') + await vi.advanceTimersByTimeAsync(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')).toMatch(/^text\/plain/) + expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + expect(await answer.text()).toContain('within 1000ms') + }) + + test('waits for 10 seconds by default', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await startServer() + + const { response } = dispatchFetch('/__preview__/src/main.ts') + await vi.advanceTimersByTimeAsync(9_999) + expect(await isPending(response!)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + + expect((await response!).status).toBe(503) + }) +}) + +describe('Service Worker HMR ports', () => { + test('forwards the HMR port of a preview to the Web Worker', async () => { + await startServer() + const worker = await connectWorker() + const hmrChannel = new MessageChannel() + openPorts.push(hmrChannel.port1, hmrChannel.port2) + + fakeServer().emit('connection', { + data: { type: 'vite:mc:init', clientId: 'preview-1' }, + ports: [hmrChannel.port1], + clientId: 'preview-1', + }) + + await vi.waitFor(() => { + expect(worker.hmrPorts).toHaveLength(1) + }) + expect(worker.hmrPorts[0]!.clientId).toBe('preview-1') + }) + + test('forwards an HMR port once a Web Worker connects', async () => { + await startServer() + const hmrChannel = new MessageChannel() + openPorts.push(hmrChannel.port1, hmrChannel.port2) + + fakeServer().emit('connection', { + data: { type: 'vite:mc:init', clientId: 'preview-1' }, + ports: [hmrChannel.port1], + clientId: 'preview-1', + }) + const worker = await connectWorker() + + await vi.waitFor(() => { + expect(worker.hmrPorts).toHaveLength(1) + }) + }) +}) diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index 0ea0d95d..27f5bf00 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -1,9 +1,11 @@ /** * Service Worker entry point for @vrowzer/vite-dev-server * - * This module exports everything needed to run the Vite dev server - * as a proxy inside a Service Worker: server creation, middleware, - * MessageChannel HMR server, and configuration. + * The Service Worker forwards each request within the base path to the Web Worker, which has the + * project files, the module graph and the plugins, and answers the request with the Vite + * middlewares. It also forwards the HMR ports of the previews to the Web Worker. + * + * The Service Worker does not resolve the Vite config, and has neither plugins nor files. * * @module node/service-worker */ @@ -17,345 +19,185 @@ import { createSvcWorkerServer } from '@vrowzer/service-worker-server' import { createBirpc } from 'birpc' import { Hono } from 'hono' import { handle } from 'hono/service-worker' -import { - createServiceWorkerFunctions, - deserializeRpcMessage, - serializeRpcMessage, -} from '../shared/rpc' +import { getRpcTransferList, serializeRequest } from '../shared/requestTransport' +import { deserializeRpcMessage, serializeRpcMessage } from '../shared/rpc' import { shouldHandleViteFetch } from '../shared/serviceWorkerFetch' -import { assertBundledDevUnsupported } from './bundled-dev-guard' -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' -import { - indexHtmlMiddleware -} from './server/middlewares/indexHtml' -import { notFoundMiddleware } from './server/middlewares/notFound' -import { servePublicMiddleware, serveRawFsMiddleware, serveStaticMiddleware } from './server/middlewares/static' import { timeMiddleware } from './server/middlewares/time' -import { transformMiddleware } from './server/middlewares/transform' -import { - BasicMinimalPluginContext, - basePluginContextMeta -} from './server/pluginContainer' -import { - createDebugger -} from './utils' -import { - createNoopWatcher, - getResolvedOutDirs, - resolveChokidarOptions, - resolveEmptyOutDir, -} from './watch' +import { createDebugger } from './utils' const debug = createDebugger('vrowzer:service-worker') -import type { FSWatcher, WatchOptions } from '#dep-types/chokidar' -import type { ListenOptions, SvcWorkerServer } from '@vrowzer/service-worker-server' -import type { BlankSchema, Env } from 'hono/types' -import type { ConnectWebWorkerPortMessage, ViteMessageChannelInitMessage, WebWorkerServiceWorkerChannelReadyMessage } from '../shared/messages' -import type { ServiceWorkerFunctions, WorkerFunctions } from '../shared/rpc' +import type { ConnectionEvent, SvcWorkerServer } from '@vrowzer/service-worker-server' +import type { BirpcReturn } from 'birpc' +import type { BlankSchema } from 'hono/types' +import type { StatusCode } from 'hono/utils/http-status' import type { - ForwardConsoleOptions, - ResolvedForwardConsoleOptions, -} from '../shared/forwardConsole' -import type { InlineConfig, ResolvedConfig } from './config' -import type { CommonServerOptions } from './http' -import type { MinimalPluginContextWithoutEnvironment, Plugin } from './plugin' -import type { DevEnvironment } from './server/environment' -import type { HmrOptions, WsOptions } from './server/hmr' -import type { ViteDevServer } from './server/index' -import type { ShortcutsState } from './shortcuts' -import type { RequiredExceptFor } from './typeUtils' - -export * from './server/middlewares/utils' - -export interface ServerOptions extends CommonServerOptions { - /** - * Configure HMR-specific options (port, host, path & protocol) - */ - hmr?: HmrOptions | boolean - /** - * Configure MessageChannel connection options. - * Set to `false` to disable the MessageChannel server and connection. - * - * WebSocket-specific address options are retained for Vite config compatibility. - */ - ws?: WsOptions | false - /** - * Warm-up files to transform and cache the results in advance. This improves the - * initial page load during server starts and prevents transform waterfalls. - */ - warmup?: { - /** - * The files to be transformed and used on the client-side. Supports glob patterns. - */ - clientFiles?: string[] - /** - * The files to be transformed and used in SSR. Supports glob patterns. - */ - ssrFiles?: string[] - } - /** - * chokidar watch options or null to disable FS watching - * https://github.com/paulmillr/chokidar/tree/3.6.0#api - */ - watch?: WatchOptions | null - /** - * Create Vite dev server to be used as a middleware in an existing server - * @default false - */ - middlewareMode?: - | boolean - | { - /** - * Parent server instance to attach to - * - * This is needed to proxy MessageChannel connections - */ - server: HttpServer - } - /** - * Options for files served via '/\@fs/'. - */ - fs?: FileSystemServeOptions - /** - * Origin for the generated asset URLs. - * - * @example `http://127.0.0.1:8080` - */ - origin?: string - /** - * Pre-transform known direct imports - * @default true - */ - preTransformRequests?: boolean - /** - * Whether or not to ignore-list source files in the dev server sourcemap, used to populate - * the [`x_google_ignoreList` source map extension](https://developer.chrome.com/blog/devtools-better-angular-debugging/#the-x_google_ignorelist-source-map-extension). - * - * By default, it excludes all paths containing `node_modules`. You can pass `false` to - * disable this behavior, or, for full control, a function that takes the source path and - * sourcemap path and returns whether to ignore the source path. - */ - sourcemapIgnoreList?: - | false - | ((sourcePath: string, sourcemapPath: string) => boolean) - /** - * Backward compatibility. The buildStart and buildEnd hooks were called only once for - * the client environment. This option enables per-environment buildStart and buildEnd hooks. - * @default false - * @experimental - */ - perEnvironmentStartEndDuringDev?: boolean - /** - * Backward compatibility. The watchChange hook was called only once for the client environment. - * This option enables per-environment watchChange hooks. - * @default false - * @experimental - */ - perEnvironmentWatchChangeDuringDev?: boolean - /** - * Run HMR tasks, by default the HMR propagation is done in parallel for all environments - * @experimental - */ - hotUpdateEnvironments?: ( - server: ViteDevServer, - hmr: (environment: DevEnvironment) => Promise, - ) => Promise - /** - * Forward browser console logs and unhandled errors to the Vrowzer - * Web Worker dev server console. - */ - forwardConsole?: boolean | ForwardConsoleOptions -} - -export interface ResolvedServerOptions extends Omit< - RequiredExceptFor< - ServerOptions, - | 'host' - | 'https' - | 'proxy' - | 'hmr' - | 'ws' - | 'watch' - | 'origin' - | 'hotUpdateEnvironments' - >, - 'fs' | 'middlewareMode' | 'sourcemapIgnoreList' | 'forwardConsole' -> { - fs: Required - middlewareMode: NonNullable - sourcemapIgnoreList: Exclude< - ServerOptions['sourcemapIgnoreList'], - false | undefined - > - forwardConsole: ResolvedForwardConsoleOptions -} - -export interface FileSystemServeOptions { - /** - * Strictly restrict file accessing outside of allowing paths. - * - * Set to `false` to disable the warning - * - * @default true - */ - strict?: boolean - - /** - * Restrict accessing files outside the allowed directories. - * - * Accepts absolute path or a path relative to project root. - * Will try to search up for workspace root by default. - */ - allow?: string[] - - /** - * Restrict accessing files that matches the patterns. - * - * This will have higher priority than `allow`. - * picomatch patterns are supported. - * - * @default ['.env', '.env.*', '*.{crt,pem,key,p12,pfx,cer,der}', '.npmrc', '.yarnrc.yml', '**\/.git/**'] - */ - deny?: string[] -} + ConnectWebWorkerPortMessage, + ViteMessageChannelInitMessage, + WebWorkerServiceWorkerChannelReadyMessage, +} from '../shared/messages' +import type { ServiceWorkerFunctions, WorkerFunctions } from '../shared/rpc' +import type { ViteEnv } from './server/index' /** - * Environment type for Vite Dev Server running in Service Worker + * The server that receives the fetch events and the MessageChannel connections of the Service Worker. */ -export interface ViteEnv extends Env { - // Bindings available throughout the request lifecycle - Bindings: {} - // Variables set during request processing - Variables: { - /** Rewritten URL after base stripping (set by baseMiddleware) */ - rewrittenUrl?: string - } -} - -export type ServerHook = ( - this: MinimalPluginContextWithoutEnvironment, - server: ViteDevServer, -) => (() => void) | void | Promise<(() => void) | void> - export type HttpServer = SvcWorkerServer< ConnectWebWorkerPortMessage | ViteMessageChannelInitMessage > -/** - * Subset of ViteDevServer for Service Worker environment. - * - * The full ViteDevServer (defined in server/index.ts) is the single source of truth. - * This type picks the properties needed by the SW proxy: fetch handling, middleware, - * and birpc-delegated transform methods. - */ -export type ViteDevServerForServiceWorker = Pick & { - httpServer: HttpServer -} - /** * Options for {@link createServer} function. */ export interface CreateServerOptions { - /** - * Whether to start listening for fetch events immediately. - * When true, the fetch event handler is registered synchronously. - */ - listen?: boolean /** * Version string for the service worker. */ version?: string /** * Base path for the Vite Dev Server routes. - * All routes will be prefixed with this path. + * The Service Worker forwards the requests within it to the Web Worker. * * @example '/__preview__' - Server handles /__preview__/* requests * @default '/' */ basePath?: string /** - * User plugins to inject into the Vite dev server. - * These are merged with the inline config plugins before resolving. - */ - plugins?: Plugin[] - /** - * 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. + * How long a request waits for a Web Worker to connect, in milliseconds. A request that no Web + * Worker can answer by then gets a `503` response. + * + * A restarted Service Worker has no Web Worker channel until the runtime connects one again, so + * the requests made in the meantime wait for it. + * + * @default 10_000 */ - onWorkerChannelReady?: () => void + ownerWaitTimeout?: number +} + +/** + * The Service Worker side of the dev server, which forwards the requests to the Web Worker. + */ +export interface ServiceWorkerServer { /** - * @internal + * The server that receives the fetch events and the MessageChannel connections. */ - previousEnvironments?: Record + readonly httpServer: HttpServer /** - * @internal + * Stop answering requests. */ - previousShortcutsState?: ShortcutsState + close(): Promise +} + +/** + * A channel with a Web Worker, after the handshake. + */ +interface WorkerChannel { + port: MessagePort + rpc: BirpcReturn } +const DEFAULT_OWNER_WAIT_TIMEOUT = 10_000 + +/** + * Create the Service Worker side of the dev server. + * + * The fetch event handler is registered synchronously, as Service Workers require. The returned + * `listen()` starts answering requests and accepting the Web Worker channels. + * + * @param serviceWorkerScope - The Service Worker's global scope (`self`) + * @param options - Server options + * @returns A function that starts the server + */ export function createServer( serviceWorkerScope: ServiceWorkerGlobalScope, - inlineConfig: InlineConfig | ResolvedConfig = { - // TODO(kazupon): resolve in ../config.ts - base: '/', - publicDir: 'public', - experimental: { - importGlobRestoreExtension: false, - renderBuiltUrl: () => undefined, - hmrPartialAccept: false, - bundledDev: false, - } - }, options: CreateServerOptions = {}, -): () => Promise { - const { server: serverConfig } = inlineConfig as InlineConfig | ResolvedConfig - const middlewareMode = !!serverConfig?.middlewareMode +): () => Promise { const basePath = options.basePath || '/' + const ownerWaitTimeout = options.ownerWaitTimeout ?? DEFAULT_OWNER_WAIT_TIMEOUT const workerOrigin = new URL(serviceWorkerScope.location.href).origin + // The channel with the Web Worker that answers the requests. A later connection replaces it. + let workerChannel: WorkerChannel | null = null + // Requests and HMR ports waiting for a Web Worker to connect + const workerWaiters = new Set<(channel: WorkerChannel) => void>() + + /** + * Wait for a Web Worker to connect, for `ownerWaitTimeout` at most. + * + * @returns The channel, or `null` when no Web Worker connected in time + */ + function waitForWorker(): Promise { + if (workerChannel) { + return Promise.resolve(workerChannel) + } + return new Promise((resolve) => { + const onConnect = (channel: WorkerChannel) => { + clearTimeout(timer) + resolve(channel) + } + const timer = setTimeout(() => { + workerWaiters.delete(onConnect) + resolve(null) + }, ownerWaitTimeout) + workerWaiters.add(onConnect) + }) + } + + function connectWorker(channel: WorkerChannel): void { + workerChannel = channel + const waiters = [...workerWaiters] + workerWaiters.clear() + for (const waiter of waiters) { + waiter(channel) + } + } + let middlewares = new Hono() // NOTE(kazupon): Apply the base path before `handle()`. `basePath()` returns a clone with its own // error handler, so `onError()` must be registered on the same app that handles requests. if (basePath !== '/') { middlewares = middlewares.basePath(basePath) } - const httpServer = createSvcWorkerServer(serviceWorkerScope, { + + // request timer + if (import.meta.env.DEBUG) { + middlewares.use(timeMiddleware('/')) + } + + // Cross-origin isolation headers (CORP/COEP/COOP) for credentialless iframe + SW + middlewares.use(crossOriginMiddleware()) + + // The Web Worker answers the request with the Vite middlewares + middlewares.use(async (c) => { + const channel = await waitForWorker() + if (!channel) { + return c.text( + `[@vrowzer/vite-dev-server] The preview is not ready: no Web Worker connected within ${ownerWaitTimeout}ms.`, + 503, + ) + } + const response = await channel.rpc.handleRequest(await serializeRequest(c.req.raw)) + // `newResponse()` adds the headers that the earlier middlewares set, but drops the status text + const answered = c.newResponse(response.body, { + status: response.status as StatusCode, + headers: new Headers(response.headers), + }) + return new Response(answered.body, { + status: answered.status, + statusText: response.statusText, + headers: answered.headers, + }) + }) + + // The Web Worker answers the errors of the project with Vite's error page. An error here means + // that the request could not reach the Web Worker, or its response could not come back. + middlewares.onError((error, c) => { + console.error('[@vrowzer/vite-dev-server] The Web Worker did not answer a request:', error) + return c.text(`[@vrowzer/vite-dev-server] Internal Server Error: ${error.message}`, 500) + }) + + const httpServer: HttpServer = createSvcWorkerServer(serviceWorkerScope, { version: options.version ?? '0.0.0', claimOnActivate: true, debug: createDebugger('vrowzer:svc-worker-server')!, @@ -377,425 +219,90 @@ export function createServer( // This is critical for Service Workers which require fetch listeners during script evaluation httpServer.setFetchHandler(fetchHandler) - /** - * Start the Vite Dev Server - */ - async function listen(): Promise { - // Merge user plugins from CreateServerOptions into the inline config - if (options.plugins?.length && !isResolvedConfig(inlineConfig)) { - ;(inlineConfig as InlineConfig).plugins = [ - ...((inlineConfig as InlineConfig).plugins as any[] ?? []), - ...options.plugins, - ] - } - - const config = isResolvedConfig(inlineConfig) - ? inlineConfig - : await resolveConfig(inlineConfig, 'serve') - // NOTE(kazupon): the Service Worker does not create `DevEnvironment`, so it checks bundled dev mode here. - assertBundledDevUnsupported(config) - debug?.('config:', config) - - const initPublicFilesPromise = initPublicFiles(config) - - const { root, server: serverConfig } = config - - const resolvedOutDirs = getResolvedOutDirs( - config.root, - config.build.outDir, - config.build.rollupOptions.output, - ) - const emptyOutDir = resolveEmptyOutDir( - config.build.emptyOutDir, - config.root, - resolvedOutDirs, - ) - const resolvedWatchOptions = resolveChokidarOptions( - { - disableGlobbing: true, - ...serverConfig.watch, - }, - resolvedOutDirs, - emptyOutDir, - config.cacheDir, - ) - - const publicFiles = await initPublicFilesPromise - const { publicDir } = config - - const watchEnabled = serverConfig.watch !== null - // NOTE(kazupon): upstream wraps the chokidar watcher with makeWatcherCloseFinal(), because - // chokidar's add() reopens a closed watcher. The virtual watcher is frozen and its add() - // never opens file system handles, so it is not wrapped here. - const watcher = watchEnabled && options.watcherFactory - ? options.watcherFactory([ - ...(config.experimental.bundledDev ? [] : [root]), - ...config.configFileDependencies, - // ...getEnvFilesForMode(config.mode, config.envDir), - // Watch the public directory explicitly because it might be outside - // of the root directory. - ...(publicDir && publicFiles ? [publicDir] : []), - ], resolvedWatchOptions) - : createNoopWatcher(resolvedWatchOptions) - - // NOTE(kazupon): upstream updates the public file list in the watcher handlers of - // `_createServer()`, which the Service Worker does not register. The Service Worker receives the - // project files through V_FS_* messages after it has started, so keep the list in sync here. It - // is updated synchronously, before the Service Worker acknowledges the message (V_FS_ACK). - if (publicDir && publicFiles) { - syncPublicFiles(watcher, publicDir, publicFiles) - } - - const closeHttpServer = createServerCloseFn(httpServer) - - // birpc RPC client for delegating transform to Web Worker. - // Initialized dynamically when a V_WW_CONNECT_PORT connection arrives - // via the httpServer's connection event (after startServer enables listenConnections). - let workerRpc: ReturnType> | null = null - - // const devHtmlTransformFn = createDevHtmlTransformFn(config) - - // Promise used by `server.close()` to ensure `closeServer()` is only called once - let closeServerPromise: Promise | undefined - const closeServer = async () => { - // if (!middlewareMode) { - // teardownSIGTERMListener(closeServerAndExit) - // } - - await Promise.allSettled([ - // watcher.close(), - closeHttpServer(), - server._ssrCompatModuleRunner?.close(), - ]) - server.resolvedUrls = null - server._ssrCompatModuleRunner = undefined - } - - let server: ViteDevServerForServiceWorker = { - config, - middlewares, - httpServer, - watcher, - - resolvedUrls: null, // will be set on listen - - transformRequest(url, options) { - debug?.('transformRequest:', url) - if (!workerRpc) { - throw new Error('[@vrowzer/vite-dev-server/service-worker] transformRequest requires workerPort to be set in CreateServerOptions') - } - // delegate to Web Worker via birpc - return workerRpc.transformRequest(url, options) - }, - - warmupRequest(url) { - if (!workerRpc) { - return Promise.resolve() - } - // delegate to Web Worker via birpc (best-effort, never throws) - return workerRpc.warmupRequest(url).catch(() => { }) - }, - - transformIndexHtml(url, html, originalUrl) { - debug?.('transformIndexHtml:', url, originalUrl) - if (!workerRpc) { - throw new Error('[@vrowzer/vite-dev-server/service-worker] transformIndexHtml requires workerPort to be set in CreateServerOptions') - } - // delegate to Web Worker via birpc - return workerRpc.transformIndexHtml(url, html, originalUrl) - }, - - openBrowser() { - debug?.('not supported: server.openBrowser()') - }, + // Listen for connections from Main Thread. + // Handles two types: + // 1. V_WW_CONNECT_PORT: MessagePort for Web Worker birpc communication + // 2. vite:mc:init: iframe HMR port, forwarded to WW via the birpc port + function onConnection(event: ConnectionEvent): void { + // V_WW_CONNECT_PORT: birpc handshake with Web Worker + if (event.data.type === 'V_WW_CONNECT_PORT' && event.ports[0]) { + const port = event.ports[0] + const clientId = event.clientId + debug?.('Worker port received via connection event') + + // Phase 1: Handshake — wait for WW's channel-ready before creating birpc + port.onmessage = async (e: MessageEvent) => { + if (e.data.type === 'V_WW_SW_CHANNEL_READY' && e.data.source === 'ww') { + // Reply with SW's channel-ready + port.postMessage({ type: 'V_WW_SW_CHANNEL_READY', source: 'sw' }) + + const rpc = createBirpc( + {}, + { + // Transfer the request bodies of handleRequest instead of copying them + post: rpcData => port.postMessage(rpcData, getRpcTransferList(rpcData)), + on: fn => { port.onmessage = (ev: MessageEvent) => fn(ev.data) }, + serialize: serializeRpcMessage, + deserialize: deserializeRpcMessage, + timeout: 30_000, + }, + ) + + // Requests can be forwarded again. The waiting ones go on before the client hears that + // the connection is established. + connectWorker({ port, rpc }) + + // Notify the originating client that the connection is established + if (clientId) { + const client = await serviceWorkerScope.clients.get(clientId) + client?.postMessage({ type: 'V_WW_CONNECT_PORT_ACK' }) + } - async close() { - if (!closeServerPromise) { - closeServerPromise = closeServer() + debug?.('Worker RPC established via birpc') } - return closeServerPromise - }, - - _setInternalServer(_server: ViteDevServer) { - // Rebind internal the server variable so functions reference the user - // server instance after a restart - server = _server as unknown as ViteDevServerForServiceWorker - }, - - _restartPromise: null, - _forceOptimizeOnRestart: false, - _shortcutsState: options.previousShortcutsState, - } - - // maintain consistency with the server instance after restarting. - const reflexServer = new Proxy(server, { - get: (_, property: keyof ViteDevServerForServiceWorker) => { - return server[property] - }, - set: (_, property: keyof ViteDevServerForServiceWorker, value: never) => { - server[property] = value - return true - }, - }) - - // TODO: setup for HMR, watchers ... - // ... - - if (!middlewareMode) { - httpServer.once('listening', () => { - // NOTE(kazupon): commented out, because Service Worker server don't need port - serverConfig.port = 0 - // update actual port since this may be different from initial value - // serverConfig.port = (httpServer.address() as net.AddressInfo).port - }) - } - - // Pre applied internal middlewares ------------------------------------------ - - // request timer - if (import.meta.env.DEBUG) { - middlewares.use(timeMiddleware(root)) - } - - // 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()) - - // // cors - // const { cors } = serverConfig - // if (cors !== false) { - // middlewares.use(corsMiddleware(typeof cors === 'boolean' ? {} : cors)) - // } - - // // host check (to prevent DNS rebinding attacks) - // const { allowedHosts } = serverConfig - // // no need to check for HTTPS as HTTPS is not vulnerable to DNS rebinding attacks - // if (allowedHosts !== true && !serverConfig.https) { - // middlewares.use(hostValidationMiddleware(allowedHosts, false)) - // } - - // apply configureServer hooks ------------------------------------------------ - - const configureServerContext = new BasicMinimalPluginContext( - { ...basePluginContextMeta, watchMode: true }, - config.logger, - ) - const postHooks: ((() => void) | void)[] = [] - for (const hook of config.getSortedPluginHooks('configureServer')) { - postHooks.push(await hook.call(configureServerContext, reflexServer as ViteDevServer)) - } - - // Internal middlewares ------------------------------------------------------ - - // NOTE(kazupon): commented out, until implementing transform middleware - // if (!config.experimental.bundledDev) { - // middlewares.use(cachedTransformMiddleware(server)) - // } - // - // // proxy - // const { proxy } = serverConfig - // if (proxy) { - // const middlewareServer = - // (isObject(middlewareMode) ? middlewareMode.server : null) || httpServer - // middlewares.use(proxyMiddleware(middlewareServer, proxy, config)) - // } - - // base - if (config.base !== '/') { - middlewares.use(baseMiddleware(config.rawBase, !!middlewareMode)) - } - - // NOTE(kazupon): commented out, until implementing other middlewares - // // open in editor support - // middlewares.use('/__open-in-editor', launchEditorMiddleware()) - // - // // ping request handler - // // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` - // middlewares.use(function viteHMRPingMiddleware(req, res, next) { - // if (req.headers['accept'] === 'text/x-vite-ping') { - // res.writeHead(204).end() - // } else { - // next() - // } - // }) - - // serve static files under /public - // this applies before the transform middleware so that these files are served - // as-is without transforms. - if (publicDir) { - middlewares.use(servePublicMiddleware(server as ViteDevServer, publicFiles)) - } - - if (config.experimental.bundledDev) { - // NOTE(kazupon): unreachable, `assertBundledDevUnsupported` rejects bundled dev mode above. - // Vite registers `triggerLazyBundlingMiddleware` and `memoryFilesMiddleware` here. - } else { - // main transform middleware - middlewares.use('*', transformMiddleware(server as ViteDevServer)) - // console.log('[SW] transformMiddleware applied', transformMiddleware) - - // serve static files - middlewares.use(serveRawFsMiddleware(server as ViteDevServer)) - middlewares.use(serveStaticMiddleware(server as ViteDevServer)) - } - - // html fallback - if (config.appType === 'spa' || config.appType === 'mpa') { - middlewares.use( - htmlFallbackMiddleware( - root, - config.appType === 'spa', - ), - ) - } - - // apply configureServer post hooks ------------------------------------------ - - // This is applied before the html middleware so that user middleware can - // serve custom content instead of index.html. - postHooks.forEach((fn) => fn && fn()) - - if (config.appType === 'spa' || config.appType === 'mpa') { - // transform index.html - middlewares.use(indexHtmlMiddleware(root, server as ViteDevServer, { isDev: true })) - - // handle 404s - middlewares.use(notFoundMiddleware()) - } - - // error handler - middlewares.onError(errorMiddleware(server as ViteDevServer, false)) - - // httpServer.listen can be called multiple times - // when port when using next port number - // this code is to avoid calling buildStart multiple times - let initingServer: Promise | undefined - let serverInited = false - const initServer = async (onListen: boolean) => { - if (serverInited) { - return - } - if (initingServer) { - return initingServer } - - initingServer = (async function () { - await startServer( - server, - { - enableListenConnections: true, - port: onListen ? serverConfig.port : -1 - } - ) - initingServer = undefined - serverInited = true - })() - return initingServer - } - - try { - await initServer(!middlewareMode) - } catch (err) { - httpServer.emit('error', err as Error) + return } - // Listen for connections from Main Thread. - // Handles two types: - // 1. V_WW_CONNECT_PORT: MessagePort for Web Worker birpc communication - // 2. vite:mc:init: iframe HMR port, forwarded to WW via the birpc port - let swWwPort: MessagePort | null = null // swWw: Service Worker Web Worker - - httpServer.on('connection', (event) => { - // V_WW_CONNECT_PORT: birpc handshake with Web Worker - if (event.data.type === 'V_WW_CONNECT_PORT' && event.ports[0]) { - const port = event.ports[0] - swWwPort = port - const clientId = event.clientId - debug?.('Worker port received via connection event') - - // Phase 1: Handshake — wait for WW's channel-ready before creating birpc - port.onmessage = async (e: MessageEvent) => { - if (e.data.type === 'V_WW_SW_CHANNEL_READY' && e.data.source === 'ww') { - // Reply with SW's channel-ready - port.postMessage({ type: 'V_WW_SW_CHANNEL_READY', source: 'sw' }) - - // Install birpc before the first await. The Web Worker may send its - // initial safe-path snapshot as soon as it receives channel-ready. - workerRpc = createBirpc( - createServiceWorkerFunctions(config.safeModulePaths), - { - post: rpcData => port.postMessage(rpcData), - on: fn => { port.onmessage = (ev: MessageEvent) => fn(ev.data) }, - serialize: serializeRpcMessage, - deserialize: deserializeRpcMessage, - timeout: 30_000, - } - ) - - // 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) - client?.postMessage({ type: 'V_WW_CONNECT_PORT_ACK' }) - } - - debug?.('Worker RPC established via birpc') - } + // vite:mc:init: iframe HMR port — forward to WW via the birpc MessagePort + if (event.data.type === 'vite:mc:init' && event.ports[0]) { + const hmrPort = event.ports[0] + const hmrClientId = event.data.clientId + debug?.('HMR port received from iframe, forwarding to WW', hmrClientId) + void waitForWorker().then((channel) => { + if (!channel) { + hmrPort.close() + return } - return - } + channel.port.postMessage({ type: 'V_WW_HMR_PORT', clientId: hmrClientId }, [hmrPort]) + }) + } + } - // vite:mc:init: iframe HMR port — forward to WW via the birpc MessagePort - if (event.data.type === 'vite:mc:init' && event.ports[0] && swWwPort) { - debug?.('HMR port received from iframe, forwarding to WW', event.data.clientId) - swWwPort.postMessage({ type: 'V_WW_HMR_PORT', clientId: event.data.clientId }, [event.ports[0]]) - return + const closeHttpServer = createServerCloseFn(httpServer) + let closeServerPromise: Promise | undefined + const server: ServiceWorkerServer = { + httpServer, + close() { + if (!closeServerPromise) { + closeServerPromise = closeHttpServer() } - }) + return closeServerPromise + }, + } + /** + * Start answering requests and accepting the Web Worker channels. + */ + async function listen(): Promise { + httpServer.on('connection', onConnection) + httpServer.listen({ enableListenConnections: true }) return server } return listen } -async function startServer( - server: ViteDevServerForServiceWorker, - options?: ListenOptions & { port?: number }, -): Promise { - const httpServer = server.httpServer - server._configServerPort = options?.port - - const startHttp = new Promise((resolve, reject) => { - const onError = (e: Error & { code?: string }) => { - httpServer.off('error', onError) - reject(e) - } - httpServer.on('error', onError) - - httpServer.listen(options) - httpServer.off('error', onError) - resolve(0) - }) - - const serverPort = await startHttp - server._currentServerPort = serverPort - server._currentServerPort = serverPort -} - export function createServerCloseFn( server: HttpServer | null, ): () => Promise { @@ -824,117 +331,6 @@ export function createServerCloseFn( }) } - -// === Middleware utils === -export { getRequestPath } from './server/middlewares/utils' - -// === Config === -export { defineConfig, resolveConfig, sortUserPlugins } from './config' -export type { HtmlAssetSource } from './assetSource' -export type { - AppType, - ConfigEnv, - DevEnvironmentOptions, - EnvironmentOptions, - ExperimentalOptions, - HTMLOptions, - InlineConfig, - LegacyOptions, - PluginHookUtils, ResolveFn, ResolvedConfig, - ResolvedDevEnvironmentOptions, - ResolvedEnvironmentOptions, - ResolvedWorkerOptions, UserConfig, - UserConfigExport, - UserConfigFn, - UserConfigFnObject, - UserConfigFnPromise -} from './config' - -// === HMR types & relay === -export { - createServerHotChannel, - getShortName, - normalizeHotChannel -} from './server/hmr' -export type { - HmrContext, - HmrOptions, - HotChannel, - HotChannelClient, - HotChannelListener, - HotUpdateOptions, - NormalizedHotChannel, - NormalizedHotChannelClient, - NormalizedServerHotChannel, - ServerHotChannel, - ServerHotChannelApi, - WsOptions -} from './server/hmr' - -// === MessageChannel HMR server === -export { - createMessageChannelServer, - isMessageChannelServer -} from './server/ws' -export type { - MessageChannelClient, - MessageChannelCustomListener, - MessageChannelServer -} from './server/ws' - -// === HMR payload types === -export type { - CustomEventMap, - InferCustomEventPayload, - InvalidatePayload -} from '#types/customEvent' -export type { - ConnectedPayload, - CustomPayload, - ErrorPayload, - FullReloadPayload, - HMRPayload, - HotPayload, - PrunePayload, - Update, - UpdatePayload -} from '#types/hmrPayload' - -// === Types only (interop with Worker) === -export type { Environment } from './environment' -export type { - DepOptimizationConfig, - DepOptimizationMetadata, - DepOptimizationOptions, - ExportsData, - OptimizedDepInfo -} from './optimizer' -export type { DevEnvironment, DevEnvironmentContext } from './server/environment' -export type { - EnvironmentModuleGraph, - EnvironmentModuleNode, - ResolvedUrl -} from './server/moduleGraph' -export type { TransformOptions, TransformResult } from './server/transformRequest' - -// === Backward compatibility === -export { ModuleGraph } from './server/mixedModuleGraph' -export type { ModuleNode } from './server/mixedModuleGraph' - -// === Logger === -export { createLogger } from './logger' -export type { LogLevel, LogType, Logger } from './logger' - -// === Dep types === -export type { - Alias, - AliasOptions, - MapToFunction, - ResolverFunction, - ResolverObject -} from '#dep-types/alias' -export type { Hono } from 'hono' - // === Protocol message types & constants === export { V_WW_CONNECT_PORT, 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 d9e680e4..e926ca6b 100644 --- a/packages/vite-dev-server/src/node/web-worker.test.ts +++ b/packages/vite-dev-server/src/node/web-worker.test.ts @@ -51,7 +51,6 @@ function createSetupResult() { return { config: { getSortedPluginHooks: vi.fn<() => []>(() => []), - safeModulePaths: new Set(), }, environments: { client: { @@ -364,10 +363,7 @@ describe('Web Worker request pipeline', () => { await listening const response = { status: 200, statusText: 'OK', headers: [], body: null } createdPipeline().handleRequest.mockResolvedValueOnce(response) - transformerMocks.connectServiceWorkerPort.mockResolvedValueOnce({ - $close: vi.fn<() => void>(), - registerSafeModulePaths: vi.fn<(paths: string[]) => Promise>(async () => undefined), - }) + transformerMocks.connectServiceWorkerPort.mockResolvedValueOnce({ $close: vi.fn<() => void>() }) await Promise.resolve(workerScope.onmessage?.({ data: { type: V_SW_CONNECT_PORT }, @@ -425,7 +421,6 @@ describe('Web Worker Service Worker channel', () => { function createRpc() { return { $close: vi.fn<(error?: Error) => void>(), - registerSafeModulePaths: vi.fn<(paths: string[]) => Promise>(async () => undefined), } } @@ -445,10 +440,8 @@ describe('Web Worker Service Worker channel', () => { .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) + async function readyWorkerScope(): Promise { + transformerMocks.setupWorker.mockResolvedValue(createSetupResult()) const workerScope = createWorkerScope() const server = createServer(workerScope) const listening = server.listen(0) @@ -502,36 +495,7 @@ describe('Web Worker Service Worker channel', () => { 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 f0733b1e..07b87bc1 100644 --- a/packages/vite-dev-server/src/node/web-worker.ts +++ b/packages/vite-dev-server/src/node/web-worker.ts @@ -19,7 +19,6 @@ */ import { createDebugger } from './utils' -import { connectSafeModulePathSync } from '../shared/rpc' import { mergeWorkerRuntimeConfig, snapshotWorkerRuntimeConfig } from './worker-runtime-config' // NOTE(kazupon): Only type-only imports from heavy modules. @@ -294,30 +293,13 @@ export function createServer( ws!.handlePort(hmrPort, clientId) }) if (serviceWorkerChannel !== channel) { - // A newer port replaced this one during the handshake + // A newer port replaced this one during the handshake. The acknowledgement does not name + // the port, so do not send one for the replaced port. serviceWorkerRpc.$close() break } channel.rpc = serviceWorkerRpc - 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') break diff --git a/packages/vite-dev-server/src/shared/rpc.test.ts b/packages/vite-dev-server/src/shared/rpc.test.ts index 9f0a292d..2656470c 100644 --- a/packages/vite-dev-server/src/shared/rpc.test.ts +++ b/packages/vite-dev-server/src/shared/rpc.test.ts @@ -1,12 +1,9 @@ import { createBirpc } from 'birpc' -import { describe, expect, it, vi } from 'vite-plus/test' +import { describe, expect, it } from 'vite-plus/test' import { - connectSafeModulePathSync, - createServiceWorkerFunctions, deserializeRpcMessage, serializeRpcMessage, } from './rpc' -import type { ServiceWorkerFunctions } from './rpc' interface ErrorWithMetadata extends Error { code?: string @@ -116,88 +113,3 @@ describe('RPC message serialization', () => { expect(serializeRpcMessage(message)).toBe(message) }) }) - -describe('safe module path RPC', () => { - it('registers paths across a birpc MessageChannel', async () => { - const channel = new MessageChannel() - const safeModulePaths = new Set(['/existing.ts']) - const client = createBirpc< - ServiceWorkerFunctions, - Record - >({}, { - post: data => channel.port1.postMessage(data), - on: fn => { - channel.port1.onmessage = event => fn(event.data) - }, - }) - const server = createBirpc< - Record, - ServiceWorkerFunctions - >(createServiceWorkerFunctions(safeModulePaths), { - post: data => channel.port2.postMessage(data), - on: fn => { - channel.port2.onmessage = event => fn(event.data) - }, - }) - - try { - await client.registerSafeModulePaths([ - '/outside/entry.ts', - '/existing.ts', - ]) - await client.registerSafeModulePaths(['/outside/ssr.ts']) - - expect(safeModulePaths).toEqual( - new Set([ - '/existing.ts', - '/outside/entry.ts', - '/outside/ssr.ts', - ]), - ) - } finally { - client.$close() - server.$close() - channel.port1.close() - channel.port2.close() - } - }) - - it('snapshots paths and replaces environment callbacks on reconnect', async () => { - const environments = [ - { _syncSafeModulePaths: undefined }, - { _syncSafeModulePaths: undefined }, - ] - const safeModulePaths = new Set(['/existing.ts']) - const firstRegister = vi.fn<(paths: string[]) => Promise>( - async () => undefined, - ) - - await connectSafeModulePathSync( - environments, - safeModulePaths, - firstRegister, - ) - expect(firstRegister).toHaveBeenCalledWith(['/existing.ts']) - - safeModulePaths.add('/later.ts') - await environments[0]._syncSafeModulePaths?.(['/later.ts']) - expect(firstRegister).toHaveBeenLastCalledWith(['/later.ts']) - - const secondRegister = vi.fn<(paths: string[]) => Promise>( - async () => undefined, - ) - await connectSafeModulePathSync( - environments, - safeModulePaths, - secondRegister, - ) - expect(secondRegister).toHaveBeenCalledWith([ - '/existing.ts', - '/later.ts', - ]) - - await environments[1]._syncSafeModulePaths?.(['/reconnected.ts']) - expect(secondRegister).toHaveBeenLastCalledWith(['/reconnected.ts']) - expect(firstRegister).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/vite-dev-server/src/shared/rpc.ts b/packages/vite-dev-server/src/shared/rpc.ts index 359953b9..661c2b28 100644 --- a/packages/vite-dev-server/src/shared/rpc.ts +++ b/packages/vite-dev-server/src/shared/rpc.ts @@ -167,54 +167,7 @@ export interface SerializedResponse { /** * Functions provided by the Service Worker (callable from Web Worker) * - * These are the functions that run in the Service Worker - * where the Hono server and fetch handler operate. + * The Service Worker only forwards requests and HMR ports to the Web Worker, + * so it provides no functions. */ -export interface ServiceWorkerFunctions { - /** - * Add plugin-resolved input paths to the Service Worker's filesystem - * allowlist. Deny patterns still take precedence. - */ - registerSafeModulePaths: (paths: string[]) => Promise -} - -interface SafeModulePathSyncEnvironment { - _syncSafeModulePaths: - | ((paths: string[]) => Promise) - | undefined -} - -/** - * Connect environment input registration to the current Service Worker RPC. - * Calling this again replaces stale callbacks and sends a fresh snapshot. - */ -export async function connectSafeModulePathSync( - environments: Iterable, - safeModulePaths: Iterable, - registerSafeModulePaths: ServiceWorkerFunctions['registerSafeModulePaths'], -): Promise { - const syncSafeModulePaths = async (paths: string[]) => { - if (paths.length > 0) { - await registerSafeModulePaths(paths) - } - } - for (const environment of environments) { - environment._syncSafeModulePaths = syncSafeModulePaths - } - await syncSafeModulePaths([...safeModulePaths]) -} - -/** - * Create the RPC handlers backed by a Service Worker config's safe-path set. - */ -export function createServiceWorkerFunctions( - safeModulePaths: Set, -): ServiceWorkerFunctions { - return { - async registerSafeModulePaths(paths) { - for (const path of paths) { - safeModulePaths.add(path) - } - }, - } -} +export type ServiceWorkerFunctions = Record diff --git a/packages/vite-dev-server/test-public/test-sw.ts b/packages/vite-dev-server/test-public/test-sw.ts deleted file mode 100644 index fb4bf55e..00000000 --- a/packages/vite-dev-server/test-public/test-sw.ts +++ /dev/null @@ -1,96 +0,0 @@ -/// -import { createServer } from '@vrowzer/vite-dev-server/service-worker' -import type { ViteDevServerForServiceWorker as ViteDevServer } from '@vrowzer/vite-dev-server/service-worker' - -declare const self: ServiceWorkerGlobalScope - -// Service Worker version - must match what the test controller expects -const SW_VERSION = 'test-v1' - -// Create listen function synchronously - fetch event is registered immediately -// This MUST be at script top-level to register fetch handler during script evaluation -// The version option is passed to createSvcWorkerServer which handles controller protocol -const listen = createServer( - self, - { - root: '/', - server: { - middlewareMode: false, - }, - } as any, - { version: SW_VERSION } -) - -// Server instance will be set after listen() -let server: ViteDevServer | null = null - -// Start the server at top level (same pattern as vrowzer's service-worker-core.ts) -// This ensures the server is ready regardless of when activate fires -const listenPromise = listen() -let listenReady = false -listenPromise.then(s => { - server = s - listenReady = true -}).catch(err => { - console.error('[test-sw] listen() failed:', err) -}) - -// Service Worker install -self.addEventListener('install', (_event) => { - // Skip waiting to activate immediately -}) - -// Service Worker activate -self.addEventListener('activate', (event) => { - event.waitUntil( - listenPromise.then(async () => { - // Signal ready to all clients - const clients = await self.clients.matchAll({ includeUncontrolled: true }) - for (const client of clients) { - client.postMessage({ type: 'V_SW_LISTEN_READY' }) - } - }) - ) -}) - -// Message handler for test control -// Note: Controller protocol messages (V_SW_VERSION, V_SW_SESSION_INIT, etc.) -// are handled automatically by createSvcWorkerServer (via createSvcWorker) -self.addEventListener('message', async (event) => { - const data = event.data - if (!data || typeof data.type !== 'string') {return} - - // Respond to listen-ready ping from main thread - if (data.type === 'V_SW_LISTEN_READY_PING' && listenReady) { - const clientId = (event.source as Client | null)?.id - if (clientId) { - const client = await self.clients.get(clientId) - client?.postMessage({ type: 'V_SW_LISTEN_READY' }) - } - return - } - - const port = event.ports?.[0] - - switch (data.type) { - // Test control: Get server status - case 'GET_SERVER_STATUS': - port?.postMessage({ - type: 'SERVER_STATUS', - hasServer: server !== null, - hasMiddlewares: server?.middlewares !== undefined, - hasHttpServer: server?.httpServer !== undefined, - }) - break - - // Test control: Close server - case 'CLOSE_SERVER': - try { - await server?.close() - port?.postMessage({ type: 'SERVER_CLOSED', success: true }) - } catch (error) { - port?.postMessage({ type: 'SERVER_CLOSED', success: false, error: String(error) }) - } - break - } -}) diff --git a/packages/vrowzer/integration/restart.integration-test.ts b/packages/vrowzer/integration/restart.integration-test.ts index 62dbff01..8b7aac1d 100644 --- a/packages/vrowzer/integration/restart.integration-test.ts +++ b/packages/vrowzer/integration/restart.integration-test.ts @@ -404,12 +404,12 @@ describe('Vrowzer Service Worker restart', () => { expect(await update).not.toBe('resolved') expect(await recordedEvents(page, 'serviceWorkerRecovered')).toEqual([]) - // A request waits for the project for 10 seconds at most + // A request waits for the Web Worker 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') + expect(response.body).toContain('no Web Worker connected within 10000ms') // The preview reports the failure when it loads again await page.evaluate(() => (window as any).__vrowzer__.reloadPreview('preview')) diff --git a/packages/vrowzer/src/service-worker-core.test.ts b/packages/vrowzer/src/service-worker-core.test.ts index cfa1c9d7..a06c722b 100644 --- a/packages/vrowzer/src/service-worker-core.test.ts +++ b/packages/vrowzer/src/service-worker-core.test.ts @@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/tes type Listener = (event: any) => void interface ServerOptions { - beforeRequest: (request: Request) => Promise - onWorkerChannelReady: () => void + version?: string + basePath?: string + ownerWaitTimeout?: number } const mocks = vi.hoisted(() => ({ @@ -29,7 +30,7 @@ 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: (_scope: unknown, _config: unknown, options: ServerOptions) => { + createServer: (_scope: unknown, options: ServerOptions) => { mocks.serverOptions = options return mocks.listen } @@ -77,26 +78,6 @@ function serverOptions(): ServerOptions { 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 }) @@ -239,84 +220,18 @@ describe('initServiceWorker instance', () => { }) }) -describe('initServiceWorker project readiness', () => { - beforeEach(async () => { +describe('initServiceWorker server', () => { + test('forwards the requests within the preview base path to the Web Workers', 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) + await initServiceWorker() - expect(client.postMessage).toHaveBeenCalledExactlyOnceWith({ - type: 'V_FS_ACK', - id: 'init-2', - error: { name: 'Error', message: 'broken file' } + // A request waits for a Web Worker as long as the runtime waits for its recovery + expect(serverOptions()).toEqual({ + version: expect.any(String), + basePath: '/__preview__/', + ownerWaitTimeout: 10_000 }) - 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 da629d48..a0102892 100644 --- a/packages/vrowzer/src/service-worker-core.ts +++ b/packages/vrowzer/src/service-worker-core.ts @@ -2,8 +2,8 @@ * Service Worker core initialization logic for vrowzer preview system. * * This file provides a factory function `initServiceWorker()` that encapsulates - * the Service Worker setup logic. It accepts an optional `plugins` array to inject - * user Vite plugins into the dev server. + * the Service Worker setup logic. The Service Worker forwards the preview requests to the Web + * Worker, which answers them with the Vite dev server. Vite plugins run in the Web Worker. * * This file is NOT bundled by vrowzer's build. It is exported as TypeScript source * and bundled by the user's Vite + Vrowzer (which provides resolve.alias for node:* polyfills). @@ -33,17 +33,14 @@ import { resolvePreviewBasePath } from './preview-base.ts' import { resolveServiceWorkerVersionForWorker } from './service-worker-version.ts' import type { FSAckMessage, FileSystemSyncMessage } from '@vrowzer/fs/watcher' -import type { CreateServerOptions } from '@vrowzer/vite-dev-server/service-worker' -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. + * How long a request within the base path waits for a 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 +const OWNER_WAIT_TIMEOUT = 10_000 function toAckError(error: unknown): NonNullable { return error instanceof Error @@ -51,7 +48,7 @@ function toAckError(error: unknown): NonNullable { : { name: 'Error', message: String(error) } } -export async function initServiceWorker(options?: { plugins?: Plugin[] }) { +export async function initServiceWorker() { // 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. @@ -69,87 +66,15 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { 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 = { + // The Web Worker answers the requests. A request made before a Web Worker connects, e.g. right + // after the browser restarted this Service Worker, waits for the runtime to connect it again. + const listen = createServer(self, { version: serviceWorkerVersion, basePath: previewBase, - ...(options?.plugins ? { plugins: options.plugins } : {}), - watcherFactory: () => subscriber.watcher as any, - beforeRequest: waitForProject, - onWorkerChannelReady: () => { - channelReady = true - updateProjectReady() - } - } as unknown as CreateServerOptions - - const listen = createServer( - self, - { - root: '/', - server: { middlewareMode: false }, - base: previewBase, - publicDir: 'public', - optimizeDeps: { disabled: true }, - experimental: { - importGlobRestoreExtension: false, - renderBuiltUrl: () => undefined, - hmrPartialAccept: false, - bundledDev: false - } - }, - serverOptions - ) + ownerWaitTimeout: OWNER_WAIT_TIMEOUT + }) - // Start the Vite dev server at the top level (not inside activate event). + // Start the server at the top level (not inside activate event). // SW processes are ephemeral — the browser can terminate and restart them at any time. // The activate event only fires once during the SW lifecycle, so if the process restarts, // listen() would never be called again. By calling it at the top level, it runs @@ -193,7 +118,7 @@ export async function initServiceWorker(options?: { plugins?: Plugin[] }) { const syncMessage = message as FileSystemSyncMessage const id = syncMessage.type === 'V_FS_MKDIR' ? undefined : syncMessage.id if (id === undefined) { - applyFileSyncMessage(syncMessage) + subscriber.handleMessage(syncMessage) return } @@ -201,7 +126,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 { - applyFileSyncMessage(syncMessage) + subscriber.handleMessage(syncMessage) } catch (error) { ack = { type: V_FS_ACK, id, error: toAckError(error) } } From fd4b0677d1c44a20879843472541226d09b374c3 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:32:03 +0900 Subject: [PATCH 03/10] refactor(vrowzer): keep the project files only in the Web Worker The Service Worker forwards the preview requests to the Web Worker, so it needs no copy of the project. The project files now live only in the Web Worker: - ready() gives the files to the Web Worker only. The Service Worker gets no V_FS_INIT, no later V_FS_* messages, and no virtual file system. - A file operation resolves once the Web Worker acknowledges it, and times out waiting for the Web Worker only. - The recovery after a Service Worker restart (#46) only connects the Web Worker channel again. The runtime no longer keeps a copy of the files for it, and no longer holds the file operations called in the meantime, since the Web Worker can apply them right away. - The error page test checks that the Web Worker names the failed plugin. serviceWorkerRecovered and serviceWorkerRecoveryError are emitted as before, when the channel is connected again or cannot be within fileSyncTimeout. --- .../integration/restart.integration-test.ts | 10 +- .../integration/vrowzer.integration-test.ts | 15 +- packages/vrowzer/src/index-dispose.test.ts | 7 +- packages/vrowzer/src/index-file-sync.test.ts | 189 +++------- packages/vrowzer/src/index-recovery.test.ts | 314 ++++------------- packages/vrowzer/src/index.ts | 328 ++++-------------- .../vrowzer/src/service-worker-core.test.ts | 78 +---- packages/vrowzer/src/service-worker-core.ts | 50 +-- 8 files changed, 208 insertions(+), 783 deletions(-) diff --git a/packages/vrowzer/integration/restart.integration-test.ts b/packages/vrowzer/integration/restart.integration-test.ts index 8b7aac1d..99cffa57 100644 --- a/packages/vrowzer/integration/restart.integration-test.ts +++ b/packages/vrowzer/integration/restart.integration-test.ts @@ -255,8 +255,8 @@ describe('Vrowzer Service Worker restart', () => { 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 + // The restarted Service Worker holds requests until the Web Worker channel is connected 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') @@ -386,7 +386,8 @@ describe('Vrowzer Service Worker restart', () => { await page.evaluate(() => (window as any).__terminateWebWorkers__()) await stopServiceWorker(page) - // Start the Service Worker, and change a file while it is being restored + // Start the Service Worker, and change a file while it is being restored. The change goes to + // the Web Worker right away, which no longer answers. void fetchFromHost(page, '/main.js').catch(() => {}) const update = page.evaluate(() => (window as any).__vrowzer__.updateFile('/main.js', 'export {}').then( @@ -401,7 +402,7 @@ describe('Vrowzer Service Worker restart', () => { .toBe(1) const [error] = await recordedEvents(page, 'serviceWorkerRecoveryError') expect(error!.message).toContain('timed out after 2000ms') - expect(await update).not.toBe('resolved') + expect(await update).toContain('timed out after 2000ms waiting for the Web Worker') expect(await recordedEvents(page, 'serviceWorkerRecovered')).toEqual([]) // A request waits for the Web Worker for 10 seconds at most @@ -463,7 +464,6 @@ describe('Vrowzer Service Worker restart', () => { 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 { diff --git a/packages/vrowzer/integration/vrowzer.integration-test.ts b/packages/vrowzer/integration/vrowzer.integration-test.ts index 39fef100..833b55c8 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -727,7 +727,7 @@ if (import.meta.hot) { } }, 30000) - test('copies binary content for both Workers and keeps the caller buffer usable', async () => { + test('copies binary content for the Web Worker and keeps the caller buffer usable', async () => { const path = '/file-sync/binary/pixel.png' const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff] @@ -741,9 +741,9 @@ if (import.meta.hot) { ) expect(caller).toEqual({ byteLength: bytes.length, bytes }) - // The Service Worker serves the file from its virtual filesystem + // The Web Worker serves the file from its virtual filesystem expect(await fetchBytesFromServiceWorker(path)).toEqual({ status: 200, bytes }) - // The Web Worker inlines the file from its own virtual filesystem + // And inlines it as an asset const inlined = await fetchFromServiceWorker(`${path}?import&inline`) const base64 = inlined.body.match(/data:image\/png;base64,([A-Za-z0-9+/=]+)/)?.[1] expect(base64).toBeDefined() @@ -787,7 +787,7 @@ if (import.meta.hot) { const path = '/public/public-files/added.txt' const url = '/public-files/added.txt' - // No polling: the promise resolves after the Service Worker has updated its public file list + // No polling: the promise resolves after the Web Worker has updated its public file list await runFileOperation('addFile', [path, 'public v1']) expect(await fetchFromServiceWorker(url)).toEqual({ status: 200, @@ -837,7 +837,7 @@ if (import.meta.hot) { // The bytes of the playground's initialBinaryFiles const bytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff] - test('serves them from the Service Worker with the same bytes', async () => { + test('serves them through the Service Worker with the same bytes', async () => { // A public file, served by servePublicMiddleware expect(await fetchBytesFromServiceWorker('/initial-public.png')).toEqual({ status: 200, @@ -1540,13 +1540,16 @@ if (import.meta.hot) { await addPreviewFiles({ [filePath]: 'export const = 1\n' }) try { - // index.html (200, via the SPA fallback) until the file reaches the Workers, + // index.html (200, via the SPA fallback) until the file reaches the Web Worker, // then 500 from the failed transform const response = await waitForPreviewResponse(filePath, 500) expect(response.body).toContain('') expect(response.body).toContain('Error') expect(response.body).toContain(filePath) + // The Web Worker builds the page from the error itself, so it names the failed plugin + const error = JSON.parse(response.body.match(/const error = (\{.*\})\n/)?.[1] ?? '{}') + expect(error).toMatchObject({ plugin: 'vite:oxc' }) } finally { await page.evaluate(path => { return (window as any).__vrowzer__.deleteFile(path) diff --git a/packages/vrowzer/src/index-dispose.test.ts b/packages/vrowzer/src/index-dispose.test.ts index 747ab276..ef3f6248 100644 --- a/packages/vrowzer/src/index-dispose.test.ts +++ b/packages/vrowzer/src/index-dispose.test.ts @@ -333,7 +333,8 @@ describe('Vrowzer dispose', () => { await completeWebWorkerSetup(worker) await waitForChannelHandshake(worker) expect(controller.subscriptionCount).toBe(6) - expect(publisher.targets.size).toBe(2) + // Only the Web Worker gets the file changes + expect(publisher.targets.size).toBe(1) // The handshake and the Service Worker instances that start expect(controller.container.listeners.size).toBe(2) @@ -372,8 +373,8 @@ describe('Vrowzer dispose', () => { test('rejects the file operations that wait for the Workers', async () => { const vrowzer = Vrowzer() const worker = await readyFully(vrowzer) - // The file sync acknowledgements and the Service Worker instances that start - expect(controller.container.listeners.size).toBe(2) + // The Service Worker instances that start. The Web Worker acknowledges the file changes. + expect(controller.container.listeners.size).toBe(1) 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 e36719a2..0beea079 100644 --- a/packages/vrowzer/src/index-file-sync.test.ts +++ b/packages/vrowzer/src/index-file-sync.test.ts @@ -15,7 +15,6 @@ interface TestMessage { path?: string content?: unknown files?: Record - binaryFiles?: Record } interface AckError { @@ -95,10 +94,8 @@ function webWorkerFileMessages(worker: TestWorker): TestMessage[] { return worker.messages.filter(isFileChange) } -function serviceWorkerFileMessages(): TestMessage[] { - return controllerMocks.postMessage.mock.calls - .map(([message]) => message as TestMessage) - .filter(isFileChange) +function serviceWorkerMessages(): TestMessage[] { + return controllerMocks.postMessage.mock.calls.map(([message]) => message as TestMessage) } function lastId(worker: TestWorker): string { @@ -152,11 +149,6 @@ beforeEach(() => { new MessageEvent('message', { data: { type: V_WW_CONNECT_PORT_ACK } }) ) }) - return - } - const id = (message as TestMessage).id - if (autoAck && id !== undefined) { - queueMicrotask(() => ackFromServiceWorker(id)) } }) }) @@ -168,7 +160,7 @@ afterEach(() => { }) describe('Vrowzer file synchronization', () => { - test('initializes each Worker once without broadcasting initial files to the ready Web Worker', async () => { + test('gives the files of ready() to the Web Worker only', async () => { const files = { '/index.html': '', '/main.js': 'export const value = 1' } const initialFiles = { ...files, @@ -183,26 +175,24 @@ describe('Vrowzer file synchronization', () => { expect.objectContaining({ type: V_WW_SETUP, files: initialFiles }), { type: V_SW_CONNECT_PORT } ]) - expect(controllerMocks.postMessage.mock.calls.map(([message]) => message)).toEqual([ - { type: 'V_FS_INIT', files: initialFiles }, + // The Service Worker forwards the requests to the Web Worker, so it gets no files + expect(serviceWorkerMessages()).toEqual([ { type: V_WW_CONNECT_PORT, runtimeId: expect.any(String) } ]) }) - test('gives both Workers the same default /index.html when ready() gets none', async () => { + test('gives the Web Worker a default /index.html when ready() gets none', async () => { const vrowzer = Vrowzer() await expect(vrowzer.ready({ files: { '/main.js': 'export {}' } })).resolves.toBe(true) const webWorkerIndex = workers[0]!.messages[0]!.files!['/index.html'] - const init = controllerMocks.postMessage.mock.calls[0]![0] as TestMessage expect(webWorkerIndex).toBeTypeOf('string') - expect(init.files!['/index.html']).toBe(webWorkerIndex) expect(webWorkerIndex).toContain('
') expect(webWorkerIndex).toContain('') }) - test('sends binary files to both Workers as copies, without transferring them', async () => { + test('sends binary files to the Web Worker as copies, without transferring them', async () => { const bytes = [0x89, 0x50, 0x4e, 0x47, 0x00, 0xff] const buffer = new Uint8Array(bytes).buffer const vrowzer = Vrowzer() @@ -219,23 +209,6 @@ describe('Vrowzer file synchronization', () => { expect([...new Uint8Array(setupFiles['/public/logo.png'] as ArrayBuffer)]).toEqual(bytes) expect(workers[0]!.transfers[0]).toBeUndefined() - // The Service Worker gets the binary files in binaryFiles - const [init, transfer] = controllerMocks.postMessage.mock.calls[0]! - expect(init).toEqual({ - type: 'V_FS_INIT', - files: { - '/main.js': 'export {}', - '/index.html': expect.stringContaining('
'), - '/dist/client/client.mjs': 'client code', - '/dist/client/env.mjs': 'env code' - }, - binaryFiles: { '/public/logo.png': expect.any(ArrayBuffer) } - }) - const serviceWorkerCopy = (init as TestMessage).binaryFiles!['/public/logo.png']! - expect(serviceWorkerCopy).not.toBe(buffer) - expect([...new Uint8Array(serviceWorkerCopy)]).toEqual(bytes) - expect(transfer).toBeUndefined() - // The caller's buffer is not detached expect(buffer.byteLength).toBe(bytes.length) expect([...new Uint8Array(buffer)]).toEqual(bytes) @@ -254,12 +227,9 @@ describe('Vrowzer file synchronization', () => { const setupFiles = workers[0]!.messages[0]!.files! expect(Object.keys(setupFiles)).not.toContain('/late.js') expect([...new Uint8Array(setupFiles['/data.bin'] as ArrayBuffer)]).toEqual([1, 2, 3]) - const init = controllerMocks.postMessage.mock.calls[0]![0] as TestMessage - expect(Object.keys(init.files!)).not.toContain('/late.js') - expect([...new Uint8Array(init.binaryFiles!['/data.bin']!)]).toEqual([1, 2, 3]) }) - test('continues sending file additions, updates, and deletions to both Workers', async () => { + test('continues sending file additions, updates, and deletions to the Web Worker only', async () => { autoAck = true const { vrowzer, worker } = await readyInstance() worker.messages.length = 0 @@ -269,7 +239,7 @@ describe('Vrowzer file synchronization', () => { await vrowzer.updateFile('/main.js', 'updated') await vrowzer.deleteFile('/main.js') - const expected = [ + expect(worker.messages).toEqual([ { type: 'V_FS_WRITE', path: '/main.js', @@ -285,73 +255,45 @@ describe('Vrowzer file synchronization', () => { id: expect.any(String) }, { type: 'V_FS_UNLINK', path: '/main.js', id: expect.any(String) } - ] - expect(worker.messages).toEqual(expected) - expect(controllerMocks.postMessage.mock.calls.map(([message]) => message)).toEqual(expected) - // Each operation has its own id, shared by both Workers + ]) + // Each operation has its own id expect(new Set(worker.messages.map(message => message.id)).size).toBe(3) - expect(serviceWorkerFileMessages().map(message => message.id)).toEqual( - worker.messages.map(message => message.id) - ) + expect(serviceWorkerMessages()).toEqual([]) }) }) describe('Vrowzer file operation results', () => { - test.each(['Web Worker', 'Service Worker'] as const)( - 'resolves only after both Workers acknowledge, the %s first', - async first => { - const { vrowzer, worker } = await readyInstance() - - const updating = vrowzer.updateFile('/main.js', 'updated') - const id = lastId(worker) - expect(serviceWorkerFileMessages().at(-1)?.id).toBe(id) - - if (first === 'Web Worker') { - worker.ack(id) - } else { - ackFromServiceWorker(id) - } - expect(await isPending(updating)).toBe(true) + test('resolves once the Web Worker acknowledges', async () => { + const { vrowzer, worker } = await readyInstance() - if (first === 'Web Worker') { - ackFromServiceWorker(id) - } else { - worker.ack(id) - } - await expect(updating).resolves.toBeUndefined() - } - ) + const updating = vrowzer.updateFile('/main.js', 'updated') + expect(await isPending(updating)).toBe(true) - test.each(['Web Worker', 'Service Worker'] as const)( - 'rejects with the operation, the path and the cause when the %s fails to apply it', - async target => { - const { vrowzer, worker } = await readyInstance() - const error = { name: 'TypeError', message: 'watchChange failed' } - - const updating = vrowzer.updateFile('/main.js', 'updated') - const id = lastId(worker) - if (target === 'Web Worker') { - worker.ack(id, error) - } else { - ackFromServiceWorker(id, error) - } + worker.ack(lastId(worker)) + await expect(updating).resolves.toBeUndefined() + }) - await expect(updating).rejects.toThrow( - `[Vrowzer] updateFile("/main.js") failed in the ${target}: watchChange failed` - ) - await expect(updating).rejects.toMatchObject({ cause: error }) - // A late reply from the other Worker does nothing - worker.ack(id) - ackFromServiceWorker(id) - } - ) + test('rejects with the operation, the path and the cause when the Web Worker fails to apply it', async () => { + const { vrowzer, worker } = await readyInstance() + const error = { name: 'TypeError', message: 'watchChange failed' } + + const updating = vrowzer.updateFile('/main.js', 'updated') + const id = lastId(worker) + worker.ack(id, error) + + await expect(updating).rejects.toThrow( + '[Vrowzer] updateFile("/main.js") failed in the Web Worker: watchChange failed' + ) + await expect(updating).rejects.toMatchObject({ cause: error }) + // A late reply does nothing + worker.ack(id) + }) - test('rejects after fileSyncTimeout and names the Workers that did not reply', async () => { + test('rejects after fileSyncTimeout', async () => { vi.useFakeTimers() - const { vrowzer, worker } = await readyInstance({ fileSyncTimeout: 1000 }) + const { vrowzer } = await readyInstance({ fileSyncTimeout: 1000 }) const deleting = vrowzer.deleteFile('/main.js') - ackFromServiceWorker(lastId(worker)) const failure = deleting.catch((error: unknown) => error) await vi.advanceTimersByTimeAsync(1000) @@ -361,7 +303,7 @@ describe('Vrowzer file operation results', () => { expect(vi.getTimerCount()).toBe(0) }) - test('waits 10 seconds for the replies by default', async () => { + test('waits 10 seconds for the reply by default', async () => { vi.useFakeTimers() const { vrowzer } = await readyInstance() @@ -380,20 +322,19 @@ describe('Vrowzer file operation results', () => { await vi.advanceTimersByTimeAsync(1) await expect(adding).rejects.toThrow( - '[Vrowzer] addFile("/main.js") timed out after 10000ms waiting for the Web Worker and the Service Worker' + '[Vrowzer] addFile("/main.js") timed out after 10000ms waiting for the Web Worker' ) }) - test('rejects the operations waiting for the Web Worker when it reports an error', async () => { + test('rejects the pending operations when the Web Worker reports an error', async () => { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const { vrowzer, worker } = await readyInstance() const updating = vrowzer.updateFile('/main.js', 'updated') const updateId = lastId(worker) const adding = vrowzer.addFile('/other.js', 'other') - const addId = lastId(worker) - // This one only waits for the Service Worker - worker.ack(addId) + worker.ack(lastId(worker)) + await expect(adding).resolves.toBeUndefined() worker.onerror?.({ message: 'Uncaught ReferenceError: x is not defined' } as ErrorEvent) @@ -401,37 +342,30 @@ describe('Vrowzer file operation results', () => { '[Vrowzer] updateFile("/main.js") failed because the Web Worker reported an error: Uncaught ReferenceError: x is not defined' ) expect(consoleError).toHaveBeenCalled() - expect(await isPending(adding)).toBe(true) - ackFromServiceWorker(addId) - await expect(adding).resolves.toBeUndefined() // A late reply for the rejected operation does nothing - ackFromServiceWorker(updateId) + worker.ack(updateId) }) - test('ignores unknown, repeated and late replies', async () => { + test('ignores unknown and late replies, and replies through the Service Worker', async () => { const { vrowzer, worker } = await readyInstance() const updating = vrowzer.updateFile('/main.js', 'updated') const id = lastId(worker) worker.ack('unknown') - ackFromServiceWorker('unknown') worker.onmessage?.({ data: { type: 'V_FS_OTHER', id } } as MessageEvent) - worker.ack(id) - // A second reply from the Web Worker does not stand in for the Service Worker - worker.ack(id) + // Only the Web Worker applies file operations + ackFromServiceWorker(id) expect(await isPending(updating)).toBe(true) - ackFromServiceWorker(id) + worker.ack(id) await expect(updating).resolves.toBeUndefined() // Replies after the operation settled do nothing worker.ack(id, { name: 'Error', message: 'late' }) - ackFromServiceWorker(id, { name: 'Error', message: 'late' }) const deleting = vrowzer.deleteFile('/main.js') const nextId = lastId(worker) expect(nextId).not.toBe(id) worker.ack(nextId) - ackFromServiceWorker(nextId) await expect(deleting).resolves.toBeUndefined() }) @@ -441,17 +375,14 @@ describe('Vrowzer file operation results', () => { const firstUpdate = first.vrowzer.updateFile('/a.js', 'a') const secondUpdate = second.vrowzer.updateFile('/b.js', 'b') - const firstId = lastId(first.worker) const secondId = lastId(second.worker) - // Both instances receive the Service Worker's replies through the same container - first.worker.ack(firstId) - ackFromServiceWorker(firstId) + first.worker.ack(secondId) + first.worker.ack(lastId(first.worker)) await expect(firstUpdate).resolves.toBeUndefined() expect(await isPending(secondUpdate)).toBe(true) second.worker.ack(secondId) - ackFromServiceWorker(secondId) await expect(secondUpdate).resolves.toBeUndefined() }) @@ -473,7 +404,6 @@ describe('Vrowzer file operation results', () => { ) await expect(ready).resolves.toBe(true) expect(webWorkerFileMessages(workers[0]!)).toEqual([]) - expect(serviceWorkerFileMessages()).toEqual([]) }) test('rejects after ready() failed without sending anything', async () => { @@ -486,27 +416,23 @@ describe('Vrowzer file operation results', () => { '[Vrowzer] deleteFile() can only be called after ready() resolves to true (current state: failed)' ) expect(webWorkerFileMessages(workers[0]!)).toEqual([]) - expect(serviceWorkerFileMessages()).toEqual([]) }) - test('sends each Worker its own copy of binary content', async () => { + test('sends a copy of binary content and keeps the caller buffer usable', async () => { const { vrowzer, worker } = await readyInstance() const buffer = new Uint8Array([1, 2, 3]).buffer const adding = vrowzer.addFile('/data.bin', buffer) + new Uint8Array(buffer).fill(9) - const webWorkerMessage = webWorkerFileMessages(worker).at(-1)! - const serviceWorkerMessage = serviceWorkerFileMessages().at(-1)! + const message = webWorkerFileMessages(worker).at(-1)! expect(buffer.byteLength).toBe(3) - expect(webWorkerMessage.content).not.toBe(buffer) - expect(serviceWorkerMessage.content).not.toBe(buffer) - expect(webWorkerMessage.content).not.toBe(serviceWorkerMessage.content) - expect([...new Uint8Array(webWorkerMessage.content as ArrayBuffer)]).toEqual([1, 2, 3]) - expect([...new Uint8Array(serviceWorkerMessage.content as ArrayBuffer)]).toEqual([1, 2, 3]) - expect(worker.transfers.at(-1)?.[0]).toBe(webWorkerMessage.content) - - worker.ack(webWorkerMessage.id!) - ackFromServiceWorker(webWorkerMessage.id!) + expect(message.content).not.toBe(buffer) + expect([...new Uint8Array(message.content as ArrayBuffer)]).toEqual([1, 2, 3]) + // The copy is transferred, not copied again + expect(worker.transfers.at(-1)?.[0]).toBe(message.content) + + worker.ack(message.id!) await expect(adding).resolves.toBeUndefined() }) @@ -521,6 +447,5 @@ describe('Vrowzer file operation results', () => { await expect(adding).rejects.toThrow('[Vrowzer] addFile("/data.bin") could not be sent:') await expect(adding).rejects.toMatchObject({ cause: expect.any(TypeError) }) expect(vi.getTimerCount()).toBe(0) - expect(serviceWorkerFileMessages()).toEqual([]) }) }) diff --git a/packages/vrowzer/src/index-recovery.test.ts b/packages/vrowzer/src/index-recovery.test.ts index 0f7093fc..608b316b 100644 --- a/packages/vrowzer/src/index-recovery.test.ts +++ b/packages/vrowzer/src/index-recovery.test.ts @@ -15,16 +15,9 @@ interface TestMessage { 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>(() => () => {}), @@ -45,7 +38,7 @@ 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 } +let replies: { webWorkerAcks: boolean; channel: boolean } class TestWorker { onmessage: ((event: MessageEvent) => void) | null = null @@ -70,6 +63,13 @@ class TestWorker { } } + /** + * Completes a channel handshake that it did not answer by itself. + */ + acceptChannel(): void { + this.onmessage?.({ data: { type: V_SW_CONNECT_PORT_ACK } } as MessageEvent) + } + private reply(data: object): void { queueMicrotask(() => this.onmessage?.({ data } as MessageEvent)) } @@ -81,10 +81,6 @@ 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 }) } @@ -103,35 +99,12 @@ 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 @@ -148,7 +121,7 @@ async function readyInstance( beforeEach(() => { vi.clearAllMocks() workers = [] - replies = { webWorkerAcks: true, serviceWorkerAcks: true, channel: true } + replies = { webWorkerAcks: true, channel: true } controllerMocks.container = new EventTarget() vi.stubGlobal('Worker', TestWorker) vi.stubGlobal( @@ -159,15 +132,8 @@ beforeEach(() => { } ) 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)) + if (message.type === V_WW_CONNECT_PORT && replies.channel) { + queueMicrotask(() => fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK })) } }) }) @@ -180,8 +146,9 @@ afterEach(() => { describe('Vrowzer Service Worker recovery', () => { describe('detection', () => { - test('restores the project when a Service Worker instance with another id starts', async () => { + test('connects the Web Worker channel again when a Service Worker instance with another id starts', async () => { const { worker, recovered, recoveryErrors } = await readyInstance({ '/main.js': 'export {}' }) + const [firstConnection] = serviceWorkerMessages(V_WW_CONNECT_PORT) controllerMocks.postMessage.mockClear() worker.messages.length = 0 @@ -191,9 +158,9 @@ describe('Vrowzer Service Worker recovery', () => { expect(recovered).toHaveBeenCalledOnce() }) expect(recoveryErrors).not.toHaveBeenCalled() - expect(serviceWorkerMessages().map(message => message.type)).toEqual([ - 'V_FS_INIT', - V_WW_CONNECT_PORT + // The Web Worker still has the files, so only the channel is connected again + expect(serviceWorkerMessages()).toEqual([ + { type: V_WW_CONNECT_PORT, runtimeId: firstConnection!.runtimeId } ]) expect(worker.messages.map(message => message.type)).toEqual([V_SW_CONNECT_PORT]) }) @@ -235,11 +202,14 @@ describe('Vrowzer Service Worker recovery', () => { expect(serviceWorkerMessages()).toEqual([]) }) - test('restores the project after ready() when an instance started during ready()', async () => { + test('connects the channel again 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) { + // The Service Worker restarts right after it got the channel of ready() + if ( + message.type === V_WW_CONNECT_PORT && + serviceWorkerMessages(V_WW_CONNECT_PORT).length === 1 + ) { startServiceWorkerInstance('sw-2') } postMessage(message, transfer) @@ -250,161 +220,32 @@ describe('Vrowzer Service Worker recovery', () => { 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) + expect(serviceWorkerMessages(V_WW_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 () => { + test('sends the operations called during a recovery to the Web Worker right away', async () => { const { vrowzer, worker, recovered } = await readyInstance({ '/a.js': 'v1', '/b.js': 'b' }) - replies.serviceWorkerAcks = false + replies.channel = 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(vrowzer.updateFile('/a.js', 'v2')).resolves.toBeUndefined() + await expect(vrowzer.deleteFile('/b.js')).resolves.toBeUndefined() - await expect(updating).resolves.toBeUndefined() - await expect(deleting).resolves.toBeUndefined() - expect(recovered).toHaveBeenCalledOnce() - const expected = [ + expect(fileChanges(worker.messages)).toEqual([ 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) + ]) + expect(fileChanges(serviceWorkerMessages())).toEqual([]) + expect(recovered).not.toHaveBeenCalled() - 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') + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + worker.acceptChannel() await vi.waitFor(() => { - expect(recovered).toHaveBeenCalledTimes(2) + expect(recovered).toHaveBeenCalledOnce() }) - expect([...new Uint8Array(recoveryInits()[1]!.binaryFiles!['/data.bin']!)]).toEqual([1, 2, 3]) }) }) @@ -415,10 +256,9 @@ describe('Vrowzer Service Worker recovery', () => { {}, { fileSyncTimeout: 1000 } ) - replies.serviceWorkerAcks = false + replies.channel = false startServiceWorkerInstance('sw-2') await flush() - const result = vrowzer.updateFile('/a.js', 'v2').catch((error: unknown) => error) await vi.advanceTimersByTimeAsync(1000) @@ -427,98 +267,68 @@ describe('Vrowzer Service Worker recovery', () => { 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([]) + // File operations do not wait for the Service Worker + await expect(vrowzer.updateFile('/a.js', 'v2')).resolves.toBeUndefined() }) - 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') + test('tries again on the next start after a failed recovery', async () => { + vi.useFakeTimers() + const { recovered, recoveryErrors } = await readyInstance({}, { fileSyncTimeout: 1000 }) + replies.channel = false 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') + await vi.advanceTimersByTimeAsync(1000) 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() + replies.channel = true startServiceWorkerInstance('sw-3') await vi.waitFor(() => { expect(recovered).toHaveBeenCalledOnce() }) - expect(recoveryInits()[1]!.files!['/a.js']).toBe('after the failure') + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(3) }) }) describe('overlaps', () => { - test('restores the newest instance when another one starts during a recovery', async () => { - const { vrowzer, recovered, recoveryErrors } = await readyInstance() - replies.serviceWorkerAcks = false + test('connects the newest instance when another one starts during a recovery', async () => { + const { worker, recovered, recoveryErrors } = await readyInstance() + replies.channel = false startServiceWorkerInstance('sw-2') await flush() startServiceWorkerInstance('sw-3') await flush() - const [first, second] = recoveryInits() - expect(second).toBeDefined() + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(3) + expect(webWorkerMessages(worker, V_SW_CONNECT_PORT)).toHaveLength(3) - ackFromServiceWorker(first!.id) - await flush() - expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) - ackFromServiceWorker(second!.id) + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + worker.acceptChannel() await vi.waitFor(() => { expect(recovered).toHaveBeenCalledOnce() }) + await flush() + 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 + test('stops the recovery and connects nothing more', async () => { + const { vrowzer, worker, recovered } = await readyInstance() + replies.channel = 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 vrowzer.dispose() + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + worker.acceptChannel() await flush() - expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) - expect(fileChanges(serviceWorkerMessages())).toEqual([]) + expect(recovered).not.toHaveBeenCalled() + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(2) startServiceWorkerInstance('sw-3') await flush() - expect(recoveryInits()).toHaveLength(1) + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(2) }) }) }) diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 035aef03..5f4178e8 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -65,11 +65,7 @@ import { resolveServiceWorkerScope } from './service-worker-scope.ts' import { resolveServiceWorkerVersion, withServiceWorkerVersion } from './service-worker-version.ts' import type { Emittable } from '@kazupon/jts-utils/event/emitter' -import type { - FSInitMessage, - FileSystemPublisher, - FileSystemPublisherTarget -} from '@vrowzer/fs/watcher' +import type { FileSystemPublisher } from '@vrowzer/fs/watcher' import type { SvcWorkerControllerEventMap } from '@vrowzer/service-worker/controller' const DEFAULT_SERVICE_WORKER_READY_TIMEOUT = 60_000 @@ -106,13 +102,6 @@ type ReadyState = 'idle' | 'initializing' | 'ready' | 'failed' | 'disposed' 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}. */ @@ -456,26 +445,14 @@ interface ResolvedVrowzerOptions { } /** - * A file operation waiting for the Workers to acknowledge it. + * A file operation waiting for the Web Worker to acknowledge it. */ interface PendingFileOperation { operation: FileOperation path: string - /** - * Workers that have not acknowledged the operation yet. - */ - waitingFor: Set resolve: () => void reject: (error: Error) => void - /** - * 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 + timer: ReturnType } interface PreviewSessionRecord { @@ -561,7 +538,7 @@ function describeFileOperation(operation: FileOperation, path: string): string { /** * Copies the files given to `ready()`, so that later changes by the caller do not reach the - * Workers. ArrayBuffers are copied as well, and the caller's buffers are never transferred. + * Web Worker. ArrayBuffers are copied as well, and the caller's buffers are never transferred. */ function copyInitialFiles(files: VrowzerConfig['files']): Record { const copied: Record = { ...files } @@ -573,24 +550,6 @@ function copyInitialFiles(files: VrowzerConfig['files']): Record): FSInitMessage { - const files: Record = {} - const binaryFiles: Record = {} - for (const [path, content] of Object.entries(allFiles)) { - if (typeof content === 'string') { - files[path] = content - } else { - binaryFiles[path] = content - } - } - return Object.keys(binaryFiles).length > 0 - ? { type: 'V_FS_INIT', files, binaryFiles } - : { type: 'V_FS_INIT', files } -} - /** * Rebuilds the error that a Worker reported in a `V_FS_ACK` message. */ @@ -679,24 +638,18 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { // Release failures of an aborted ready(), reported by dispose() const readyReleaseErrors: unknown[] = [] const controllerSubscriptions: (() => void)[] = [] - let serviceWorkerTarget: FileSystemPublisherTarget | null = null - // File operations waiting for acknowledgements, by operation id + // File operations waiting for the Web Worker to acknowledge them, 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 + // The Service Worker instance that has the Web Worker channel 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 @@ -713,8 +666,8 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } /** - * Releases what initialization created: Service Worker controller event forwarding, - * the file sync targets and acknowledgement listener, and the Web Worker. + * Releases what initialization created: Service Worker controller event forwarding, the + * listener of the Service Worker instances that start, and the Web Worker with its file sync. * Used by a failed ready() and by dispose(). */ function releaseRuntime(errors: unknown[]): void { @@ -726,16 +679,6 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { if (stopListeningStarts) { attempt(errors, stopListeningStarts) } - const stopListeningAcks = stopListeningServiceWorkerAcks - stopListeningServiceWorkerAcks = null - if (stopListeningAcks) { - attempt(errors, stopListeningAcks) - } - const target = serviceWorkerTarget - serviceWorkerTarget = null - if (target) { - attempt(errors, () => publisher.removeTarget(target)) - } attempt(errors, cleanupWebWorker) } @@ -759,28 +702,20 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } } - /** - * Rejects the pending file operations, or only those still waiting for `target`. - */ - function rejectFileOperations( - toError: (pending: PendingFileOperation) => Error, - target?: FileSyncTarget - ): void { + function rejectFileOperations(toError: (pending: PendingFileOperation) => Error): void { // Deleting entries while iterating a Map is safe, so no copy is needed for (const [id, pending] of pendingFileOperations) { - if (target === undefined || pending.waitingFor.has(target)) { - settleFileOperation(id, toError(pending)) - } + settleFileOperation(id, toError(pending)) } } - function handleFileSyncAck(target: FileSyncTarget, data: unknown): void { + function handleFileSyncAck(data: unknown): void { if (!isRecord(data) || data.type !== V_FS_ACK || typeof data.id !== 'string') { return } const pending = pendingFileOperations.get(data.id) - // Unknown ids belong to another instance in the same page, or to settled operations - if (!pending || !pending.waitingFor.has(target)) { + // Unknown ids belong to settled operations + if (!pending) { return } if (data.error !== undefined) { @@ -788,23 +723,20 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { settleFileOperation( data.id, new Error( - `${describeFileOperation(pending.operation, pending.path)} failed in the ${target}: ${cause.message}`, + `${describeFileOperation(pending.operation, pending.path)} failed in the Web Worker: ${cause.message}`, { cause } ) ) return } - pending.waitingFor.delete(target) - if (pending.waitingFor.size === 0) { - settleFileOperation(data.id) - } + settleFileOperation(data.id) } /** - * Receives file sync acknowledgements from the Workers, once the instance is ready. + * Receives file sync acknowledgements from the Web Worker, once the instance is ready. */ function listenFileSyncAcks(worker: Worker): void { - worker.onmessage = event => handleFileSyncAck('Web Worker', event.data) + worker.onmessage = event => handleFileSyncAck(event.data) worker.onerror = event => { console.error('[Vrowzer] Web Worker error:', event.message, event.filename, event.lineno) const message = event.message || 'unknown error' @@ -812,36 +744,19 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { pending => new Error( `${describeFileOperation(pending.operation, pending.path)} failed because the Web Worker reported an error: ${message}` - ), - 'Web Worker' + ) ) } - - const container = getController()?.container - if (container) { - const handler = (event: MessageEvent) => handleFileSyncAck('Service Worker', event.data) - container.addEventListener('message', handler) - stopListeningServiceWorkerAcks = () => container.removeEventListener('message', handler) - } - } - - 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. + * Sends a file operation to the Web Worker, which has the project files, and waits until it has + * applied it, up to `fileSyncTimeout`. */ function syncFile( operation: FileOperation, path: string, - prepare: () => SendFileOperation + send: (options: { id: string }) => void ): Promise { if (readyState === 'disposed') { return Promise.reject(new Error(`[Vrowzer] ${operation}() cannot be called after dispose()`)) @@ -854,79 +769,37 @@ 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') - } - if (serviceWorkerTarget) { - waitingFor.add('Service Worker') - } const id = crypto.randomUUID() + const timeout = resolved.fileSyncTimeout return new Promise((resolve, reject) => { - 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) - }) - } + const timer = setTimeout(() => { + settleFileOperation( + id, + new Error( + `${describeFileOperation(operation, path)} timed out after ${timeout}ms waiting for the Web Worker` + ) + ) + }, timeout) + pendingFileOperations.set(id, { operation, path, resolve, reject, timer }) - /** - * 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 ') + try { + send({ id }) + } catch (error) { settleFileOperation( id, new Error( - `${describeFileOperation(operation, path)} timed out after ${timeout}ms waiting for ${targets}` + `${describeFileOperation(operation, path)} could not be sent: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } ) ) } - }, 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. + * Listens for the Service Worker instances that start, to connect the Web Worker channel again + * in a restarted one. */ function listenServiceWorkerStarts(): void { const container = getController()?.container @@ -959,10 +832,10 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } /** - * 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. + * Connects the Web Worker channel again in a restarted Service Worker, which has lost it. The Web + * Worker still has the project files, so file operations go on during 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 @@ -982,9 +855,6 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { return } recovery = null - for (const { id, send } of heldFileOperations.splice(0)) { - sendFileOperation(id, send) - } _emitter.emit('serviceWorkerRecovered', undefined) }, (error: unknown) => { @@ -993,17 +863,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { 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( @@ -1017,81 +877,23 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } /** - * 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. + * Connects the Web Worker channel again in the restarted Service Worker. */ async function restoreServiceWorker(signal: AbortSignal): Promise { - const serviceWorker = getServiceWorker() - if (!serviceWorker) { + if (!getServiceWorker()) { 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. + * Stops the recovery in progress without reporting it. */ function stopRecovery(reason: Error): void { const current = recovery recovery = null - heldFileOperations.length = 0 current?.abort(reason) } @@ -1391,7 +1193,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { try { // The files are sent later, so copy them as they are when ready() is called const initialFiles = copyInitialFiles(config.files) - // A preview always loads /index.html, so give both Workers the same default + // A preview always loads /index.html, so give the Web Worker a default if (!Object.hasOwn(initialFiles, '/index.html')) { initialFiles['/index.html'] = DEFAULT_INDEX_HTML } @@ -1496,7 +1298,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { ) // 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. + // A restarted Service Worker has lost the Web Worker channel of this instance. serviceWorkerInstanceId = getServiceWorkerInstanceId() listenServiceWorkerStarts() @@ -1520,28 +1322,13 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } } - // 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 = { - postMessage: (msg: any, transfer?: any) => serviceWorker.postMessage(msg, transfer ?? []) - } - publisher.addTarget(serviceWorkerTarget) - // The Web Worker already loaded these files during V_WW_SETUP. - // Broadcasting them again emits add events and an initial HMR reload. - serviceWorker.postMessage(createFSInitMessage(allFiles)) - } - - // 8. Establish MessageChannel (Service Worker ↔ Web Worker) + // 7. Establish MessageChannel (Service Worker ↔ Web Worker). The Service Worker forwards the + // preview requests to the Web Worker, which has the project files. await establishChannel(signal) // dispose() may have been called while the last ACK was being delivered signal.throwIfAborted() - // 9. File operations are accepted from now on, so listen for their acknowledgements + // 8. File operations are accepted from now on, so listen for their acknowledgements listenFileSyncAcks(currentWebWorker) readyState = 'ready' @@ -1718,18 +1505,19 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { }, addFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('addFile', filePath, () => prepareWrite(filePath, content)) + return syncFile('addFile', filePath, options => + publisher.writeFile(filePath, content, options) + ) }, updateFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('updateFile', filePath, () => prepareWrite(filePath, content)) + return syncFile('updateFile', filePath, options => + publisher.writeFile(filePath, content, options) + ) }, deleteFile(filePath: string): Promise { - return syncFile('deleteFile', filePath, () => options => { - publisher.unlink(filePath, options) - projectFiles.delete(filePath) - }) + return syncFile('deleteFile', filePath, options => publisher.unlink(filePath, options)) }, dispose, diff --git a/packages/vrowzer/src/service-worker-core.test.ts b/packages/vrowzer/src/service-worker-core.test.ts index a06c722b..1477f3b1 100644 --- a/packages/vrowzer/src/service-worker-core.test.ts +++ b/packages/vrowzer/src/service-worker-core.test.ts @@ -9,26 +9,10 @@ interface ServerOptions { } const mocks = vi.hoisted(() => ({ - handleMessage: vi.fn<(message: unknown) => void>(), listen: vi.fn<() => Promise>(), serverOptions: undefined as ServerOptions | undefined })) -vi.mock('@vrowzer/fs', () => ({ - fs: { - mkdirSync: vi.fn<() => void>(), - writeFileSync: vi.fn<() => void>() - }, - vol: { - fromJSON: vi.fn<() => void>() - } -})) -vi.mock('@vrowzer/fs/watcher', () => ({ - V_FS_ACK: 'V_FS_ACK', - createFileSystemSubscriber: () => ({ watcher: {}, handleMessage: mocks.handleMessage }) -})) -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: (_scope: unknown, options: ServerOptions) => { mocks.serverOptions = options @@ -86,7 +70,6 @@ function messagesOfType(client: TestClient, type: string): { type: string; insta beforeEach(() => { vi.clearAllMocks() - mocks.handleMessage.mockReset() mocks.serverOptions = undefined windowClients = [] }) @@ -97,55 +80,6 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('initServiceWorker file synchronization', () => { - beforeEach(async () => { - // Keep the server starting, so that only message handling runs - mocks.listen.mockReturnValue(new Promise(() => {})) - stubServiceWorkerScope() - await initServiceWorker() - }) - - 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_INIT', id: 'op-1', files: { '/main.ts': 'test' } } - ])('acknowledges $type to the sending client after applying it', message => { - const client = createClient() - mocks.handleMessage.mockImplementation(() => { - expect(client.postMessage).not.toHaveBeenCalled() - }) - - receive(message, client) - - expect(mocks.handleMessage).toHaveBeenCalledExactlyOnceWith(message) - expect(client.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'V_FS_ACK', id: 'op-1' }) - }) - - test('acknowledges with the error when writing the file fails', () => { - const client = createClient() - mocks.handleMessage.mockImplementation(() => { - throw new TypeError('invalid content') - }) - - receive({ type: 'V_FS_WRITE', id: 'op-2', path: '/main.ts', content: 'x' }, client) - - expect(client.postMessage).toHaveBeenCalledExactlyOnceWith({ - type: 'V_FS_ACK', - id: 'op-2', - error: { name: 'TypeError', message: 'invalid content' } - }) - }) - - test('does not acknowledge messages without an id', () => { - const client = createClient() - - receive({ type: 'V_FS_WRITE', path: '/main.ts', content: 'x' }, client) - - expect(mocks.handleMessage).toHaveBeenCalledOnce() - expect(client.postMessage).not.toHaveBeenCalled() - }) -}) - describe('initServiceWorker instance', () => { test('tells its window clients that it started once listen() completes', async () => { let completeListen!: () => void @@ -234,4 +168,16 @@ describe('initServiceWorker server', () => { ownerWaitTimeout: 10_000 }) }) + + test('does not take the project files, which only the Web Worker has', async () => { + mocks.listen.mockResolvedValue() + stubServiceWorkerScope() + await initServiceWorker() + const client = createClient() + + receive({ type: 'V_FS_WRITE', id: 'op-1', path: '/main.ts', content: 'x' }, client) + receive({ type: 'V_FS_INIT', id: 'op-2', files: { '/main.ts': 'x' } }, client) + + expect(client.postMessage).not.toHaveBeenCalled() + }) }) diff --git a/packages/vrowzer/src/service-worker-core.ts b/packages/vrowzer/src/service-worker-core.ts index a0102892..43f1fa68 100644 --- a/packages/vrowzer/src/service-worker-core.ts +++ b/packages/vrowzer/src/service-worker-core.ts @@ -19,10 +19,6 @@ /// -import { fs, vol } from '@vrowzer/fs' -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_INSTANCE_STARTED, @@ -32,8 +28,6 @@ import { import { resolvePreviewBasePath } from './preview-base.ts' import { resolveServiceWorkerVersionForWorker } from './service-worker-version.ts' -import type { FSAckMessage, FileSystemSyncMessage } from '@vrowzer/fs/watcher' - declare const self: ServiceWorkerGlobalScope /** @@ -42,27 +36,12 @@ declare const self: ServiceWorkerGlobalScope */ const OWNER_WAIT_TIMEOUT = 10_000 -function toAckError(error: unknown): NonNullable { - return error instanceof Error - ? { name: error.name, message: error.message } - : { name: 'Error', message: String(error) } -} - export async function initServiceWorker() { // 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, - '/dist/client/env.mjs': env - }) - fs.mkdirSync('/public', { recursive: true }) - fs.writeFileSync('/public/.gitkeep', '', { encoding: 'utf8' }) - - const subscriber = createFileSystemSubscriber(fs) const previewBase = resolvePreviewBasePath() const serviceWorkerVersion = resolveServiceWorkerVersionForWorker(self.location.href) @@ -85,7 +64,7 @@ export async function initServiceWorker() { 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. + // its Web Worker channel here, and connects it again. const clients = await self.clients.matchAll({ type: 'window' }) for (const client of clients) { client.postMessage({ type: V_SW_INSTANCE_STARTED, instanceId }) @@ -105,33 +84,6 @@ export async function initServiceWorker() { client?.postMessage({ type: V_SW_LISTEN_READY, instanceId }) }) } - return - } - - // Skip protocol messages handled by @vrowzer/service-worker - if (typeof message?.type === 'string' && message.type.startsWith('V_SW_')) { - return - } - - // 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_MKDIR' ? undefined : syncMessage.id - if (id === undefined) { - subscriber.handleMessage(syncMessage) - return - } - - // The virtual filesystem is written synchronously, so later requests see the change - // 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) - } catch (error) { - ack = { type: V_FS_ACK, id, error: toAckError(error) } - } - event.source?.postMessage(ack) - return } }) From 3aef1673ff0527424df3e67e3936563c29318d76 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:34:03 +0900 Subject: [PATCH 04/10] test(vrowzer): cover plugin middlewares in the Web Worker The Web Worker now runs the middlewares that plugins add in configureServer. A test plugin of the playground adds one in the hook and one in the function that the hook returns. The test checks that the first runs before the internal middlewares, and the second after them and before the SPA fallback to index.html. --- .../integration/playground/vite.config.ts | 31 +++++++++++++++++++ .../integration/vrowzer.integration-test.ts | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/vrowzer/integration/playground/vite.config.ts b/packages/vrowzer/integration/playground/vite.config.ts index 8ade402a..cf89c642 100644 --- a/packages/vrowzer/integration/playground/vite.config.ts +++ b/packages/vrowzer/integration/playground/vite.config.ts @@ -274,6 +274,36 @@ function fileSyncWebWorkerPlugin(): Plugin { } } +/** + * Adds middlewares as Vite plugins do: one in `configureServer`, which runs before the internal + * middlewares of the dev server, and one in the function that the hook returns, which runs after + * them and before `index.html`. + */ +function middlewaresWebWorkerPlugin(): Plugin { + return { + name: 'vrowzer-test:middlewares-web-worker', + apply: 'serve', + configureServer(server) { + // Only the Web Worker's dev server has environments and the root of the virtual project + const environments = (server as { environments?: unknown }).environments + if (server.config.root !== '/' || !environments) { + return + } + + // The Web Worker's middlewares are a Hono app under the preview base path + const middlewares = server.middlewares as unknown as { + use(path: string, handler: (c: { text(body: string): Response }) => Response): void + } + middlewares.use('/__middlewares__/pre', c => c.text('pre middleware')) + middlewares.use('/__middlewares__/pre-shadowing.txt', c => c.text('pre middleware')) + return () => { + middlewares.use('/__middlewares__/post', c => c.text('post middleware')) + middlewares.use('/__middlewares__/post-shadowed.txt', c => c.text('post middleware')) + } + } + } +} + export default defineConfig({ server: { origin: 'https://assets.vrowzer.test' @@ -284,6 +314,7 @@ export default defineConfig({ postcssOnceExitWebWorkerPlugin(), hmrClientTrackingWebWorkerPlugin(), fileSyncWebWorkerPlugin(), + middlewaresWebWorkerPlugin(), Vrowzer({ auto: false, basePath: '/__preview__/', diff --git a/packages/vrowzer/integration/vrowzer.integration-test.ts b/packages/vrowzer/integration/vrowzer.integration-test.ts index 833b55c8..8fbeda08 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -1558,6 +1558,35 @@ if (import.meta.hot) { }) }) + describe('plugin middlewares', () => { + test('answers with the middlewares that plugins add in configureServer', async () => { + await addPreviewFiles({ + '/__middlewares__/pre-shadowing.txt': 'project file', + '/__middlewares__/post-shadowed.txt': 'project file' + }) + + // A middleware added in the hook runs before the internal middlewares + expect(await fetchFromServiceWorker('/__middlewares__/pre')).toMatchObject({ + status: 200, + body: 'pre middleware' + }) + expect(await fetchFromServiceWorker('/__middlewares__/pre-shadowing.txt')).toMatchObject({ + status: 200, + body: 'pre middleware' + }) + // A middleware added in the function that the hook returns runs after them, and before the + // SPA fallback answers with index.html + expect(await fetchFromServiceWorker('/__middlewares__/post')).toMatchObject({ + status: 200, + body: 'post middleware' + }) + expect(await fetchFromServiceWorker('/__middlewares__/post-shadowed.txt')).toMatchObject({ + status: 200, + body: 'project file' + }) + }) + }) + describe('filesystem security', () => { const deniedFiles = { '/.env': 'ENV_SECRET_CANARY', From a646a387302f2effdaddac9447531be9ec5d9df2 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:37:40 +0900 Subject: [PATCH 05/10] docs(vrowzer): describe the Web Worker answering preview requests - README: the Architecture section describes how the Service Worker forwards the requests within basePath to the Web Worker, which has the project files and runs the Vite middlewares. A NOTE describes the change from vrowzer 0.4.x: the Service Worker no longer takes plugins with initServiceWorker(), and the middlewares that plugins add in configureServer run in the Web Worker. - README and JSDoc: file operations wait for the Web Worker only, and a restarted Service Worker only gets the Web Worker channel again, while file operations go on. - The architecture diagram drops the Service Worker's file subscriber, virtual file system and Vite dev server, and shows the V_FS_* messages going to the Web Worker only. - The recovery error message says that the restarted Service Worker could not be connected to the Web Worker. - The API references are regenerated. --- packages/vrowzer/README.md | 53 +++++++------- packages/vrowzer/assets/architecture.svg | 38 ++++------ .../docs/default/interfaces/Vrowzer.md | 35 +++++----- .../docs/default/interfaces/VrowzerConfig.md | 2 +- .../docs/default/interfaces/VrowzerOptions.md | 2 +- .../default/type-aliases/VrowzerEventMap.md | 6 +- packages/vrowzer/src/index.ts | 69 +++++++++---------- 7 files changed, 97 insertions(+), 108 deletions(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index efb4c3ab..321f4349 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -113,7 +113,7 @@ const vrowzer = Vrowzer() The runtime `serviceWorkerScope` remains available for compatibility and for builds without the plugin. If both values are provided, they must match or `Vrowzer()` throws before registration. Without either value, the scope defaults to `/`. The scope does not set the preview URL; that is the role of `basePath`. -The scope selects which pages the Service Worker controls, not which request URLs it receives from those pages. Vrowzer only responds to same-origin HTTP(S) requests within `basePath`. Cross-origin requests and same-origin requests outside `basePath` are left to the browser's native network path. Requests within `basePath` that the virtual project cannot serve get a 404 response from the Service Worker; they are not sent to the host server. +The scope selects which pages the Service Worker controls, not which request URLs it receives from those pages. Vrowzer only responds to same-origin HTTP(S) requests within `basePath`. Cross-origin requests and same-origin requests outside `basePath` are left to the browser's native network path. The Service Worker forwards the requests within `basePath` to the Web Worker, which answers them. Those that the virtual project cannot serve get a 404 response; they are not sent to the host server. `serviceWorkerVersion` identifies the Service Worker version expected by the controller and reported by the worker. When `@vrowzer/vite-plugin` is used, configure the version on the plugin and omit the runtime option: @@ -143,13 +143,13 @@ const vrowzer = Vrowzer({ webWorkerSetupTimeout: 120000 }) Set this option to `0` for an immediate timeout. It does not apply to Service Worker readiness and does not need a corresponding Vite plugin option. -`fileSyncTimeout` controls how long `addFile()`, `updateFile()` and `deleteFile()` wait for the Web Worker and the Service Worker to apply a change. It defaults to 10000 milliseconds. The Web Worker applies a change after the plugins' `watchChange` hooks finish, so slow plugins make it take longer. The default is shorter than the 30 seconds that the Service Worker waits for the Web Worker to transform a request, so in such environments an operation can reject although the change is applied later. Increase the timeout there: +`fileSyncTimeout` controls how long `addFile()`, `updateFile()` and `deleteFile()` wait for the Web Worker to apply a change. It defaults to 10000 milliseconds. The Web Worker applies a change after the plugins' `watchChange` hooks finish, so slow plugins make it take longer. The default is shorter than the 30 seconds that the Service Worker waits for the Web Worker to answer a request, so in such environments an operation can reject although the change is applied later. Increase the timeout there: ```ts 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). +The same timeout limits how long Vrowzer takes to connect a restarted Service Worker to the Web Worker again. See [Service Worker restarts](#service-worker-restarts). **Options:** @@ -160,13 +160,13 @@ The same timeout limits how long Vrowzer takes to restore the project in a resta | `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, or to restore a restarted Service Worker | +| `fileSyncTimeout` | `number` | `10000` | Milliseconds to wait for the Web Worker to apply a file change, or to reconnect a restarted Service Worker | ### Instance Methods #### `ready(config): Promise` -Initializes the preview system: creates Web Worker and Service Worker, establishes a MessageChannel between them, and syncs initial files. +Initializes the preview system: creates Web Worker and Service Worker, gives the initial files to the Web Worker, and establishes a MessageChannel between them. Call this method once per Vrowzer instance. Use one initialized Vrowzer instance per page and share it with every preview session. ```ts @@ -178,7 +178,7 @@ const ready = await vrowzer.ready({ }) ``` -File contents can be strings or `ArrayBuffer`s. Each `ArrayBuffer` is copied for the Workers when `ready()` is called, so the caller's buffer stays usable. +File contents can be strings or `ArrayBuffer`s. Each `ArrayBuffer` is copied for the Web Worker when `ready()` is called, so the caller's buffer stays usable. If `files` has no `/index.html`, Vrowzer uses a default one: an empty `
` and a module script that loads `/main.js`. @@ -251,7 +251,7 @@ Deletes a file from the virtual filesystem. Deleting a file that does not exist #### Waiting for file changes -The promise of `addFile()`, `updateFile()` and `deleteFile()` resolves when later preview requests see the change: the Web Worker and the Service Worker have written the file to their virtual filesystems, and the Web Worker has invalidated the modules that depend on it. It does not wait for HMR updates of mounted previews. To load the new contents in a fresh document, wait for the promise before reloading: +The promise of `addFile()`, `updateFile()` and `deleteFile()` resolves when later preview requests see the change: the Web Worker has written the file to its virtual filesystem, and invalidated the modules that depend on it. It does not wait for HMR updates of mounted previews. To load the new contents in a fresh document, wait for the promise before reloading: ```ts await vrowzer.updateFile('/main.js', source) @@ -259,10 +259,10 @@ vrowzer.reloadPreview() ``` - Operations awaited one after another are applied in that order. Operations started together may resolve in any order, and several files are not applied as one transaction. -- `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. +- `ArrayBuffer` content is copied for the Web 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 the Web Worker fails to apply the change (for example, a plugin's `watchChange` hook throws), when it reports an error, when it does not reply within `fileSyncTimeout`, or when the instance is disposed first. The error message names the operation and the path. - 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). +- A Service Worker restart does not delay the change, since the Web Worker applies it. 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. @@ -272,11 +272,11 @@ vrowzer.reloadPreview() Disposes the instance when the host application stops using it. The promise resolves after these resources are released: - every preview session, as with `unmount()` -- the instance's Web Worker -- the forwarding of Service Worker controller events and the file synchronization to the Service Worker +- the instance's Web Worker, with the project files +- the forwarding of Service Worker controller events - all event handlers, which are removed as soon as `dispose()` is called -The Service Worker registration and its virtual filesystem are shared with other clients and are kept, so a new instance can start right away. +The Service Worker registration is shared with other clients and is kept, so a new instance can start right away. ```ts await vrowzer.dispose() @@ -293,8 +293,8 @@ 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. +- If Vrowzer is connecting a restarted Service Worker to the Web Worker again, it stops. +- File operations still waiting for the Web Worker 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`. > [!NOTE] @@ -327,7 +327,7 @@ The session stays mounted and its rendering does not change: an error response b #### `serviceWorkerRecovered` -Emitted when Vrowzer has restored the project in a restarted Service Worker. See [Service Worker restarts](#service-worker-restarts). +Emitted when Vrowzer has connected a restarted Service Worker to the Web Worker again. See [Service Worker restarts](#service-worker-restarts). ```ts vrowzer.on('serviceWorkerRecovered', () => { @@ -337,23 +337,23 @@ vrowzer.on('serviceWorkerRecovered', () => { #### `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). +Emitted with an `Error` when Vrowzer could not connect a restarted Service Worker to the Web Worker again 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: +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 its channel to the Web Worker. The project files are in the Web Worker, which keeps them. Vrowzer connects the channel again 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. +2. Vrowzer connects the Web Worker channel again. 3. Vrowzer emits `serviceWorkerRecovered`. -While the project is being restored: +While the channel is being connected: -- 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. +- Preview requests wait for the Web Worker channel, for up to 10 seconds. After that, they get a `503` response, which a loading preview reports with `previewLoadError`. +- `addFile()`, `updateFile()` and `deleteFile()` go on as usual, since the Web Worker applies them. - 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: +If the channel is not connected within `fileSyncTimeout`, Vrowzer emits `serviceWorkerRecoveryError`, and 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 => { @@ -367,6 +367,13 @@ vrowzer.on('serviceWorkerRecoveryError', async error => { ![Architecture](./assets/architecture.svg) +The Web Worker runs the Vite dev server: it has the project files, the module graph and the plugins, and answers the preview requests with the Vite middlewares, including the ones that plugins add in `configureServer`. The Service Worker has no project files. It forwards the requests within `basePath` to the Web Worker over a MessageChannel, and the HMR ports of the previews as well. + +Give Vite plugins to the Web Worker, as `@vrowzer/vite-plugin` does with the plugins of `vite.config.ts`. The Service Worker runs no plugins. + +> [!NOTE] +> Up to vrowzer 0.4.x, the Service Worker ran the Vite middlewares on its own copy of the project files, and `initServiceWorker()` of `vrowzer/service-worker-core` took `plugins` for its dev server. The Web Worker's dev server had no `middlewares`, so the middlewares that plugins add in `configureServer` ran only in the Service Worker, for the plugins given to it. + ## 📚 API References See the [API References](./docs/index.md) diff --git a/packages/vrowzer/assets/architecture.svg b/packages/vrowzer/assets/architecture.svg index 617711a5..6a19ac7d 100644 --- a/packages/vrowzer/assets/architecture.svg +++ b/packages/vrowzer/assets/architecture.svg @@ -38,15 +38,9 @@ Service Worker - - - FileSystemSubscriber - @vrowzer/fs/watcher - - - - Virtual File System - @vrowzer/fs + + Has no project files. Forwards the preview requests + and the HMR ports to the Web Worker. @@ -56,7 +50,7 @@ @vrowzer/vite-dev-server/service-worker - Vite Dev Server (fetch handler) + Request forwarding (fetch handler) @@ -86,12 +80,12 @@ @vrowzer/vite-dev-server/web-worker - Worker server + Worker server, Vite middlewares @vrowzer/vite-dev-server/transformer - DevEnvironment + DevEnvironment, plugins @@ -100,23 +94,19 @@ - - - V_FS_* - - - V_FS_* + + V_FS_* register / claim - - - - RPC - (MessageChannel) + + + + RPC + (MessageChannel) @@ -126,7 +116,7 @@ fetch /__preview__/ - + HMR update (MessageChannel) diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index 0d58cc53..871c5c46 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -22,25 +22,22 @@ addFile(filePath: string, content: string | ArrayBuffer): Promise; Adds a new file to the preview environment with the specified content. -The promise resolves when later preview requests see the change: the Web Worker and the -Service Worker have written the file to their virtual filesystems, and the Web Worker has -invalidated the modules that depend on it. HMR updates of mounted previews are not awaited. +The promise resolves when later preview requests see the change: the Web Worker has written the +file to its virtual filesystem, and invalidated the modules that depend on it. HMR updates of +mounted previews are not awaited. It rejects without sending the change before [Vrowzer.ready](#method-ready) resolves to `true`, after it -fails, and after [Vrowzer.dispose](#method-dispose). It also rejects when a Worker fails to apply the change, -when the Web Worker reports an error, when the Workers do not reply within +fails, and after [Vrowzer.dispose](#method-dispose). It also rejects when the Web Worker fails to apply the +change, when it reports an error, when it does 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 | | --- | --- | --- | | `filePath` | `string` | The path of the file to be added. | -| `content` | `string \| ArrayBuffer` | The content of the file, which can be a string or an ArrayBuffer. An ArrayBuffer is copied for the Workers and stays usable. | +| `content` | `string \| ArrayBuffer` | The content of the file, which can be a string or an ArrayBuffer. An ArrayBuffer is copied for the Web Worker and stays usable. | #### Returns @@ -79,13 +76,13 @@ dispose(): Promise; Disposes this instance. -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. +An in-progress [Vrowzer.ready](#method-ready) is aborted and resolves to `false`, and the reconnection of +a restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is +terminated with the project files, Service Worker controller events are no longer forwarded, and +all event handlers are removed right away. The Service Worker registration is kept for other +clients. -File operations still waiting for the Workers reject. After disposal, `ready()` and the file +File operations still waiting for the Web Worker reject. After disposal, `ready()` and the file methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a new instance to start again. @@ -150,8 +147,8 @@ ready(config: VrowzerConfig): Promise; Ready for preview system initialization. -This method initializes the Web Worker, Service Worker, and MessageChannel, -then syncs initial files to both workers. +This method initializes the Web Worker with the initial files, the Service Worker, and the +MessageChannel between them. It can only be called once per Vrowzer instance. #### Parameters @@ -207,7 +204,7 @@ unmount(target?: PreviewSessionRef): void; ``` Unmounts one preview session, or every session when no target is provided. -The shared Service Worker, Web Worker, and virtual filesystem remain active. +The Service Worker, the Web Worker, and its virtual filesystem remain active. Use [Vrowzer.dispose](#method-dispose) to release the whole instance. #### Parameters @@ -237,7 +234,7 @@ The promise resolves and rejects as with [Vrowzer.addFile](#method-addfile). | Name | Type | Description | | --- | --- | --- | | `filePath` | `string` | The path of the file to be updated. | -| `content` | `string \| ArrayBuffer` | The new content for the file, which can be a string or an ArrayBuffer. An ArrayBuffer is copied for the Workers and stays usable. | +| `content` | `string \| ArrayBuffer` | The new content for the file, which can be a string or an ArrayBuffer. An ArrayBuffer is copied for the Web Worker and stays usable. | #### Returns diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md b/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md index c7ba0ed4..ee30d852 100644 --- a/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md +++ b/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md @@ -12,4 +12,4 @@ export interface VrowzerConfig | Name | Type | Description | | --- | --- | --- | -| `files` | `Record` | A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. An ArrayBuffer is copied for the Workers when [`Vrowzer.ready`](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-ready) is called, and stays usable. Without `/index.html`, a default one is used: an empty `#app` element and a module script that loads `/main.js`. | +| `files` | `Record` | A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. An ArrayBuffer is copied for the Web Worker when [`Vrowzer.ready`](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-ready) is called, and stays usable. Without `/index.html`, a default one is used: an empty `#app` element and a module script that loads `/main.js`. | diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md index 885065b6..fabe3a1a 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. 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` | +| `fileSyncTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Web 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 answer 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 connect a restarted Service Worker to the Web Worker again. 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 17e860f2..155f92ba 100644 --- a/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md +++ b/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md @@ -3,7 +3,7 @@ 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 for restoring a restarted Service Worker. +and adds events for preview sessions and for reconnecting a restarted Service Worker. ## Signature @@ -16,5 +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. | +| `serviceWorkerRecovered` | `void` | Emitted when Vrowzer has connected a restarted Service Worker to the Web Worker again. The browser stops an idle Service Worker and starts it again for the next request or message. The restarted Service Worker has lost its channel to the Web Worker, which keeps the project files, so Vrowzer connects the channel again, without reloading the host page. File operations go on in the meantime. | +| `serviceWorkerRecoveryError` | `Error` | Emitted when Vrowzer could not connect a restarted Service Worker to the Web Worker again within [VrowzerOptions.fileSyncTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-filesynctimeout). Preview requests that the Service Worker cannot forward 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/src/index.ts b/packages/vrowzer/src/index.ts index 5f4178e8..d75ba543 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -149,16 +149,16 @@ export interface VrowzerOptions { */ webWorkerSetupTimeout?: number /** - * Timeout in milliseconds for the Web Worker and the Service Worker to apply a change made with + * Timeout in milliseconds for the Web Worker to apply a change made with * {@link Vrowzer.addFile}, {@link Vrowzer.updateFile} or {@link Vrowzer.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 + * shorter than the 30 seconds that the Service Worker waits for the Web Worker to answer 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 - * {@link VrowzerEventMap.serviceWorkerRecoveryError}. + * It also limits how long Vrowzer takes to connect a restarted Service Worker to the Web Worker + * again. See {@link VrowzerEventMap.serviceWorkerRecoveryError}. * * @default 10000 */ @@ -171,7 +171,7 @@ export interface VrowzerOptions { export interface VrowzerConfig { /** * A record of file paths and their corresponding content, which can be either a string or an ArrayBuffer. - * An ArrayBuffer is copied for the Workers when {@linkcode Vrowzer.ready} is called, and stays usable. + * An ArrayBuffer is copied for the Web Worker when {@linkcode Vrowzer.ready} is called, and stays usable. * Without `/index.html`, a default one is used: an empty `#app` element and a module script that * loads `/main.js`. */ @@ -283,7 +283,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 for restoring a restarted Service Worker. + * and adds events for preview sessions and for reconnecting a restarted Service Worker. */ export type VrowzerEventMap = SvcWorkerControllerEventMap & { /** @@ -296,23 +296,21 @@ export type VrowzerEventMap = SvcWorkerControllerEventMap & { */ previewLoadError: PreviewLoadErrorInfo /** - * Emitted when Vrowzer has restored the project in a restarted Service Worker. + * Emitted when Vrowzer has connected a restarted Service Worker to the Web Worker again. * * 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. + * The restarted Service Worker has lost its channel to the Web Worker, which keeps the project + * files, so Vrowzer connects the channel again, without reloading the host page. File operations + * go on in the meantime. */ 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 + * Emitted when Vrowzer could not connect a restarted Service Worker to the Web Worker again 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. + * Preview requests that the Service Worker cannot forward 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. */ @@ -326,8 +324,8 @@ export interface Vrowzer extends Emittable { /** * Ready for preview system initialization. * - * This method initializes the Web Worker, Service Worker, and MessageChannel, - * then syncs initial files to both workers. + * This method initializes the Web Worker with the initial files, the Service Worker, and the + * MessageChannel between them. * It can only be called once per Vrowzer instance. * * @return A promise that resolves to `true` if the boot process is successful, or `false` if it fails. @@ -365,7 +363,7 @@ export interface Vrowzer extends Emittable { reloadPreview(target?: PreviewSessionRef): void /** * Unmounts one preview session, or every session when no target is provided. - * The shared Service Worker, Web Worker, and virtual filesystem remain active. + * The Service Worker, the Web Worker, and its virtual filesystem remain active. * Use {@link Vrowzer.dispose} to release the whole instance. * * @param target - A session ID or mounted session object. @@ -374,22 +372,19 @@ export interface Vrowzer extends Emittable { /** * Adds a new file to the preview environment with the specified content. * - * The promise resolves when later preview requests see the change: the Web Worker and the - * Service Worker have written the file to their virtual filesystems, and the Web Worker has - * invalidated the modules that depend on it. HMR updates of mounted previews are not awaited. + * The promise resolves when later preview requests see the change: the Web Worker has written the + * file to its virtual filesystem, and invalidated the modules that depend on it. HMR updates of + * mounted previews are not awaited. * * It rejects without sending the change before {@link Vrowzer.ready} resolves to `true`, after it - * fails, and after {@link Vrowzer.dispose}. It also rejects when a Worker fails to apply the change, - * when the Web Worker reports an error, when the Workers do not reply within + * fails, and after {@link Vrowzer.dispose}. It also rejects when the Web Worker fails to apply the + * change, when it reports an error, when it does not reply within * {@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. + * ArrayBuffer is copied for the Web Worker and stays usable. */ addFile(filePath: string, content: string | ArrayBuffer): Promise /** @@ -399,7 +394,7 @@ export interface Vrowzer extends Emittable { * * @param filePath - The path of the file to be updated. * @param content - The new content for the file, which can be a string or an ArrayBuffer. An - * ArrayBuffer is copied for the Workers and stays usable. + * ArrayBuffer is copied for the Web Worker and stays usable. */ updateFile(filePath: string, content: string | ArrayBuffer): Promise /** @@ -414,13 +409,13 @@ export interface Vrowzer extends Emittable { /** * Disposes this instance. * - * 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. + * An in-progress {@link Vrowzer.ready} is aborted and resolves to `false`, and the reconnection of + * a restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is + * terminated with the project files, Service Worker controller events are no longer forwarded, and + * all event handlers are removed right away. The Service Worker registration is kept for other + * clients. * - * File operations still waiting for the Workers reject. After disposal, `ready()` and the file + * File operations still waiting for the Web Worker reject. After disposal, `ready()` and the file * methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a * new instance to start again. * @@ -839,7 +834,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { */ function recoverServiceWorker(instanceId: string): void { serviceWorkerInstanceId = instanceId - // A newer Service Worker instance started, so stop restoring the previous one + // A newer Service Worker instance started, so stop connecting the previous one recovery?.abort(new Error('[Vrowzer] A newer Service Worker instance started')) const controller = new AbortController() recovery = controller @@ -867,7 +862,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { _emitter.emit( 'serviceWorkerRecoveryError', new Error( - `[Vrowzer] Could not restore the project in the restarted Service Worker: ${reason}`, + `[Vrowzer] Could not connect the restarted Service Worker to the Web Worker: ${reason}`, { cause: error } ) ) From 1eefdfb48164d6ed01a352431780215af9995958 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:52:09 +0900 Subject: [PATCH 06/10] feat(vrowzer): give each instance its own preview base path Each Vrowzer instance now has an ID, 12 hexadecimal digits from 48 random bits, created when Vrowzer() is called. Its previews load from its own base path, basePath followed by the ID, e.g. /__preview__/0123456789ab/. The path is also the Vite base of its Web Worker, so import.meta.env.BASE_URL in the preview returns it. - The new read-only previewBasePath returns the path. It is set right after Vrowzer() and stays the same after dispose(). - The ID replaces the runtimeId that V_WW_CONNECT_PORT has carried since #46. It no longer uses crypto.randomUUID(), so it does not change the order of the preview load tokens. - The integration and e2e tests read preview files under the previewBasePath of their instance. The Service Worker still forwards every request within basePath to the Web Worker that connected last. The next commit routes the requests by the ID in the path. --- e2e/playground/define/define.spec.ts | 4 +- e2e/vite-vanilla/vanilla-basic.spec.ts | 3 +- e2e/vite-worker-config/worker-config.spec.ts | 5 +- .../custom-base.integration-test.ts | 9 +- .../vrowzer/integration/custom-base/main.ts | 1 + .../integration/restart.integration-test.ts | 9 +- .../integration/vrowzer.integration-test.ts | 85 +++++++++++-------- packages/vrowzer/src/index-file-sync.test.ts | 18 ++++ packages/vrowzer/src/index.test.ts | 36 ++++++++ packages/vrowzer/src/index.ts | 38 +++++++-- 10 files changed, 155 insertions(+), 53 deletions(-) diff --git a/e2e/playground/define/define.spec.ts b/e2e/playground/define/define.spec.ts index d9806173..67ac7bda 100644 --- a/e2e/playground/define/define.spec.ts +++ b/e2e/playground/define/define.spec.ts @@ -183,8 +183,10 @@ describe('define', () => { } }) + // The filter rewrites the source with the Vite base, the preview base path of the instance + const previewBasePath = await page.evaluate(() => (window as any).__vrowzer__.previewBasePath) expect(await result.jsonValue()).toEqual({ - rewritten: '/__preview__/data.json', + rewritten: `${previewBasePath}data.json`, untouched: '/data.json' }) }) diff --git a/e2e/vite-vanilla/vanilla-basic.spec.ts b/e2e/vite-vanilla/vanilla-basic.spec.ts index c2f8a5e8..8502e7dd 100644 --- a/e2e/vite-vanilla/vanilla-basic.spec.ts +++ b/e2e/vite-vanilla/vanilla-basic.spec.ts @@ -95,11 +95,12 @@ if (import.meta.hot) { import.meta.hot.accept() } await expect.poll(() => iframeInnerText(), { timeout: 10000 }).toContain('Virtual updated') expect(await body.getAttribute('data-hmr-token')).toBe('preserved') + const previewBasePath = await page.evaluate(() => (window as any).__vrowzer__.previewBasePath) expect( browserRequests.some(request => { const url = new URL(request) return ( - url.pathname === '/__preview__/@id/__x00__virtual:invalidation' && + url.pathname === `${previewBasePath}@id/__x00__virtual:invalidation` && url.searchParams.get('variant') === 'query' && url.searchParams.has('t') ) diff --git a/e2e/vite-worker-config/worker-config.spec.ts b/e2e/vite-worker-config/worker-config.spec.ts index 64fd4280..863b2917 100644 --- a/e2e/vite-worker-config/worker-config.spec.ts +++ b/e2e/vite-worker-config/worker-config.spec.ts @@ -15,9 +15,12 @@ describe('dedicated Worker config', () => { expect(await page.textContent('#status')).toBe('Ready') await expect.poll(() => page.textContent('#react-host')).toBe('React host: host define') await expect.poll(() => iframeInnerText(), { timeout: 30000 }).toContain('Worker preview') + // The preview loads from the preview base path of the instance, under the custom basePath + const previewBasePath = await page.evaluate(() => (window as any).__vrowzer__.previewBasePath) + expect(previewBasePath).toMatch(/^\/worker-preview\/[0-9a-f]{12}\/$/) expect( browserResponses.some( - response => new URL(response.url).pathname === '/worker-preview/' && response.status === 200 + response => new URL(response.url).pathname === previewBasePath && response.status === 200 ) ).toBe(true) }) diff --git a/packages/vrowzer/integration/custom-base.integration-test.ts b/packages/vrowzer/integration/custom-base.integration-test.ts index 5ec6b79b..163e81c3 100644 --- a/packages/vrowzer/integration/custom-base.integration-test.ts +++ b/packages/vrowzer/integration/custom-base.integration-test.ts @@ -12,7 +12,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const FIXTURE_DIR = join(__dirname, 'custom-base') const HOST_BASE = '/app/' const PREVIEW_BASE = '/app/__preview__/' -const PREVIEW_MODULE = `${PREVIEW_BASE}main.js` const PREVIEW_TEXT = 'Custom base preview works' const SERVICE_WORKER_SCOPE = '/app/' const SERVICE_WORKER_VERSION = 'app-v2' @@ -73,10 +72,14 @@ async function expectPreviewContent(page: Page): Promise { ) .toContain(PREVIEW_TEXT) + // The previews of the instance load from its own path under the plugin's basePath + const previewBasePath = await page.evaluate(() => (window as any).__vrowzer__.previewBasePath) + expect(previewBasePath).toMatch(new RegExp(`^${PREVIEW_BASE}[0-9a-f]{12}/$`)) + const response = await page.evaluate(async previewBase => { const result = await fetch(previewBase) return { status: result.status, body: await result.text() } - }, PREVIEW_BASE) + }, previewBasePath) expect(response.status).toBe(200) expect(response.body).toContain('Custom base preview') expect(response.body).not.toContain('Waiting for Service Worker...') @@ -84,7 +87,7 @@ async function expectPreviewContent(page: Page): Promise { const moduleResponse = await page.evaluate(async previewModule => { const result = await fetch(previewModule) return { status: result.status, body: await result.text() } - }, PREVIEW_MODULE) + }, `${previewBasePath}main.js`) expect(moduleResponse.status).toBe(200) expect(moduleResponse.body).toContain(PREVIEW_TEXT) } diff --git a/packages/vrowzer/integration/custom-base/main.ts b/packages/vrowzer/integration/custom-base/main.ts index 946b56fb..cf374811 100644 --- a/packages/vrowzer/integration/custom-base/main.ts +++ b/packages/vrowzer/integration/custom-base/main.ts @@ -4,6 +4,7 @@ const status = document.getElementById('status')! const container = document.getElementById('preview-container')! const vrowzer = Vrowzer() +;(window as any).__vrowzer__ = vrowzer async function init() { try { diff --git a/packages/vrowzer/integration/restart.integration-test.ts b/packages/vrowzer/integration/restart.integration-test.ts index 99cffa57..d08774c7 100644 --- a/packages/vrowzer/integration/restart.integration-test.ts +++ b/packages/vrowzer/integration/restart.integration-test.ts @@ -109,7 +109,8 @@ async function fetchFromHost( ): Promise { return page.evaluate( async ({ path, acceptHeader }) => { - const response = await fetch(`/__preview__${path}`, { headers: { Accept: acceptHeader } }) + const base = (window as any).__vrowzer__.previewBasePath as string + const response = await fetch(`${base}${path.slice(1)}`, { headers: { Accept: acceptHeader } }) return { status: response.status, body: await response.text() } }, { path: requestPath, acceptHeader: accept } @@ -150,7 +151,8 @@ async function fetchBytes( const target = inPreview ? (document.querySelector('#preview-container iframe') as HTMLIFrameElement).contentWindow! : window - const response = await target.fetch(`/__preview__${path}`) + const base = (window as any).__vrowzer__.previewBasePath as string + const response = await target.fetch(`${base}${path.slice(1)}`) return { status: response.status, bytes: [...new Uint8Array(await response.arrayBuffer())] } }, { path: requestPath, inPreview: from === 'preview' } @@ -330,7 +332,8 @@ describe('Vrowzer Service Worker restart', () => { 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', { + const base = (window as any).__vrowzer__.previewBasePath as string + ;(window as any).__heldRequest__ = fetch(`${base}held.js`, { headers: { Accept: 'text/javascript' } }).then( response => ({ status: response.status }), diff --git a/packages/vrowzer/integration/vrowzer.integration-test.ts b/packages/vrowzer/integration/vrowzer.integration-test.ts index 8fbeda08..93f5f3a1 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -43,6 +43,8 @@ let delayedServiceWorkerRequests = 0 let hmrClientProbeId = 0 // Requests within the preview base path that reached the host server const hostPreviewRequests: string[] = [] +// The preview base path of the playground's instance, e.g. `/__preview__/0123456789ab/` +let previewBase: string function delayServiceWorkerResponse(delay: number): Plugin { return { @@ -146,6 +148,13 @@ interface SourceMapPayload { [key: string]: unknown } +/** + * Returns the URL path of a preview file, under the preview base path of the playground's instance. + */ +function previewPath(filePath: string): string { + return `${previewBase}${filePath.replace(/^\//, '')}` +} + async function addPreviewFiles(files: Record): Promise { await page.evaluate(async filesToAdd => { const vrowzer = (window as any).__vrowzer__ @@ -182,7 +191,7 @@ globalThis[${JSON.stringify(resultKey)}] = { clientIds } }) await page.evaluate( - ({ filePath, resultKey }) => { + ({ src, resultKey }) => { const iframe = document.querySelector('#preview-container iframe') as HTMLIFrameElement | null const iframeDocument = iframe?.contentDocument const iframeWindow = iframe?.contentWindow as any @@ -192,10 +201,10 @@ globalThis[${JSON.stringify(resultKey)}] = { clientIds } delete iframeWindow[resultKey] const script = iframeDocument.createElement('script') script.type = 'module' - script.src = `/__preview__${filePath}` + script.src = src iframeDocument.head.append(script) }, - { filePath, resultKey } + { src: previewPath(filePath), resultKey } ) await expect @@ -238,12 +247,12 @@ async function fetchFromPreview(requestPath: string): Promise { throw new Error('Preview iframe is not available') } - const response = await iframe.contentWindow.fetch(`/__preview__${path}`) + const response = await iframe.contentWindow.fetch(path) return { status: response.status, body: await response.text() } - }, requestPath) + }, previewPath(requestPath)) } /** @@ -254,22 +263,22 @@ async function fetchFromServiceWorker( requestPath: string ): Promise { return page.evaluate(async path => { - const response = await fetch(`/__preview__${path}`) + const response = await fetch(path) return { status: response.status, body: await response.text(), contentType: response.headers.get('Content-Type') } - }, requestPath) + }, previewPath(requestPath)) } async function fetchBytesFromServiceWorker( requestPath: string ): Promise<{ status: number; bytes: number[] }> { return page.evaluate(async path => { - const response = await fetch(`/__preview__${path}`) + const response = await fetch(path) return { status: response.status, bytes: [...new Uint8Array(await response.arrayBuffer())] } - }, requestPath) + }, previewPath(requestPath)) } /** @@ -492,6 +501,7 @@ beforeAll(async () => { `Expected vrowzer playground to become Ready, got ${JSON.stringify(status)}\n${pageConsoleLogs.join('\n')}` ) } + previewBase = await page.evaluate(() => (window as any).__vrowzer__.previewBasePath) if (SERVICE_WORKER_RESPONSE_DELAY > 0) { if (delayedServiceWorkerRequests === 0) { @@ -1161,7 +1171,7 @@ if (import.meta.hot) { // Instead, these tests point one session's bootstrap at another URL right after mount(). // The bootstrap embeds the preview URL as a JSON string, and the load token is kept, // so the host still accepts the reports from the replaced document. - const PREVIEW_URL_LITERAL = JSON.stringify('/__preview__/') + const previewUrlLiteral = () => JSON.stringify(previewBase) const UNREACHABLE_URL = 'http://127.0.0.1:9/' const sessionIds: string[] = [] const fixtureFiles: Record = {} @@ -1181,7 +1191,7 @@ if (import.meta.hot) { ) } }, - { id, previewUrl, previewUrlLiteral: PREVIEW_URL_LITERAL } + { id, previewUrl, previewUrlLiteral: previewUrlLiteral() } ) } @@ -1288,7 +1298,7 @@ if (import.meta.hot) { test('reports an error status of the preview HTML', async () => { await prepareFixtures() - await mountSession('html-status', '/__preview__/load-error-broken.ts') + await mountSession('html-status', previewPath('/load-error-broken.ts')) const report = await waitForLoadError('html-status') @@ -1296,7 +1306,7 @@ if (import.meta.hot) { id: 'html-status', stage: 'html', status: 500, - url: `${serverUrl}/__preview__/load-error-broken.ts` + url: `${serverUrl}${previewPath('/load-error-broken.ts')}` }) expect(report.message).toContain('500') }) @@ -1305,7 +1315,7 @@ if (import.meta.hot) { await prepareFixtures() // The SPA fallback answers other missing paths with index.html, and removing index.html would // break the other sessions. The fallback skips /favicon.ico, so its request ends in a 404. - await mountSession('html-missing', '/__preview__/favicon.ico') + await mountSession('html-missing', previewPath('/favicon.ico')) const report = await waitForLoadError('html-missing') @@ -1334,33 +1344,33 @@ if (import.meta.hot) { test('reports a missing entry module', async () => { await prepareFixtures() - await mountSession('missing-entry', '/__preview__/load-error-missing-entry.html') + await mountSession('missing-entry', previewPath('/load-error-missing-entry.html')) const report = await waitForLoadError('missing-entry') expect(report).toMatchObject({ id: 'missing-entry', stage: 'script', - url: `${serverUrl}/__preview__/load-error-missing.js` + url: `${serverUrl}${previewPath('/load-error-missing.js')}` }) }) test('reports a module graph that fails to load', async () => { await prepareFixtures() - await mountSession('broken-graph', '/__preview__/load-error-graph.html') + await mountSession('broken-graph', previewPath('/load-error-graph.html')) const report = await waitForLoadError('broken-graph') expect(report).toMatchObject({ id: 'broken-graph', stage: 'script', - url: `${serverUrl}/__preview__/load-error-entry.js` + url: `${serverUrl}${previewPath('/load-error-entry.js')}` }) }) test('reports a classic script that fails to load', async () => { await prepareFixtures() - await mountSession('classic', '/__preview__/load-error-classic.html') + await mountSession('classic', previewPath('/load-error-classic.html')) const report = await waitForLoadError('classic') @@ -1385,23 +1395,26 @@ if (import.meta.hot) { test('loads after the input is fixed and the session reloads', async () => { await prepareFixtures() - await mountSession('fixable', '/__preview__/load-error-fixable.html') + await mountSession('fixable', previewPath('/load-error-fixable.html')) await waitForLoadError('fixable') fixtureFiles['/load-error-fixable.js'] = 'globalThis.__vrowzerLoadErrorFixed = true\n' await addPreviewFiles({ '/load-error-fixable.js': fixtureFiles['/load-error-fixable.js'] }) await waitForPreviewBodyContaining('/load-error-fixable.js', '__vrowzerLoadErrorFixed') await page.evaluate( - ({ previewUrlLiteral }) => { + ({ previewUrlLiteral, fixtureUrl }) => { const session = (window as any).__vrowzer__.getSession('fixable') // reload() renews the load token; point the new document at the same fixture session.reload() session.iframe.srcdoc = session.iframe.srcdoc.replaceAll( previewUrlLiteral, - JSON.stringify('/__preview__/load-error-fixable.html') + JSON.stringify(fixtureUrl) ) }, - { previewUrlLiteral: PREVIEW_URL_LITERAL } + { + previewUrlLiteral: previewUrlLiteral(), + fixtureUrl: previewPath('/load-error-fixable.html') + } ) await waitForScriptGlobal('fixable', '__vrowzerLoadErrorFixed') @@ -1497,7 +1510,7 @@ if (import.meta.hot) { await addPreviewFiles({ [filePath]: content }) await waitForPreviewBodyContaining(`${filePath}?import`, 'preview-owned') - const requestUrl = `${serverUrl}/__preview__${filePath}?import&probe=${Date.now()}` + const requestUrl = `${serverUrl}${previewPath(filePath)}?import&probe=${Date.now()}` const { result, response } = await captureResponse(requestUrl, () => page.evaluate(async url => (await fetch(url)).text(), requestUrl) ) @@ -1510,19 +1523,19 @@ if (import.meta.hot) { test('answers missing files within basePath with 404 without asking the host', async () => { const hostRequestCount = hostPreviewRequests.length - const responses = await page.evaluate(async () => { + const responses = await page.evaluate(async base => { const read = async (path: string, init?: RequestInit) => { - const response = await fetch(path, init) + const response = await fetch(`${base}${path}`, init) return { status: response.status, body: await response.text() } } return { - narrowAccept: await read('/__preview__/missing-files/data.json', { + narrowAccept: await read('missing-files/data.json', { headers: { Accept: 'application/json' } }), - post: await read('/__preview__/missing-files/data.json', { method: 'POST', body: '{}' }), - favicon: await read('/__preview__/favicon.ico') + post: await read('missing-files/data.json', { method: 'POST', body: '{}' }), + favicon: await read('favicon.ico') } - }) + }, previewBase) // The SPA fallback does not answer these requests, so they end in the 404 handler expect(responses).toEqual({ @@ -1797,7 +1810,7 @@ globalThis.__vrowzerTrailingSlashWarmupUrls = warmupUrls const script = iframeDocument.createElement('script') script.type = 'module' - script.src = '/__preview__/trailing-slash-test.js' + script.src = `${(window as any).__vrowzer__.previewBasePath}trailing-slash-test.js` iframeDocument.head.append(script) }) @@ -1875,7 +1888,7 @@ globalThis.__vrowzerFsHtmlProxyResults = results const script = iframeDocument.createElement('script') script.type = 'module' - script.src = '/__preview__/fs-html-proxy-test.js' + script.src = `${(window as any).__vrowzer__.previewBasePath}fs-html-proxy-test.js` iframeDocument.head.append(script) }) @@ -1939,7 +1952,7 @@ globalThis.__vrowzerFsHtmlProxyResults = results expect(response.body).toContain('.css-resolve-url') expect(response.body).toContain( - 'url(https://assets.vrowzer.test/__preview__/css-resolve-url/bg.png)' + `url(https://assets.vrowzer.test${previewPath('/css-resolve-url/bg.png')})` ) expect(response.body).not.toContain('url(./bg.png)') }) @@ -2011,7 +2024,7 @@ globalThis.__vrowzerJsonImportResult = { data, name, nested, list } const script = iframeDocument.createElement('script') script.type = 'module' - script.src = '/__preview__/json-import/entry.js' + script.src = `${(window as any).__vrowzer__.previewBasePath}json-import/entry.js` iframeDocument.head.append(script) }) @@ -2037,7 +2050,7 @@ globalThis.__vrowzerJsonImportResult = { data, name, nested, list } describe('CSS server.origin', () => { test('applies server.origin to public URLs', async () => { - const expectedUrl = 'https://assets.vrowzer.test/__preview__/server-origin-icon.png' + const expectedUrl = `https://assets.vrowzer.test${previewPath('/server-origin-icon.png')}` await addPreviewFiles({ '/public/server-origin-icon.png': 'server origin icon', @@ -2052,7 +2065,7 @@ globalThis.__vrowzerJsonImportResult = { data, name, nested, list } expect(response.status).toBe(200) expect(response.body).toContain(expectedUrl) - expect(response.body).not.toContain("url('/__preview__/server-origin-icon.png')") + expect(response.body).not.toContain(`url('${previewPath('/server-origin-icon.png')}')`) }) }) diff --git a/packages/vrowzer/src/index-file-sync.test.ts b/packages/vrowzer/src/index-file-sync.test.ts index 0beea079..4a93a401 100644 --- a/packages/vrowzer/src/index-file-sync.test.ts +++ b/packages/vrowzer/src/index-file-sync.test.ts @@ -181,6 +181,24 @@ describe('Vrowzer file synchronization', () => { ]) }) + test('serves the project from the preview base path of the instance', async () => { + const vrowzer = Vrowzer() + const previewBasePath = vrowzer.previewBasePath + + await expect(vrowzer.ready({ files: {} })).resolves.toBe(true) + + // The Vite base of the Web Worker is the preview base path + expect(workers[0]!.messages[0]).toMatchObject({ + type: V_WW_SETUP, + config: { base: previewBasePath }, + options: { basePath: previewBasePath } + }) + // The Service Worker forwards the requests under it to the Web Worker of this instance + expect(serviceWorkerMessages()).toEqual([ + { type: V_WW_CONNECT_PORT, runtimeId: previewBasePath.split('/').at(-2) } + ]) + }) + test('gives the Web Worker a default /index.html when ready() gets none', async () => { const vrowzer = Vrowzer() diff --git a/packages/vrowzer/src/index.test.ts b/packages/vrowzer/src/index.test.ts index fa98c6ad..456e4a41 100644 --- a/packages/vrowzer/src/index.test.ts +++ b/packages/vrowzer/src/index.test.ts @@ -145,6 +145,42 @@ describe('Vrowzer factory', () => { }) }) +describe('Vrowzer preview base path', () => { + test('gives each instance its own preview base path under basePath', () => { + const first = Vrowzer() + const second = Vrowzer() + const custom = Vrowzer({ basePath: '/app/__preview__' }) + + expect(first.previewBasePath).toMatch(/^\/__preview__\/[0-9a-f]{12}\/$/) + expect(second.previewBasePath).toMatch(/^\/__preview__\/[0-9a-f]{12}\/$/) + expect(second.previewBasePath).not.toBe(first.previewBasePath) + expect(custom.previewBasePath).toMatch(/^\/app\/__preview__\/[0-9a-f]{12}\/$/) + }) + + test('keeps the preview base path, which cannot be changed, after dispose()', async () => { + const vrowzer = Vrowzer() + const previewBasePath = vrowzer.previewBasePath + + expect(() => { + ;(vrowzer as { previewBasePath: string }).previewBasePath = '/__preview__/other/' + }).toThrow(TypeError) + await vrowzer.dispose() + + expect(vrowzer.previewBasePath).toBe(previewBasePath) + }) + + test('loads the previews from the preview base path', () => { + setupDocument() + const vrowzer = Vrowzer() + + const session = vrowzer.mount(createContainer(), { id: 'desktop' }) + + expect(getTestIframe(session.iframe).srcdoc).toContain( + `const previewUrl = ${JSON.stringify(vrowzer.previewBasePath)};` + ) + }) +}) + describe('Vrowzer preview sessions', () => { test('mounts and returns a frozen preview session', () => { setupDocument() diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index d75ba543..6f735067 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -321,6 +321,18 @@ export type VrowzerEventMap = SvcWorkerControllerEventMap & { * The main interface for the Vrowzer preview environment. */ export interface Vrowzer extends Emittable { + /** + * The base path of the previews of this instance: {@link VrowzerOptions.basePath} followed by an + * ID of the instance, e.g. `/__preview__/0123456789ab/`. + * + * The previews load from it, and it is the Vite `base` of the project, which + * `import.meta.env.BASE_URL` returns in the preview. The Service Worker forwards the requests + * under it to the Web Worker of this instance, so several instances can share one Service Worker, + * e.g. in two tabs. + * + * It is set when {@link Vrowzer} is called, and stays the same after {@link Vrowzer.dispose}. + */ + readonly previewBasePath: string /** * Ready for preview system initialization. * @@ -531,6 +543,15 @@ function describeFileOperation(operation: FileOperation, path: string): string { return `[Vrowzer] ${operation}(${JSON.stringify(path)})` } +/** + * Creates the ID of a runtime instance, which names its previews: 12 hexadecimal digits from 48 + * random bits. + */ +function createRuntimeId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(6)) + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') +} + /** * Copies the files given to `ready()`, so that later changes by the caller do not reach the * Web Worker. ArrayBuffers are copied as well, and the caller's buffers are never transferred. @@ -635,9 +656,10 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { const controllerSubscriptions: (() => void)[] = [] // File operations waiting for the Web Worker to acknowledge them, by operation id const pendingFileOperations = new Map() - // 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 owner of the previews and the Web Worker channel of this instance. The previews load from + // its own base path, so that several instances can share one Service Worker. + const runtimeId = createRuntimeId() + const previewBasePath = `${resolved.basePath}${runtimeId}/` // The Service Worker instance that has the Web Worker channel of this instance let serviceWorkerInstanceId: string | null = null // A Service Worker instance that started while ready() was in progress @@ -1168,7 +1190,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } record.loadToken = crypto.randomUUID() record.session.iframe.srcdoc = createBootstrapHtml( - resolved.basePath, + previewBasePath, record.context, record.loadToken ) @@ -1184,7 +1206,6 @@ 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) @@ -1247,7 +1268,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { type: V_WW_SETUP, config: { root: '/', - base: resolved.basePath, + base: previewBasePath, publicDir: 'public', optimizeDeps: { disabled: true }, experimental: { @@ -1256,7 +1277,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { bundledDev: false } }, - options: { basePath: resolved.basePath }, + options: { basePath: previewBasePath }, files }) }, @@ -1386,6 +1407,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } const instance: Vrowzer = { + previewBasePath, on: _emitter.on, off: _emitter.off, once: _emitter.once, @@ -1461,7 +1483,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { listenPreviewMessages() // srcdoc bootstrap: fetch preview HTML via Service Worker - iframe.srcdoc = createBootstrapHtml(resolved.basePath, context, record.loadToken) + iframe.srcdoc = createBootstrapHtml(previewBasePath, context, record.loadToken) return session }, From 3ae4f8f738f75f5ad7ddf962053cf12279f1ffc7 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 12:57:35 +0900 Subject: [PATCH 07/10] fix(vite-dev-server): route requests and HMR ports to the owning Web Worker The Service Worker kept one Web Worker channel, the last one connected, so two runtimes that share it, e.g. in two tabs, got each other's previews, transforms and HMR updates (#45). The Service Worker now keeps the Web Worker channels by runtime ID, from V_WW_CONNECT_PORT, and routes by the ID in the preview URL: - A request goes to the Web Worker of the runtime named by the first path segment within the base path, e.g. /__preview__/0123456789ab/. - A request for a runtime that has not connected waits up to ownerWaitTimeout, e.g. right after a restart, then gets a 503. A request without a runtime ID in its URL gets a 404 right away. - A new channel of the same runtime replaces and closes its previous one. - The HMR client sends the Vite base of the preview with vite:mc:init, and the Service Worker forwards the port to the Web Worker of that runtime. A port without a runtime in its base is closed. - V_WW_CONNECT_PORT_ACK names the runtime, and a runtime takes only its own acknowledgement, since runtimes in the same page receive each other's. A connection without a valid runtime ID is closed. --- packages/vite-dev-server/src/client/client.ts | 2 + .../src/node/service-worker.test.ts | 192 +++++++++++++----- .../src/node/service-worker.ts | 148 ++++++++++---- .../vite-dev-server/src/shared/messages.ts | 20 +- .../src/shared/moduleRunnerTransport.test.ts | 26 +++ .../src/shared/moduleRunnerTransport.ts | 10 +- packages/vrowzer/src/index-dispose.test.ts | 5 +- packages/vrowzer/src/index-file-sync.test.ts | 3 +- packages/vrowzer/src/index-recovery.test.ts | 45 +++- packages/vrowzer/src/index.ts | 5 +- 10 files changed, 361 insertions(+), 95 deletions(-) diff --git a/packages/vite-dev-server/src/client/client.ts b/packages/vite-dev-server/src/client/client.ts index 8ed35fac..1dd582f6 100644 --- a/packages/vite-dev-server/src/client/client.ts +++ b/packages/vite-dev-server/src/client/client.ts @@ -54,8 +54,10 @@ const transport = normalizeModuleRunnerTransport( throw new Error('No active Service Worker controller found for HMR transport') } + // The base names the runtime that owns this preview, and its Web Worker gets the port let transport = createMessageChannelModuleRunnerTransport(navigator.serviceWorker.controller.postMessage.bind(navigator.serviceWorker.controller), { pingInterval: hmrTimeout, + base, }) return { diff --git a/packages/vite-dev-server/src/node/service-worker.test.ts b/packages/vite-dev-server/src/node/service-worker.test.ts index aa915db2..6d5840e6 100644 --- a/packages/vite-dev-server/src/node/service-worker.test.ts +++ b/packages/vite-dev-server/src/node/service-worker.test.ts @@ -59,6 +59,9 @@ vi.mock('@vrowzer/service-worker-server', () => ({ import { createServer } from './service-worker' const ORIGIN = 'https://vrowzer.test' +// Runtime IDs, which the previews of each owner have in their URLs +const OWNER_A = '0123456789ab' +const OWNER_B = 'ba9876543210' interface WindowClient { id: string @@ -106,6 +109,10 @@ function dispatchFetch(path: string, init?: RequestInit): FetchResult { return result } +function previewPath(owner: string, path: string): string { + return `/__preview__/${owner}${path}` +} + function bytes(text: string): ArrayBuffer { return new TextEncoder().encode(text).buffer as ArrayBuffer } @@ -120,16 +127,23 @@ interface ConnectedWorker { hmrPorts: { clientId?: string, port: MessagePort }[] } +interface ConnectOptions { + runtimeId?: string + clientId?: string + handleRequest?: ConnectedWorker['handleRequest'] +} + /** - * Connects a Web Worker as the runtime does: the client sends `V_WW_CONNECT_PORT` with one end of - * a channel, and the Web Worker completes the handshake on the other end. + * Connects a Web Worker as the runtime does: the client sends `V_WW_CONNECT_PORT` with its runtime + * ID and one end of a channel, and the Web Worker completes the handshake on the other end. */ -async function connectWorker( +async function connectWorker({ + runtimeId = OWNER_A, clientId = 'host-1', handleRequest = vi.fn<(request: SerializedRequest) => Promise>( - async () => okResponse('answer'), + async () => okResponse(`answer of ${runtimeId}`), ), -): Promise { +}: ConnectOptions = {}): Promise { const channel = new MessageChannel() openPorts.push(channel.port1, channel.port2) const worker: ConnectedWorker = { port: channel.port2, handleRequest, hmrPorts: [] } @@ -156,7 +170,7 @@ async function connectWorker( } }) fakeServer().emit('connection', { - data: { type: 'V_WW_CONNECT_PORT' }, + data: { type: 'V_WW_CONNECT_PORT', runtimeId }, ports: [channel.port1], clientId, }) @@ -165,6 +179,23 @@ async function connectWorker( return worker } +/** + * Sends the HMR port of a preview, as `@vite/client` does with `vite:mc:init`. + * + * @returns A spy on `close()` of the sent port + */ +function sendHmrPort(base: string | undefined, clientId = 'preview-1') { + const channel = new MessageChannel() + openPorts.push(channel.port1, channel.port2) + const close = vi.spyOn(channel.port1, 'close') + fakeServer().emit('connection', { + data: { type: 'vite:mc:init', clientId, ...(base === undefined ? {} : { base }) }, + ports: [channel.port1], + clientId, + }) + return close +} + async function isPending(promise: Promise): Promise { let settled = false promise.then( @@ -179,7 +210,10 @@ async function isPending(promise: Promise): Promise { beforeEach(() => { svcMocks.server = null - clients = [{ id: 'host-1', postMessage: vi.fn<(message: unknown) => void>() }] + clients = [ + { id: 'host-1', postMessage: vi.fn<(message: unknown) => void>() }, + { id: 'host-2', postMessage: vi.fn<(message: unknown) => void>() }, + ] openPorts = [] }) @@ -199,7 +233,7 @@ describe('Service Worker request forwarding', () => { expect(fakeServer().listenOptions).toMatchObject({ enableListenConnections: true }) }) - test('forwards a request within the base path to the Web Worker and answers with its response', async () => { + test('forwards a request to the Web Worker of the owner in its URL and answers with its response', async () => { await startServer() const worker = await connectWorker() worker.handleRequest.mockResolvedValueOnce({ @@ -209,13 +243,13 @@ describe('Service Worker request forwarding', () => { body: bytes('export default 1'), }) - const { response } = dispatchFetch('/__preview__/src/main.ts?t=1', { + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts?t=1'), { headers: { accept: 'text/javascript' }, }) const answer = await response! expect(worker.handleRequest).toHaveBeenCalledExactlyOnceWith({ - url: `${ORIGIN}/__preview__/src/main.ts?t=1`, + url: `${ORIGIN}/__preview__/${OWNER_A}/src/main.ts?t=1`, method: 'GET', headers: [['accept', 'text/javascript']], body: null, @@ -227,11 +261,31 @@ describe('Service Worker request forwarding', () => { expect(await answer.text()).toBe('export default 1') }) + test('keeps the requests and responses of two owners apart', async () => { + await startServer() + const workerA = await connectWorker({ runtimeId: OWNER_A, clientId: 'host-1' }) + const workerB = await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) + + const [answerA, answerB, answerA2] = await Promise.all([ + dispatchFetch(previewPath(OWNER_A, '/index.html')).response!, + dispatchFetch(previewPath(OWNER_B, '/index.html')).response!, + dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response!, + ]) + + expect(await answerA.text()).toBe(`answer of ${OWNER_A}`) + expect(await answerB.text()).toBe(`answer of ${OWNER_B}`) + expect(await answerA2.text()).toBe(`answer of ${OWNER_A}`) + expect(workerA.handleRequest.mock.calls.map(([request]) => new URL(request.url).pathname)) + .toEqual([`/__preview__/${OWNER_A}/index.html`, `/__preview__/${OWNER_A}/src/main.ts`]) + expect(workerB.handleRequest.mock.calls.map(([request]) => new URL(request.url).pathname)) + .toEqual([`/__preview__/${OWNER_B}/index.html`]) + }) + test('adds the cross-origin isolation headers to the response', async () => { await startServer() await connectWorker() - const answer = await dispatchFetch('/__preview__/').response! + const answer = await dispatchFetch(previewPath(OWNER_A, '/')).response! expect(answer.headers.get('Cross-Origin-Resource-Policy')).toBe('same-origin') expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') @@ -242,7 +296,7 @@ describe('Service Worker request forwarding', () => { await startServer() const worker = await connectWorker() - await dispatchFetch('/__preview__/api', { method: 'POST', body: 'payload' }).response + await dispatchFetch(previewPath(OWNER_A, '/api'), { method: 'POST', body: 'payload' }).response const [request] = worker.handleRequest.mock.calls[0]! expect(request.method).toBe('POST') @@ -254,7 +308,7 @@ describe('Service Worker request forwarding', () => { const worker = await connectWorker() worker.handleRequest.mockResolvedValueOnce({ status: 304, statusText: '', headers: [], body: null }) - const answer = await dispatchFetch('/__preview__/src/main.ts').response! + const answer = await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response! expect(answer.status).toBe(304) expect(answer.body).toBeNull() @@ -265,7 +319,19 @@ describe('Service Worker request forwarding', () => { const worker = await connectWorker() expect(dispatchFetch('/index.html').response).toBeUndefined() - expect(dispatchFetch('/__preview__other/main.js').response).toBeUndefined() + expect(dispatchFetch(`/__preview__other/${OWNER_A}/main.js`).response).toBeUndefined() + expect(worker.handleRequest).not.toHaveBeenCalled() + }) + + test('answers a request without an owner in its URL with 404 right away', async () => { + await startServer() + const worker = await connectWorker() + + for (const path of ['/__preview__/', '/__preview__/main.js', '/__preview__/src/main.js', `/__preview__/${OWNER_A}`]) { + const answer = await dispatchFetch(path).response! + expect(answer.status).toBe(404) + expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + } expect(worker.handleRequest).not.toHaveBeenCalled() }) @@ -275,56 +341,85 @@ describe('Service Worker request forwarding', () => { const worker = await connectWorker() worker.handleRequest.mockRejectedValueOnce(new Error('handler failed')) - const answer = await dispatchFetch('/__preview__/src/main.ts').response! + const answer = await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response! expect(answer.status).toBe(500) expect(await answer.text()).toContain('handler failed') expect(answer.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') }) - test('tells the client that sent the port that the connection is established', async () => { + test('tells the client that sent the port that the connection of its runtime is established', async () => { await startServer() - await connectWorker('host-1') + await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) await vi.waitFor(() => { - expect(clients[0]!.postMessage).toHaveBeenCalledExactlyOnceWith({ type: 'V_WW_CONNECT_PORT_ACK' }) + expect(clients[1]!.postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'V_WW_CONNECT_PORT_ACK', + runtimeId: OWNER_B, + }) }) + expect(clients[0]!.postMessage).not.toHaveBeenCalled() }) - test('forwards requests to the Web Worker that connected last', async () => { + test('replaces the channel of an owner that connects again', async () => { await startServer() const first = await connectWorker() const second = await connectWorker() - await dispatchFetch('/__preview__/src/main.ts').response + await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response expect(first.handleRequest).not.toHaveBeenCalled() expect(second.handleRequest).toHaveBeenCalledOnce() }) + + test('does not accept a connection without a valid runtime id', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) + const channel = new MessageChannel() + openPorts.push(channel.port1, channel.port2) + const close = vi.spyOn(channel.port1, 'close') + + fakeServer().emit('connection', { + data: { type: 'V_WW_CONNECT_PORT', runtimeId: 'not an id' }, + ports: [channel.port1], + clientId: 'host-1', + }) + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts')) + await vi.advanceTimersByTimeAsync(1_000) + + expect(close).toHaveBeenCalledOnce() + expect((await response!).status).toBe(503) + expect(clients[0]!.postMessage).not.toHaveBeenCalled() + }) }) -describe('Service Worker without a Web Worker', () => { - test('holds a request until a Web Worker connects', async () => { +describe('Service Worker without the Web Worker of an owner', () => { + test('holds a request until the Web Worker of its owner connects', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) - const { response } = dispatchFetch('/__preview__/src/main.ts') - await vi.advanceTimersByTimeAsync(999) + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts')) + await vi.advanceTimersByTimeAsync(500) + // Another owner does not answer it + const other = await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) + await vi.advanceTimersByTimeAsync(499) expect(await isPending(response!)).toBe(true) const worker = await connectWorker() const answer = await response! expect(answer.status).toBe(200) - expect(await answer.text()).toBe('answer') + expect(await answer.text()).toBe(`answer of ${OWNER_A}`) expect(worker.handleRequest).toHaveBeenCalledOnce() + expect(other.handleRequest).not.toHaveBeenCalled() }) - test('answers with 503 when no Web Worker connects in time', async () => { + test('answers with 503 when the owner does not connect in time', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) + await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) - const { response } = dispatchFetch('/__preview__/src/main.ts') + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts')) await vi.advanceTimersByTimeAsync(999) expect(await isPending(response!)).toBe(true) await vi.advanceTimersByTimeAsync(1) @@ -340,7 +435,7 @@ describe('Service Worker without a Web Worker', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) await startServer() - const { response } = dispatchFetch('/__preview__/src/main.ts') + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts')) await vi.advanceTimersByTimeAsync(9_999) expect(await isPending(response!)).toBe(true) await vi.advanceTimersByTimeAsync(1) @@ -350,38 +445,41 @@ describe('Service Worker without a Web Worker', () => { }) describe('Service Worker HMR ports', () => { - test('forwards the HMR port of a preview to the Web Worker', async () => { + test('forwards the HMR port of a preview to the Web Worker of the owner in its base', async () => { await startServer() - const worker = await connectWorker() - const hmrChannel = new MessageChannel() - openPorts.push(hmrChannel.port1, hmrChannel.port2) + const workerA = await connectWorker({ runtimeId: OWNER_A, clientId: 'host-1' }) + const workerB = await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) - fakeServer().emit('connection', { - data: { type: 'vite:mc:init', clientId: 'preview-1' }, - ports: [hmrChannel.port1], - clientId: 'preview-1', - }) + sendHmrPort(`/__preview__/${OWNER_B}/`, 'preview-b') await vi.waitFor(() => { - expect(worker.hmrPorts).toHaveLength(1) + expect(workerB.hmrPorts).toHaveLength(1) }) - expect(worker.hmrPorts[0]!.clientId).toBe('preview-1') + expect(workerB.hmrPorts[0]!.clientId).toBe('preview-b') + expect(workerA.hmrPorts).toEqual([]) }) - test('forwards an HMR port once a Web Worker connects', async () => { + test('forwards an HMR port once the Web Worker of its owner connects', async () => { await startServer() - const hmrChannel = new MessageChannel() - openPorts.push(hmrChannel.port1, hmrChannel.port2) - fakeServer().emit('connection', { - data: { type: 'vite:mc:init', clientId: 'preview-1' }, - ports: [hmrChannel.port1], - clientId: 'preview-1', - }) + sendHmrPort(`/__preview__/${OWNER_A}/`) const worker = await connectWorker() await vi.waitFor(() => { expect(worker.hmrPorts).toHaveLength(1) }) }) + + test('closes an HMR port without an owner in its base', async () => { + await startServer() + await connectWorker() + + const closeWithoutBase = sendHmrPort(undefined) + const closeWithoutOwner = sendHmrPort('/__preview__/') + + await vi.waitFor(() => { + expect(closeWithoutBase).toHaveBeenCalledOnce() + expect(closeWithoutOwner).toHaveBeenCalledOnce() + }) + }) }) diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index 27f5bf00..84cdcd46 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -1,9 +1,13 @@ /** * Service Worker entry point for @vrowzer/vite-dev-server * - * The Service Worker forwards each request within the base path to the Web Worker, which has the - * project files, the module graph and the plugins, and answers the request with the Vite - * middlewares. It also forwards the HMR ports of the previews to the Web Worker. + * The Service Worker forwards each request within the base path to the Web Worker that owns it, + * which has the project files, the module graph and the plugins, and answers the request with the + * Vite middlewares. It also forwards the HMR ports of the previews to their Web Workers. + * + * Several runtimes, e.g. in two tabs, can share the Service Worker. Each runtime connects its Web + * Worker with its runtime ID, and its previews have the ID as the first path segment within the + * base path, e.g. `/__preview__/0123456789ab/src/main.ts`. * * The Service Worker does not resolve the Vite config, and has neither plugins nor files. * @@ -64,11 +68,11 @@ export interface CreateServerOptions { */ basePath?: string /** - * How long a request waits for a Web Worker to connect, in milliseconds. A request that no Web - * Worker can answer by then gets a `503` response. + * How long a request waits for the Web Worker of its owner to connect, in milliseconds. A request + * that no Web Worker can answer by then gets a `503` response. * - * A restarted Service Worker has no Web Worker channel until the runtime connects one again, so - * the requests made in the meantime wait for it. + * A restarted Service Worker has no Web Worker channels until the runtimes connect them again, so + * the requests made in the meantime wait for them. * * @default 10_000 */ @@ -90,7 +94,7 @@ export interface ServiceWorkerServer { } /** - * A channel with a Web Worker, after the handshake. + * A channel with the Web Worker of a runtime, after the handshake. */ interface WorkerChannel { port: MessagePort @@ -99,6 +103,37 @@ interface WorkerChannel { const DEFAULT_OWNER_WAIT_TIMEOUT = 10_000 +/** + * The ID of a runtime, which owns a Web Worker channel and the previews under its path. + * + * @see {@link ConnectWebWorkerPortMessage.runtimeId} + */ +const RUNTIME_ID_PATTERN = /^[0-9a-f]{12}$/ + +function isRuntimeId(value: unknown): value is string { + return typeof value === 'string' && RUNTIME_ID_PATTERN.test(value) +} + +/** + * Returns the runtime that a path within the base path names as its first segment, e.g. + * `0123456789ab` for `/__preview__/0123456789ab/src/main.ts`, or `undefined` when it names none. + * + * @param pathname - A URL path, or the Vite base of a preview + * @param baseRoot - The base path, with a trailing slash + */ +function ownerOfPath(pathname: string, baseRoot: string): string | undefined { + if (!pathname.startsWith(baseRoot)) { + return undefined + } + const rest = pathname.slice(baseRoot.length) + const end = rest.indexOf('/') + if (end === -1) { + return undefined + } + const owner = rest.slice(0, end) + return isRuntimeId(owner) ? owner : undefined +} + /** * Create the Service Worker side of the dev server. * @@ -114,41 +149,63 @@ export function createServer( options: CreateServerOptions = {}, ): () => Promise { const basePath = options.basePath || '/' + const baseRoot = basePath.endsWith('/') ? basePath : `${basePath}/` const ownerWaitTimeout = options.ownerWaitTimeout ?? DEFAULT_OWNER_WAIT_TIMEOUT const workerOrigin = new URL(serviceWorkerScope.location.href).origin - // The channel with the Web Worker that answers the requests. A later connection replaces it. - let workerChannel: WorkerChannel | null = null - // Requests and HMR ports waiting for a Web Worker to connect - const workerWaiters = new Set<(channel: WorkerChannel) => void>() + // The Web Worker channels, by the runtime that owns them + const owners = new Map() + // Requests and HMR ports waiting for the Web Worker of a runtime to connect + const ownerWaiters = new Map void>>() /** - * Wait for a Web Worker to connect, for `ownerWaitTimeout` at most. + * Wait for the Web Worker of `owner` to connect, for `ownerWaitTimeout` at most. * - * @returns The channel, or `null` when no Web Worker connected in time + * @returns The channel, or `null` when the Web Worker did not connect in time */ - function waitForWorker(): Promise { - if (workerChannel) { - return Promise.resolve(workerChannel) + function waitForOwner(owner: string): Promise { + const connected = owners.get(owner) + if (connected) { + return Promise.resolve(connected) } return new Promise((resolve) => { + let waiters = ownerWaiters.get(owner) + if (!waiters) { + waiters = new Set() + ownerWaiters.set(owner, waiters) + } const onConnect = (channel: WorkerChannel) => { clearTimeout(timer) resolve(channel) } const timer = setTimeout(() => { - workerWaiters.delete(onConnect) + const current = ownerWaiters.get(owner) + current?.delete(onConnect) + if (current?.size === 0) { + ownerWaiters.delete(owner) + } resolve(null) }, ownerWaitTimeout) - workerWaiters.add(onConnect) + waiters.add(onConnect) }) } - function connectWorker(channel: WorkerChannel): void { - workerChannel = channel - const waiters = [...workerWaiters] - workerWaiters.clear() - for (const waiter of waiters) { + function closeChannel(channel: WorkerChannel, reason: string): void { + // Reject the requests still waiting for this channel, whose replies never come + channel.rpc.$close(new Error(`[@vrowzer/vite-dev-server] ${reason}`)) + channel.port.close() + } + + function connectOwner(owner: string, channel: WorkerChannel): void { + const previous = owners.get(owner) + owners.set(owner, channel) + // The runtime connected its Web Worker again, which has closed the previous port + if (previous) { + closeChannel(previous, `The Web Worker channel of ${owner} was replaced`) + } + const waiters = ownerWaiters.get(owner) + ownerWaiters.delete(owner) + for (const waiter of waiters ?? []) { waiter(channel) } } @@ -168,9 +225,14 @@ export function createServer( // Cross-origin isolation headers (CORP/COEP/COOP) for credentialless iframe + SW middlewares.use(crossOriginMiddleware()) - // The Web Worker answers the request with the Vite middlewares + // The Web Worker of the runtime named in the URL answers the request with the Vite middlewares middlewares.use(async (c) => { - const channel = await waitForWorker() + const owner = ownerOfPath(c.req.path, baseRoot) + if (owner === undefined) { + // A preview URL names the runtime that owns it, so nothing answers this one + return c.body(null, 404) + } + const channel = await waitForOwner(owner) if (!channel) { return c.text( `[@vrowzer/vite-dev-server] The preview is not ready: no Web Worker connected within ${ownerWaitTimeout}ms.`, @@ -227,8 +289,15 @@ export function createServer( // V_WW_CONNECT_PORT: birpc handshake with Web Worker if (event.data.type === 'V_WW_CONNECT_PORT' && event.ports[0]) { const port = event.ports[0] + const runtimeId = event.data.runtimeId const clientId = event.clientId - debug?.('Worker port received via connection event') + // Without its runtime ID, no request can be forwarded to the Web Worker + if (!isRuntimeId(runtimeId)) { + debug?.('Worker port without a valid runtime ID', runtimeId) + port.close() + return + } + debug?.('Worker port received via connection event', runtimeId) // Phase 1: Handshake — wait for WW's channel-ready before creating birpc port.onmessage = async (e: MessageEvent) => { @@ -248,14 +317,15 @@ export function createServer( }, ) - // Requests can be forwarded again. The waiting ones go on before the client hears that - // the connection is established. - connectWorker({ port, rpc }) + // The requests of the runtime can be forwarded now. The waiting ones go on before the + // client hears that the connection is established. + connectOwner(runtimeId, { port, rpc }) - // Notify the originating client that the connection is established + // Notify the originating client that the connection is established. Runtimes in the same + // page receive each other's acknowledgements, so it names the runtime. if (clientId) { const client = await serviceWorkerScope.clients.get(clientId) - client?.postMessage({ type: 'V_WW_CONNECT_PORT_ACK' }) + client?.postMessage({ type: 'V_WW_CONNECT_PORT_ACK', runtimeId }) } debug?.('Worker RPC established via birpc') @@ -264,12 +334,18 @@ export function createServer( return } - // vite:mc:init: iframe HMR port — forward to WW via the birpc MessagePort + // vite:mc:init: iframe HMR port — forward to the WW of the runtime named in the Vite base if (event.data.type === 'vite:mc:init' && event.ports[0]) { const hmrPort = event.ports[0] - const hmrClientId = event.data.clientId - debug?.('HMR port received from iframe, forwarding to WW', hmrClientId) - void waitForWorker().then((channel) => { + const { base, clientId: hmrClientId } = event.data + const owner = typeof base === 'string' ? ownerOfPath(base, baseRoot) : undefined + if (owner === undefined) { + debug?.('HMR port without the base of its runtime', hmrClientId, base) + hmrPort.close() + return + } + debug?.('HMR port received from iframe, forwarding to WW', owner, hmrClientId) + void waitForOwner(owner).then((channel) => { if (!channel) { hmrPort.close() return diff --git a/packages/vite-dev-server/src/shared/messages.ts b/packages/vite-dev-server/src/shared/messages.ts index dd4371db..106d5f85 100644 --- a/packages/vite-dev-server/src/shared/messages.ts +++ b/packages/vite-dev-server/src/shared/messages.ts @@ -82,10 +82,14 @@ 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. + * ID of the runtime that owns the channel: 12 lowercase hexadecimal digits. + * + * The previews of the runtime have it as the first path segment within the base path, e.g. + * `/__preview__/0123456789ab/`. The Service Worker forwards their requests and HMR ports to this + * channel, so several runtimes can share one Service Worker. A later channel of the same runtime + * replaces this one. */ - runtimeId?: string + runtimeId: string } /** @@ -115,6 +119,11 @@ export interface WebWorkerServiceWorkerChannelReadyMessage { */ export interface ConnectWebWorkerPortAckMessage { type: 'V_WW_CONNECT_PORT_ACK' + /** + * ID of the runtime whose channel is established. Runtimes in the same page receive each other's + * acknowledgements, so each one takes only its own. + */ + runtimeId: string } /** @@ -147,6 +156,11 @@ export interface ViteMessageChannelInitMessage { * Client ID of the iframe sending the HMR */ clientId?: string + /** + * The Vite base of the preview, e.g. `/__preview__/0123456789ab/`. It names the runtime that owns + * the preview, and the Service Worker forwards the port to the Web Worker of that runtime. + */ + base?: string } // ---- Service Worker listen readiness protocol ---- diff --git a/packages/vite-dev-server/src/shared/moduleRunnerTransport.test.ts b/packages/vite-dev-server/src/shared/moduleRunnerTransport.test.ts index 64e99f7e..4bd6f631 100644 --- a/packages/vite-dev-server/src/shared/moduleRunnerTransport.test.ts +++ b/packages/vite-dev-server/src/shared/moduleRunnerTransport.test.ts @@ -102,6 +102,32 @@ describe('createMessageChannelModuleRunnerTransport', () => { expect.objectContaining({ type: 'vite:mc:init' }), [mockPort2] ) + expect(mockPostMessage.mock.calls[0]![0]).not.toHaveProperty('base') + }) + + it('names the Vite base of the preview, which tells the Service Worker its Web Worker', async () => { + const mockPostMessage = vi.fn<(message: unknown, transfer: Transferable[]) => void>() + const transport = createMessageChannelModuleRunnerTransport(mockPostMessage, { + timeout: 100, + pingInterval: 0, + base: '/__preview__/0123456789ab/', + }) + + const connectPromise = transport.connect({ + onMessage: vi.fn<(payload: unknown) => void>(), + onDisconnection: vi.fn<() => void>(), + }) + simulateInitEcho(mockPostMessage) + await connectPromise + + expect(mockPostMessage).toHaveBeenCalledWith( + { + type: 'vite:mc:init', + clientId: expect.any(String), + base: '/__preview__/0123456789ab/', + }, + [mockPort2] + ) }) it('waits for connect confirmation message and resolves', async () => { diff --git a/packages/vite-dev-server/src/shared/moduleRunnerTransport.ts b/packages/vite-dev-server/src/shared/moduleRunnerTransport.ts index 224a2b50..95b2731f 100644 --- a/packages/vite-dev-server/src/shared/moduleRunnerTransport.ts +++ b/packages/vite-dev-server/src/shared/moduleRunnerTransport.ts @@ -295,6 +295,10 @@ export const createMessageChannelModuleRunnerTransport = ( options: { pingInterval?: number timeout?: number + /** + * The Vite base of the preview, which tells the Service Worker the Web Worker to connect to + */ + base?: string } = {} ): Required< Pick @@ -317,7 +321,11 @@ export const createMessageChannelModuleRunnerTransport = ( // Transfer `port2` to host via `postMessage` console.log(`[vrowzer] ${clientId} is connecting via MessageChannel...`) postMessage( - { type: MC_INIT_EVENT, clientId }, + { + type: MC_INIT_EVENT, + clientId, + ...(options.base === undefined ? {} : { base: options.base }), + } satisfies ViteMessageChannelInitMessage, [channel.port2] ) diff --git a/packages/vrowzer/src/index-dispose.test.ts b/packages/vrowzer/src/index-dispose.test.ts index ef3f6248..b06ccb41 100644 --- a/packages/vrowzer/src/index-dispose.test.ts +++ b/packages/vrowzer/src/index-dispose.test.ts @@ -206,7 +206,10 @@ async function readyFully(vrowzer: ReturnType): Promise { ) controllerMocks.postMessage.mockImplementation(message => { if (message.type === V_WW_CONNECT_PORT) { + const { runtimeId } = message as { runtimeId?: string } queueMicrotask(() => { controllerMocks.container.dispatchEvent( - new MessageEvent('message', { data: { type: V_WW_CONNECT_PORT_ACK } }) + new MessageEvent('message', { data: { type: V_WW_CONNECT_PORT_ACK, runtimeId } }) ) }) } diff --git a/packages/vrowzer/src/index-recovery.test.ts b/packages/vrowzer/src/index-recovery.test.ts index 608b316b..26564bf6 100644 --- a/packages/vrowzer/src/index-recovery.test.ts +++ b/packages/vrowzer/src/index-recovery.test.ts @@ -85,6 +85,15 @@ function startServiceWorkerInstance(instanceId: string): void { fromServiceWorker({ type: V_SW_INSTANCE_STARTED, instanceId }) } +/** + * Completes the Service Worker's side of the latest channel handshake, which it did not answer by + * itself. The acknowledgement names the runtime of the channel. + */ +function acceptChannelInServiceWorker(): void { + const [connection] = serviceWorkerMessages(V_WW_CONNECT_PORT).slice(-1) + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK, runtimeId: connection!.runtimeId }) +} + function serviceWorkerMessages(type?: string): TestMessage[] { return controllerMocks.postMessage.mock.calls .map(([message]) => message) @@ -133,7 +142,9 @@ beforeEach(() => { ) controllerMocks.postMessage.mockImplementation(message => { if (message.type === V_WW_CONNECT_PORT && replies.channel) { - queueMicrotask(() => fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK })) + queueMicrotask(() => + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK, runtimeId: message.runtimeId }) + ) } }) }) @@ -144,6 +155,32 @@ afterEach(() => { vi.unstubAllGlobals() }) +describe('Vrowzer channel handshake', () => { + test('takes only the acknowledgement of its own runtime', async () => { + replies.channel = false + const vrowzer = Vrowzer() + let settled = false + const ready = vrowzer.ready({ files: {} }).finally(() => { + settled = true + }) + await vi.waitFor(() => { + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(1) + }) + workers.at(-1)!.acceptChannel() + + // Another runtime in the same page + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK, runtimeId: 'ba9876543210' }) + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + await flush() + expect(settled).toBe(false) + + const runtimeId = vrowzer.previewBasePath.split('/').at(-2) + expect(serviceWorkerMessages(V_WW_CONNECT_PORT)[0]!.runtimeId).toBe(runtimeId) + fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK, runtimeId }) + await expect(ready).resolves.toBe(true) + }) +}) + describe('Vrowzer Service Worker recovery', () => { describe('detection', () => { test('connects the Web Worker channel again when a Service Worker instance with another id starts', async () => { @@ -241,7 +278,7 @@ describe('Vrowzer Service Worker recovery', () => { expect(fileChanges(serviceWorkerMessages())).toEqual([]) expect(recovered).not.toHaveBeenCalled() - fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + acceptChannelInServiceWorker() worker.acceptChannel() await vi.waitFor(() => { expect(recovered).toHaveBeenCalledOnce() @@ -300,7 +337,7 @@ describe('Vrowzer Service Worker recovery', () => { expect(serviceWorkerMessages(V_WW_CONNECT_PORT)).toHaveLength(3) expect(webWorkerMessages(worker, V_SW_CONNECT_PORT)).toHaveLength(3) - fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + acceptChannelInServiceWorker() worker.acceptChannel() await vi.waitFor(() => { @@ -320,7 +357,7 @@ describe('Vrowzer Service Worker recovery', () => { await flush() await vrowzer.dispose() - fromServiceWorker({ type: V_WW_CONNECT_PORT_ACK }) + acceptChannelInServiceWorker() worker.acceptChannel() await flush() diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 6f735067..2bce81ef 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -931,10 +931,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { let stopWaitingForServiceWorker = () => {} let stopWaitingForWebWorker = () => {} - // Wait for Service Worker's ACK + // Wait for Service Worker's ACK. Instances in the same page receive each other's ACKs, so take + // only the one of this instance. const serviceWorkerAck = new Promise(resolve => { const handler = (event: MessageEvent) => { - if (event.data?.type === V_WW_CONNECT_PORT_ACK) { + if (event.data?.type === V_WW_CONNECT_PORT_ACK && event.data.runtimeId === runtimeId) { stopWaitingForServiceWorker() resolve() } From c44163a5c760057bb982836dc018c8c040af8271 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 13:01:02 +0900 Subject: [PATCH 08/10] feat(vrowzer): release the preview owner on dispose A disposed runtime left its Web Worker channel in the Service Worker, which forwarded the requests of its previews to a terminated Web Worker until they timed out. - dispose() sends the new V_WW_DISCONNECT_PORT with the runtime ID, once the instance has sent its Web Worker channel. The Service Worker closes the channel, and answers the requests and HMR ports of the runtime's previews with 404 and a closed port from then on. Requests that wait for the runtime to connect get the 404 as well. - A closed page cannot send the message, so when a runtime connects, the Service Worker releases the runtimes whose pages clients.matchAll() no longer lists. - A released runtime that connects again is accepted. --- .../src/node/service-worker.test.ts | 116 +++++++++++++++++- .../src/node/service-worker.ts | 92 ++++++++++++-- .../vite-dev-server/src/shared/messages.ts | 17 +++ packages/vrowzer/src/index-dispose.test.ts | 21 ++++ packages/vrowzer/src/index.ts | 21 +++- 5 files changed, 252 insertions(+), 15 deletions(-) diff --git a/packages/vite-dev-server/src/node/service-worker.test.ts b/packages/vite-dev-server/src/node/service-worker.test.ts index 6d5840e6..b31ae394 100644 --- a/packages/vite-dev-server/src/node/service-worker.test.ts +++ b/packages/vite-dev-server/src/node/service-worker.test.ts @@ -75,16 +75,32 @@ interface FetchResult { let clients: WindowClient[] let openPorts: MessagePort[] +let scopeListeners: Map +let matchAllClients: ReturnType Promise>> function createScope(): ServiceWorkerGlobalScope { return { location: { href: `${ORIGIN}/service-worker.js` }, clients: { get: async (id: string) => clients.find(client => client.id === id), + matchAll: matchAllClients, + }, + addEventListener(type: string, listener: Listener) { + scopeListeners.set(type, [...(scopeListeners.get(type) ?? []), listener]) }, } as unknown as ServiceWorkerGlobalScope } +/** + * Sends a message without ports to the Service Worker, as a page does with `postMessage()`. + */ +function postToServiceWorker(data: unknown, clientId = 'host-1'): void { + const source = clients.find(client => client.id === clientId) + for (const listener of scopeListeners.get('message') ?? []) { + listener({ data, source, ports: [] }) + } +} + function fakeServer(): FakeServer { if (!svcMocks.server) { throw new Error('createSvcWorkerServer() was not called') @@ -123,6 +139,8 @@ function okResponse(body: string, headers: [string, string][] = []): SerializedR interface ConnectedWorker { port: MessagePort + /** The end of the channel that the Service Worker has */ + serviceWorkerPort: MessagePort handleRequest: ReturnType Promise>> hmrPorts: { clientId?: string, port: MessagePort }[] } @@ -146,7 +164,12 @@ async function connectWorker({ }: ConnectOptions = {}): Promise { const channel = new MessageChannel() openPorts.push(channel.port1, channel.port2) - const worker: ConnectedWorker = { port: channel.port2, handleRequest, hmrPorts: [] } + const worker: ConnectedWorker = { + port: channel.port2, + serviceWorkerPort: channel.port1, + handleRequest, + hmrPorts: [], + } const connected = new Promise((resolve) => { channel.port2.onmessage = (event) => { if (event.data?.type !== 'V_WW_SW_CHANNEL_READY' || event.data.source !== 'sw') { @@ -213,8 +236,13 @@ beforeEach(() => { clients = [ { id: 'host-1', postMessage: vi.fn<(message: unknown) => void>() }, { id: 'host-2', postMessage: vi.fn<(message: unknown) => void>() }, + { id: 'host-3', postMessage: vi.fn<(message: unknown) => void>() }, ] openPorts = [] + scopeListeners = new Map() + matchAllClients = vi.fn<(options?: ClientQueryOptions) => Promise>( + async () => clients, + ) }) afterEach(() => { @@ -483,3 +511,89 @@ describe('Service Worker HMR ports', () => { }) }) }) + +describe('Service Worker owner release', () => { + const OWNER_C = 'c0ffee000000' + + test('answers the requests of a disposed runtime with 404, and keeps the other runtimes', async () => { + await startServer() + const workerA = await connectWorker({ runtimeId: OWNER_A, clientId: 'host-1' }) + await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) + const closeA = vi.spyOn(workerA.serviceWorkerPort, 'close') + + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT', runtimeId: OWNER_A }) + + // The Service Worker closes its end of the channel + expect(closeA).toHaveBeenCalledOnce() + const answerA = await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response! + expect(answerA.status).toBe(404) + expect(answerA.headers.get('Cross-Origin-Embedder-Policy')).toBe('require-corp') + expect(workerA.handleRequest).not.toHaveBeenCalled() + const answerB = await dispatchFetch(previewPath(OWNER_B, '/src/main.ts')).response! + expect(await answerB.text()).toBe(`answer of ${OWNER_B}`) + }) + + test('answers the waiting requests of a runtime that is disposed before it connects with 404', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await startServer({ basePath: '/__preview__/', ownerWaitTimeout: 1_000 }) + + const { response } = dispatchFetch(previewPath(OWNER_A, '/src/main.ts')) + await vi.advanceTimersByTimeAsync(100) + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT', runtimeId: OWNER_A }) + + expect((await response!).status).toBe(404) + }) + + test('closes the HMR ports of a disposed runtime', async () => { + await startServer() + await connectWorker() + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT', runtimeId: OWNER_A }) + + const close = sendHmrPort(`/__preview__/${OWNER_A}/`) + + await vi.waitFor(() => { + expect(close).toHaveBeenCalledOnce() + }) + }) + + test('ignores a release without a valid runtime id', async () => { + await startServer() + const worker = await connectWorker() + + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT', runtimeId: 'not an id' }) + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT' }) + + await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response + expect(worker.handleRequest).toHaveBeenCalledOnce() + }) + + test('releases the runtimes whose pages are closed when a runtime connects', async () => { + await startServer() + const workerA = await connectWorker({ runtimeId: OWNER_A, clientId: 'host-1' }) + await connectWorker({ runtimeId: OWNER_B, clientId: 'host-2' }) + // The tab of runtime A is closed, which sends nothing + clients = clients.filter(client => client.id !== 'host-1') + + await connectWorker({ runtimeId: OWNER_C, clientId: 'host-3' }) + + await vi.waitFor(async () => { + expect((await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response!).status).toBe(404) + }) + expect(matchAllClients).toHaveBeenCalledWith({ type: 'window', includeUncontrolled: true }) + expect(workerA.handleRequest).not.toHaveBeenCalled() + const answerB = await dispatchFetch(previewPath(OWNER_B, '/src/main.ts')).response! + expect(await answerB.text()).toBe(`answer of ${OWNER_B}`) + }) + + test('accepts a runtime that connects again after its release', async () => { + await startServer() + await connectWorker() + postToServiceWorker({ type: 'V_WW_DISCONNECT_PORT', runtimeId: OWNER_A }) + + const worker = await connectWorker() + + const answer = await dispatchFetch(previewPath(OWNER_A, '/src/main.ts')).response! + expect(answer.status).toBe(200) + expect(worker.handleRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index 84cdcd46..7d580dad 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -38,6 +38,7 @@ import type { BlankSchema } from 'hono/types' import type { StatusCode } from 'hono/utils/http-status' import type { ConnectWebWorkerPortMessage, + DisconnectWebWorkerPortMessage, ViteMessageChannelInitMessage, WebWorkerServiceWorkerChannelReadyMessage, } from '../shared/messages' @@ -99,6 +100,10 @@ export interface ServiceWorkerServer { interface WorkerChannel { port: MessagePort rpc: BirpcReturn + /** + * The page of the runtime, which sent the port + */ + clientId: string | undefined } const DEFAULT_OWNER_WAIT_TIMEOUT = 10_000 @@ -155,13 +160,16 @@ export function createServer( // The Web Worker channels, by the runtime that owns them const owners = new Map() + // Runtimes that were disposed or whose pages are closed. Their previews get 404. + const releasedOwners = new Set() // Requests and HMR ports waiting for the Web Worker of a runtime to connect - const ownerWaiters = new Map void>>() + const ownerWaiters = new Map void>>() /** * Wait for the Web Worker of `owner` to connect, for `ownerWaitTimeout` at most. * - * @returns The channel, or `null` when the Web Worker did not connect in time + * @returns The channel, or `null` when the Web Worker did not connect in time or the runtime was + * released */ function waitForOwner(owner: string): Promise { const connected = owners.get(owner) @@ -174,7 +182,7 @@ export function createServer( waiters = new Set() ownerWaiters.set(owner, waiters) } - const onConnect = (channel: WorkerChannel) => { + const onConnect = (channel: WorkerChannel | null) => { clearTimeout(timer) resolve(channel) } @@ -196,17 +204,50 @@ export function createServer( channel.port.close() } + function settleOwnerWaiters(owner: string, channel: WorkerChannel | null): void { + const waiters = ownerWaiters.get(owner) + ownerWaiters.delete(owner) + for (const waiter of waiters ?? []) { + waiter(channel) + } + } + function connectOwner(owner: string, channel: WorkerChannel): void { const previous = owners.get(owner) owners.set(owner, channel) + releasedOwners.delete(owner) // The runtime connected its Web Worker again, which has closed the previous port if (previous) { closeChannel(previous, `The Web Worker channel of ${owner} was replaced`) } - const waiters = ownerWaiters.get(owner) - ownerWaiters.delete(owner) - for (const waiter of waiters ?? []) { - waiter(channel) + settleOwnerWaiters(owner, channel) + } + + /** + * Release a runtime that was disposed or whose page is closed. Its previews get 404 from now on. + */ + function releaseOwner(owner: string, reason: string): void { + releasedOwners.add(owner) + const channel = owners.get(owner) + owners.delete(owner) + if (channel) { + closeChannel(channel, reason) + } + settleOwnerWaiters(owner, null) + } + + /** + * Release the runtimes whose pages are closed. A closed page cannot send + * `V_WW_DISCONNECT_PORT`, so this runs when another runtime connects. + */ + async function releaseClosedOwners(): Promise { + const pages = await serviceWorkerScope.clients.matchAll({ type: 'window', includeUncontrolled: true }) + const openPages = new Set(pages.map((page) => page.id)) + for (const [owner, channel] of owners) { + if (channel.clientId !== undefined && !openPages.has(channel.clientId)) { + debug?.('Releasing the runtime of a closed page', owner) + releaseOwner(owner, `The page of ${owner} is closed`) + } } } @@ -228,11 +269,15 @@ export function createServer( // The Web Worker of the runtime named in the URL answers the request with the Vite middlewares middlewares.use(async (c) => { const owner = ownerOfPath(c.req.path, baseRoot) - if (owner === undefined) { - // A preview URL names the runtime that owns it, so nothing answers this one + // A preview URL names the runtime that owns it, so nothing answers a URL without one, or one + // of a released runtime + if (owner === undefined || releasedOwners.has(owner)) { return c.body(null, 404) } const channel = await waitForOwner(owner) + if (!channel && releasedOwners.has(owner)) { + return c.body(null, 404) + } if (!channel) { return c.text( `[@vrowzer/vite-dev-server] The preview is not ready: no Web Worker connected within ${ownerWaitTimeout}ms.`, @@ -319,7 +364,10 @@ export function createServer( // The requests of the runtime can be forwarded now. The waiting ones go on before the // client hears that the connection is established. - connectOwner(runtimeId, { port, rpc }) + connectOwner(runtimeId, { port, rpc, clientId }) + void releaseClosedOwners().catch((error: unknown) => { + debug?.('Could not look for closed pages', error) + }) // Notify the originating client that the connection is established. Runtimes in the same // page receive each other's acknowledgements, so it names the runtime. @@ -339,8 +387,8 @@ export function createServer( const hmrPort = event.ports[0] const { base, clientId: hmrClientId } = event.data const owner = typeof base === 'string' ? ownerOfPath(base, baseRoot) : undefined - if (owner === undefined) { - debug?.('HMR port without the base of its runtime', hmrClientId, base) + if (owner === undefined || releasedOwners.has(owner)) { + debug?.('HMR port without the base of a connected runtime', hmrClientId, base) hmrPort.close() return } @@ -367,11 +415,27 @@ export function createServer( }, } + // V_WW_DISCONNECT_PORT: a disposed runtime releases its previews. It has no ports, so it is not a + // connection of the server. + function onMessage(event: ExtendableMessageEvent): void { + const message = event.data as Partial | null + if (message?.type !== 'V_WW_DISCONNECT_PORT') { + return + } + if (!isRuntimeId(message.runtimeId)) { + debug?.('V_WW_DISCONNECT_PORT without a valid runtime ID', message.runtimeId) + return + } + debug?.('Releasing a disposed runtime', message.runtimeId) + releaseOwner(message.runtimeId, `The runtime ${message.runtimeId} was disposed`) + } + /** * Start answering requests and accepting the Web Worker channels. */ async function listen(): Promise { httpServer.on('connection', onConnection) + serviceWorkerScope.addEventListener('message', onMessage) httpServer.listen({ enableListenConnections: true }) return server } @@ -411,8 +475,10 @@ export function createServerCloseFn( export { V_WW_CONNECT_PORT, V_WW_CONNECT_PORT_ACK, + V_WW_DISCONNECT_PORT, V_WW_SW_CHANNEL_READY } from '../shared/messages' export type { - ConnectWebWorkerPortAckMessage, ConnectWebWorkerPortMessage, WebWorkerServiceWorkerChannelReadyMessage + ConnectWebWorkerPortAckMessage, ConnectWebWorkerPortMessage, DisconnectWebWorkerPortMessage, + WebWorkerServiceWorkerChannelReadyMessage } from '../shared/messages' diff --git a/packages/vite-dev-server/src/shared/messages.ts b/packages/vite-dev-server/src/shared/messages.ts index 106d5f85..e57471c1 100644 --- a/packages/vite-dev-server/src/shared/messages.ts +++ b/packages/vite-dev-server/src/shared/messages.ts @@ -92,6 +92,22 @@ export interface ConnectWebWorkerPortMessage { runtimeId: string } +/** + * Main Thread -> Service Worker: The runtime is disposed and releases its previews. + * Sent with `postMessage()`, without ports. + * + * The Service Worker closes the Web Worker channel of the runtime, and answers the requests of its + * previews with 404 from then on. A runtime whose page is closed sends nothing, so the Service + * Worker also releases the runtimes whose pages are gone when another runtime connects. + */ +export interface DisconnectWebWorkerPortMessage { + type: 'V_WW_DISCONNECT_PORT' + /** + * ID of the runtime that is disposed. + */ + runtimeId: string +} + /** * Main Thread -> Web Worker: Accept a MessagePort for Service Worker communication. * The port is transferred via postMessage's transfer list. @@ -212,6 +228,7 @@ export const V_WW_SETUP = 'V_WW_SETUP' as const export const V_WW_SETUP_ACK = 'V_WW_SETUP_ACK' as const export const V_WW_SETUP_ERROR = 'V_WW_SETUP_ERROR' as const export const V_WW_CONNECT_PORT = 'V_WW_CONNECT_PORT' as const +export const V_WW_DISCONNECT_PORT = 'V_WW_DISCONNECT_PORT' as const export const V_SW_CONNECT_PORT = 'V_SW_CONNECT_PORT' as const export const V_WW_SW_CHANNEL_READY = 'V_WW_SW_CHANNEL_READY' as const export const V_WW_CONNECT_PORT_ACK = 'V_WW_CONNECT_PORT_ACK' as const diff --git a/packages/vrowzer/src/index-dispose.test.ts b/packages/vrowzer/src/index-dispose.test.ts index b06ccb41..3a88286e 100644 --- a/packages/vrowzer/src/index-dispose.test.ts +++ b/packages/vrowzer/src/index-dispose.test.ts @@ -349,6 +349,11 @@ describe('Vrowzer dispose', () => { expect(publisher.targets.size).toBe(0) expect(worker.terminate).toHaveBeenCalledTimes(1) expect(vi.getTimerCount()).toBe(0) + // The Service Worker may have connected the channel, so it releases the previews as well + expect(serviceWorker.postMessage).toHaveBeenLastCalledWith({ + type: 'V_WW_DISCONNECT_PORT', + runtimeId: vrowzer.previewBasePath.split('/').at(-2) + }) }) test('releases everything after a successful ready()', async () => { @@ -373,6 +378,22 @@ describe('Vrowzer dispose', () => { expect(vi.getTimerCount()).toBe(0) }) + test('releases the previews of the instance in the Service Worker', async () => { + const vrowzer = Vrowzer() + await readyFully(vrowzer) + serviceWorker.postMessage.mockClear() + + await vrowzer.dispose() + + expect(serviceWorker.postMessage).toHaveBeenCalledExactlyOnceWith({ + type: 'V_WW_DISCONNECT_PORT', + runtimeId: vrowzer.previewBasePath.split('/').at(-2) + }) + // Disposing again sends nothing more + await vrowzer.dispose() + expect(serviceWorker.postMessage).toHaveBeenCalledOnce() + }) + test('rejects the file operations that wait for the Workers', async () => { const vrowzer = Vrowzer() const worker = await readyFully(vrowzer) diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 2bce81ef..d32382b6 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -51,7 +51,8 @@ import { V_SW_CONNECT_PORT, V_WW_CONNECT_PORT_ACK, V_SW_CONNECT_PORT_ACK, - V_SW_INSTANCE_STARTED + V_SW_INSTANCE_STARTED, + V_WW_DISCONNECT_PORT } from '@vrowzer/vite-dev-server/messages' import { abortable } from './abort.ts' import { @@ -66,6 +67,7 @@ import { resolveServiceWorkerVersion, withServiceWorkerVersion } from './service import type { Emittable } from '@kazupon/jts-utils/event/emitter' import type { FileSystemPublisher } from '@vrowzer/fs/watcher' +import type { DisconnectWebWorkerPortMessage } from '@vrowzer/vite-dev-server/messages' import type { SvcWorkerControllerEventMap } from '@vrowzer/service-worker/controller' const DEFAULT_SERVICE_WORKER_READY_TIMEOUT = 60_000 @@ -660,6 +662,8 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { // its own base path, so that several instances can share one Service Worker. const runtimeId = createRuntimeId() const previewBasePath = `${resolved.basePath}${runtimeId}/` + // Whether the Web Worker channel was sent to the Service Worker, which then forwards the previews + let channelRequested = false // The Service Worker instance that has the Web Worker channel of this instance let serviceWorkerInstanceId: string | null = null // A Service Worker instance that started while ready() was in progress @@ -966,6 +970,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { }) // Transfer ports + channelRequested = true serviceWorker.postMessage({ type: V_WW_CONNECT_PORT, runtimeId }, [channel.port1]) worker.postMessage({ type: V_SW_CONNECT_PORT }, [channel.port2]) @@ -1374,6 +1379,19 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } } + /** + * Tells the Service Worker that this instance no longer answers its previews, which then get 404. + */ + function releasePreviews(): void { + if (!channelRequested) { + return + } + getServiceWorker()?.postMessage({ + type: V_WW_DISCONNECT_PORT, + runtimeId + } satisfies DisconnectWebWorkerPortMessage) + } + function dispose(): Promise { if (disposePromise) { return disposePromise @@ -1395,6 +1413,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { // An aborted ready() releases what it created and resolves to false await readyPromise errors.push(...readyReleaseErrors.splice(0)) + attempt(errors, releasePreviews) // Deleting entries while iterating a Map is safe, so no copy is needed for (const record of previewSessions.values()) { attempt(errors, () => unmountSession(record)) From e8552632cea09c4429214ece726ad0ba3037288e Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 13:07:25 +0900 Subject: [PATCH 09/10] test(vrowzer): cover projects sharing a Service Worker in the browser The new isolation tests open two pages in one browser context, which share the Service Worker registration as tabs do. Each test checks a criterion of #45, and those carried over from #43 and #46: - Both tabs have /index.html and /src/main.ts with different contents, initialized in both orders, and each preview shows its own. - Files that one tab updates, adds or deletes stay in its project, also after both previews reload. - While the Web Worker of one tab holds a transform, the other tab edits, and the held response and the HMR updates reach their own tabs. - After one tab is disposed or closed, the other one keeps loading assets, editing and reloading. The previews of the disposed tab get 404, and so do those of the closed tab once another runtime connects. - The previews of one tab share its updates. - After the Service Worker restarts, each tab restores its own project. Without the routing by runtime ID, the first test fails. --- .../integration/isolation.integration-test.ts | 426 ++++++++++++++++++ 1 file changed, 426 insertions(+) create mode 100644 packages/vrowzer/integration/isolation.integration-test.ts diff --git a/packages/vrowzer/integration/isolation.integration-test.ts b/packages/vrowzer/integration/isolation.integration-test.ts new file mode 100644 index 00000000..918d5124 --- /dev/null +++ b/packages/vrowzer/integration/isolation.integration-test.ts @@ -0,0 +1,426 @@ +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' + +// Pages in one browser context share the Service Worker registration, as tabs do. These tests open +// two of them in a new context for each test. They use the fixture of +// restart.integration-test.ts, which creates Vrowzer instances with any files, and holds the load +// of /held.js in the Web Worker until /held-release is written. +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 Tab { + page: Page + previewBasePath: string +} + +interface PreviewResponse { + status: number + body: string +} + +/** + * The project of a tab: the same paths in every tab, with contents that name the tab. + */ +function projectOf(name: string): Record { + return { + '/index.html': ` + + Project ${name} + +
+ + +`, + '/held.js': `export const held = ${JSON.stringify(name)}`, + '/data.txt': `data of ${name}` + } +} + +/** + * Opens a page in `context`, and mounts a preview of a ready Vrowzer instance whose `/src/main.ts` + * renders `text`. + */ +async function openTab(context: BrowserContext, name: string, text = `main ${name}`): Promise { + const page = await context.newPage() + await page.goto(`${origin}/`) + await page.waitForFunction(() => document.body.dataset.fixtureReady === 'true') + const previewBasePath = await page.evaluate( + async ({ files, text }) => { + const fixture = window as any + const vrowzer = fixture.__createVrowzer__() + fixture.__vrowzer__ = vrowzer + const ready = await vrowzer.ready({ + files: { + ...fixture.__previewFiles__, + ...files, + '/src/main.ts': fixture.__mainSource__(text) + } + }) + if (!ready) { + throw new Error('ready() failed') + } + vrowzer.mount(document.getElementById('preview-container'), { id: 'preview' }) + return vrowzer.previewBasePath as string + }, + { files: projectOf(name), text } + ) + await expectPreviewText(page, text) + return { page, previewBasePath } +} + +/** + * Reads a file of the tab's project through the Service Worker, from the host page of `from`. A + * narrow `Accept` header keeps the SPA fallback from answering a missing file with index.html. + */ +async function fetchFromTab( + tab: Tab, + requestPath: string, + accept = 'text/plain', + from: Page = tab.page +): Promise { + return from.evaluate( + async ({ url, acceptHeader }) => { + const response = await fetch(url, { headers: { Accept: acceptHeader } }) + return { status: response.status, body: await response.text() } + }, + { url: `${tab.previewBasePath}${requestPath.slice(1)}`, acceptHeader: accept } + ) +} + +async function fetchFromTabUntilServed(tab: Tab, requestPath: string): Promise { + let response: PreviewResponse | undefined + await expect + .poll( + async () => { + response = await fetchFromTab(tab, requestPath, 'text/javascript') + return response.status + }, + { timeout: 30_000 } + ) + .toBe(200) + return response! +} + +async function runFileOperation(tab: Tab, operation: string, args: unknown[]): Promise { + await tab.page.evaluate( + ({ operation, args }) => (window as any).__vrowzer__[operation](...args), + { operation, args } + ) +} + +async function updateMain(tab: Tab, text: string): Promise { + await tab.page.evaluate(async content => { + const fixture = window as any + await fixture.__vrowzer__.updateFile('/src/main.ts', fixture.__mainSource__(content)) + }, text) +} + +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 = 0): 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 = 0): Promise { + return page.evaluate(sessionIndex => { + const iframe = document.querySelectorAll('#preview-container iframe')[ + sessionIndex + ] as HTMLIFrameElement + return (iframe.contentWindow as any)?.__previewMarker__ + }, index) +} + +async function reloadPreview(tab: Tab): Promise { + await tab.page.evaluate(() => (window as any).__vrowzer__.reloadPreview('preview')) + await expect.poll(() => previewMarker(tab.page), { timeout: 30_000 }).toBeUndefined() +} + +async function recordedEvents(page: Page, type: string): Promise { + return page.evaluate( + eventType => + ((window as any).__events__ as { type: string }[]).filter(event => event.type === eventType), + type + ) +} + +/** + * Stops the Service Worker process that the pages of the context share, and waits until it has + * stopped. The next request or message starts it again. + */ +async function stopServiceWorker(page: Page): 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 expect.poll(() => stopped, { timeout: 15_000 }).toBe(true) + } finally { + await cdp.detach() + } +} + +async function withContext(run: (context: BrowserContext) => Promise): Promise { + const context = await browser.newContext() + try { + await run(context) + } finally { + await context.close() + } +} + +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 isolation 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 tabs sharing a Service Worker', () => { + test.each([ + { order: 'A first', names: ['A', 'B'] }, + { order: 'B first', names: ['B', 'A'] } + ])( + 'serves each tab its own project at the same paths ($order)', + async ({ names }) => { + await withContext(async context => { + const tabs: Record = {} + for (const name of names) { + tabs[name] = await openTab(context, name) + } + + expect(tabs.A!.previewBasePath).not.toBe(tabs.B!.previewBasePath) + for (const name of ['A', 'B']) { + const tab = tabs[name]! + await expectPreviewText(tab.page, `main ${name}`) + expect((await fetchFromTab(tab, '/', 'text/html')).body).toContain( + `Project ${name}` + ) + expect((await fetchFromTab(tab, '/src/main.ts', 'text/javascript')).body).toContain( + `main ${name}` + ) + expect(await fetchFromTab(tab, '/data.txt')).toEqual({ + status: 200, + body: `data of ${name}` + }) + } + }) + }, + 90_000 + ) + + test('keeps the file changes of each tab in its own project', async () => { + await withContext(async context => { + const a = await openTab(context, 'A') + const b = await openTab(context, 'B') + + await updateMain(a, 'A v2') + await runFileOperation(a, 'addFile', ['/only-a.txt', 'added in A']) + await runFileOperation(b, 'deleteFile', ['/data.txt']) + await runFileOperation(b, 'updateFile', ['/public/hello.txt', 'hello from B']) + await reloadPreview(a) + await reloadPreview(b) + + await expectPreviewText(a.page, 'A v2') + await expectPreviewText(b.page, 'main B') + expect(await fetchFromTab(a, '/only-a.txt')).toEqual({ status: 200, body: 'added in A' }) + expect((await fetchFromTab(b, '/only-a.txt')).status).toBe(404) + expect(await fetchFromTab(a, '/data.txt')).toEqual({ status: 200, body: 'data of A' }) + expect((await fetchFromTab(b, '/data.txt')).status).toBe(404) + expect(await fetchFromTab(a, '/hello.txt')).toEqual({ status: 200, body: 'hello v1' }) + expect(await fetchFromTab(b, '/hello.txt')).toEqual({ status: 200, body: 'hello from B' }) + }) + }, 90_000) + + test('delivers delayed transforms and HMR updates to their own tabs', async () => { + await withContext(async context => { + const a = await openTab(context, 'A') + const b = await openTab(context, 'B') + await markPreview(a.page) + await markPreview(b.page) + + // The Web Worker of A holds the load of /held.js + await a.page.evaluate(base => { + ;(window as any).__heldRequest__ = fetch(`${base}held.js`, { + headers: { Accept: 'text/javascript' } + }).then(async response => ({ status: response.status, body: await response.text() })) + }, a.previewBasePath) + await a.page.waitForTimeout(500) + + // Meanwhile, B edits its project, and the HMR update reaches its preview only + await updateMain(b, 'B v2') + await expectPreviewText(b.page, 'B v2') + expect((await fetchFromTab(b, '/src/main.ts', 'text/javascript')).body).toContain('B v2') + expect(await previewText(a.page, 0)).toBe('main A') + + // The held response reaches A + await runFileOperation(a, 'updateFile', ['/held-release', '']) + const held = await a.page.evaluate(() => (window as any).__heldRequest__) + expect(held.status).toBe(200) + expect(held.body).toContain('"A"') + + // The HMR updates of A reach its preview only + await updateMain(a, 'A v2') + await expectPreviewText(a.page, 'A v2') + expect(await previewText(b.page, 0)).toBe('B v2') + // Neither preview reloaded + expect(await previewMarker(a.page)).toBe('kept') + expect(await previewMarker(b.page)).toBe('kept') + }) + }, 90_000) + + test.each(['disposed', 'closed'] as const)( + 'keeps a tab working after the other tab is %s', + async ending => { + await withContext(async context => { + const a = await openTab(context, 'A') + const b = await openTab(context, 'B') + + if (ending === 'disposed') { + await b.page.evaluate(() => (window as any).__vrowzer__.dispose()) + // The previews of the disposed instance get 404 + expect((await fetchFromTab(b, '/src/main.ts', 'text/javascript')).status).toBe(404) + } else { + await b.page.close() + } + + // A loads assets, edits its project and reloads its preview + expect( + await a.page.evaluate(async base => { + const response = await fetch(`${base}pixel.png`) + return [...new Uint8Array(await response.arrayBuffer())] + }, a.previewBasePath) + ).toEqual(PIXEL_BYTES) + await markPreview(a.page) + await updateMain(a, 'A v2') + await expectPreviewText(a.page, 'A v2') + expect(await previewMarker(a.page)).toBe('kept') + await reloadPreview(a) + await expectPreviewText(a.page, 'A v2') + + if (ending === 'closed') { + // A closed tab sends nothing. Its previews are released when another runtime connects. + await openTab(context, 'C') + await expect + .poll( + async () => (await fetchFromTab(b, '/src/main.ts', 'text/javascript', a.page)).status, + { timeout: 15_000 } + ) + .toBe(404) + } + }) + }, + 90_000 + ) + + test('shares the updates of a tab among its previews', async () => { + await withContext(async context => { + const a = await openTab(context, 'A') + const b = await openTab(context, 'B') + await a.page.evaluate(() => + (window as any).__vrowzer__.mount(document.getElementById('preview-container'), { + id: 'second' + }) + ) + await expectPreviewText(a.page, 'main A', 1) + await markPreview(a.page, 0) + await markPreview(a.page, 1) + + await updateMain(a, 'A v2') + + await expectPreviewText(a.page, 'A v2', 0) + await expectPreviewText(a.page, 'A v2', 1) + expect(await previewMarker(a.page, 0)).toBe('kept') + expect(await previewMarker(a.page, 1)).toBe('kept') + expect(await previewText(b.page, 0)).toBe('main B') + }) + }, 90_000) + + test('restores the project of each tab after the Service Worker restarts', async () => { + await withContext(async context => { + const a = await openTab(context, 'A') + const b = await openTab(context, 'B') + await updateMain(a, 'A v2') + await updateMain(b, 'B v2') + + await stopServiceWorker(a.page) + expect((await fetchFromTabUntilServed(a, '/src/main.ts')).body).toContain('A v2') + expect((await fetchFromTabUntilServed(b, '/src/main.ts')).body).toContain('B v2') + for (const tab of [a, b]) { + await expect + .poll(async () => (await recordedEvents(tab.page, 'serviceWorkerRecovered')).length, { + timeout: 30_000 + }) + .toBe(1) + expect(await recordedEvents(tab.page, 'serviceWorkerRecoveryError')).toEqual([]) + } + + // Both tabs edit and reload their previews again + await markPreview(a.page) + await markPreview(b.page) + await updateMain(a, 'A v3') + await updateMain(b, 'B v3') + await expectPreviewText(a.page, 'A v3') + await expectPreviewText(b.page, 'B v3') + expect(await previewMarker(a.page)).toBe('kept') + expect(await previewMarker(b.page)).toBe('kept') + await reloadPreview(a) + await reloadPreview(b) + await expectPreviewText(a.page, 'A v3') + await expectPreviewText(b.page, 'B v3') + }) + }, 120_000) +}) From 852e861d32eea1f4b8da5b5d17b6e36a07c5a2e1 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 13:08:59 +0900 Subject: [PATCH 10/10] docs(vrowzer): describe preview base paths per instance - README: basePath is the start of the preview URLs. Each instance loads its previews from its own path under it, which the new previewBasePath returns, so several instances, e.g. in two tabs, can share a Service Worker. A NOTE describes the change from vrowzer 0.4.x: the preview URLs and import.meta.env.BASE_URL now have the instance ID, and hosts that build preview URLs from basePath should use previewBasePath. - README: dispose() releases the previews of the instance in the Service Worker, and a closed page's previews are released when another instance connects. - JSDoc of basePath and dispose(), and the architecture diagram. - The API references are regenerated, with previewBasePath. --- packages/vrowzer/README.md | 32 +++++++++++++++---- packages/vrowzer/assets/architecture.svg | 6 ++-- .../docs/default/interfaces/Vrowzer.md | 8 ++++- .../docs/default/interfaces/VrowzerOptions.md | 2 +- packages/vrowzer/src/index.ts | 8 +++-- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index 321f4349..e0df3c8f 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -79,7 +79,7 @@ await vrowzer.updateFile( Creates a new Vrowzer instance. -When `@vrowzer/vite-plugin` is used, configure the preview URL with the plugin's `basePath`. The value is shared with the application, Web Worker, and Service Worker, so the runtime option can be omitted: +When `@vrowzer/vite-plugin` is used, configure the start of the preview URLs with the plugin's `basePath`. The value is shared with the application, Web Worker, and Service Worker, so the runtime option can be omitted: ```ts // vite.config.ts @@ -99,7 +99,12 @@ import { Vrowzer } from 'vrowzer' const vrowzer = Vrowzer() ``` -The runtime `basePath` remains available for compatibility and for usage without the plugin. If both the plugin and runtime values are provided, their canonical paths must match or `Vrowzer()` throws. Without either value, the preview path is `/__preview__/`. +The runtime `basePath` remains available for compatibility and for usage without the plugin. If both the plugin and runtime values are provided, their canonical paths must match or `Vrowzer()` throws. Without either value, the preview URLs start with `/__preview__/`. + +Each instance loads its previews from its own path under `basePath`, which [`previewBasePath`](#previewbasepath-string) returns, e.g. `/__preview__/0123456789ab/`. The path is also the Vite base of the project, so `import.meta.env.BASE_URL` in the preview returns it. This lets several instances share one Service Worker, e.g. in two tabs of the application: each tab gets only its own files, transforms and HMR updates. + +> [!NOTE] +> Up to vrowzer 0.4.x, the previews loaded from `basePath` itself, e.g. `/__preview__/`, and `import.meta.env.BASE_URL` was `basePath`. Use `previewBasePath` instead where the host builds preview URLs from `basePath`. Two instances that shared a Service Worker got each other's files and HMR updates. `serviceWorkerScope` controls which pages the browser allows the Service Worker to control. When `@vrowzer/vite-plugin` is used, configure the scope on the plugin so the registration and `Service-Worker-Allowed` response header use the same value. The runtime option can then be omitted: @@ -113,7 +118,7 @@ const vrowzer = Vrowzer() The runtime `serviceWorkerScope` remains available for compatibility and for builds without the plugin. If both values are provided, they must match or `Vrowzer()` throws before registration. Without either value, the scope defaults to `/`. The scope does not set the preview URL; that is the role of `basePath`. -The scope selects which pages the Service Worker controls, not which request URLs it receives from those pages. Vrowzer only responds to same-origin HTTP(S) requests within `basePath`. Cross-origin requests and same-origin requests outside `basePath` are left to the browser's native network path. The Service Worker forwards the requests within `basePath` to the Web Worker, which answers them. Those that the virtual project cannot serve get a 404 response; they are not sent to the host server. +The scope selects which pages the Service Worker controls, not which request URLs it receives from those pages. Vrowzer only responds to same-origin HTTP(S) requests within `basePath`. Cross-origin requests and same-origin requests outside `basePath` are left to the browser's native network path. The Service Worker forwards each request within `basePath` to the Web Worker of the instance whose `previewBasePath` it is under, which answers it. Those that the virtual project cannot serve get a 404 response, as do the requests under no instance's path or under a disposed instance's path; they are not sent to the host server. `serviceWorkerVersion` identifies the Service Worker version expected by the controller and reported by the worker. When `@vrowzer/vite-plugin` is used, configure the version on the plugin and omit the runtime option: @@ -155,13 +160,27 @@ The same timeout limits how long Vrowzer takes to connect a restarted Service Wo | Option | Type | Default | Description | | --------------------------- | -------- | --------------------------------- | ------------------------------------------------------------ | -| `basePath` | `string` | Plugin value or `'/__preview__/'` | Preview URL pathname; must match the plugin value | +| `basePath` | `string` | Plugin value or `'/__preview__/'` | Start of the preview URLs; must match the plugin value | | `serviceWorkerVersion` | `string` | Plugin value or `'vrowzer-v1'` | SW version; must match the plugin value | | `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 Web Worker to apply a file change, or to reconnect a restarted Service Worker | +### Instance Properties + +#### `previewBasePath: string` + +The base path of the instance's previews: `basePath` followed by an ID of the instance, e.g. `/__preview__/0123456789ab/`. It is set when `Vrowzer()` is called, and stays the same after `dispose()`. The previews load from it, and it is the Vite base of the project, which `import.meta.env.BASE_URL` returns in the preview. + +```ts +const vrowzer = Vrowzer() +await vrowzer.ready({ files }) + +// Read a preview file through the Service Worker +const response = await fetch(`${vrowzer.previewBasePath}main.js`) +``` + ### Instance Methods #### `ready(config): Promise` @@ -273,10 +292,11 @@ Disposes the instance when the host application stops using it. The promise reso - every preview session, as with `unmount()` - the instance's Web Worker, with the project files +- the instance's previews in the Service Worker, which answers the requests under `previewBasePath` with 404 from then on - the forwarding of Service Worker controller events - all event handlers, which are removed as soon as `dispose()` is called -The Service Worker registration is shared with other clients and is kept, so a new instance can start right away. +The Service Worker registration is shared with other clients and is kept, so a new instance can start right away. A closed page cannot release its previews, so the Service Worker releases them when another instance connects. ```ts await vrowzer.dispose() @@ -367,7 +387,7 @@ vrowzer.on('serviceWorkerRecoveryError', async error => { ![Architecture](./assets/architecture.svg) -The Web Worker runs the Vite dev server: it has the project files, the module graph and the plugins, and answers the preview requests with the Vite middlewares, including the ones that plugins add in `configureServer`. The Service Worker has no project files. It forwards the requests within `basePath` to the Web Worker over a MessageChannel, and the HMR ports of the previews as well. +The Web Worker runs the Vite dev server: it has the project files, the module graph and the plugins, and answers the preview requests with the Vite middlewares, including the ones that plugins add in `configureServer`. The Service Worker has no project files. It forwards each request within `basePath` to the Web Worker of the instance whose `previewBasePath` it is under, over a MessageChannel, and the HMR ports of the previews as well. Several instances, e.g. in two tabs, can share the Service Worker this way. Give Vite plugins to the Web Worker, as `@vrowzer/vite-plugin` does with the plugins of `vite.config.ts`. The Service Worker runs no plugins. diff --git a/packages/vrowzer/assets/architecture.svg b/packages/vrowzer/assets/architecture.svg index 6a19ac7d..d4371fd1 100644 --- a/packages/vrowzer/assets/architecture.svg +++ b/packages/vrowzer/assets/architecture.svg @@ -39,8 +39,8 @@ Service Worker - Has no project files. Forwards the preview requests - and the HMR ports to the Web Worker. + Has no project files. Forwards the preview requests and + the HMR ports to the Web Worker of the instance in the URL. @@ -114,7 +114,7 @@ - fetch /__preview__/ + fetch /__preview__/<id>/ diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index 871c5c46..1929e555 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -12,6 +12,12 @@ The main interface for the Vrowzer preview environment. export interface Vrowzer extends Emittable ``` +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `previewBasePath` _(readonly)_ | `string` | The base path of the previews of this instance: [VrowzerOptions.basePath](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-basepath) followed by an ID of the instance, e.g. `/__preview__/0123456789ab/`. The previews load from it, and it is the Vite `base` of the project, which `import.meta.env.BASE_URL` returns in the preview. The Service Worker forwards the requests under it to the Web Worker of this instance, so several instances can share one Service Worker, e.g. in two tabs. It is set when [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md) is called, and stays the same after [Vrowzer.dispose](#method-dispose). | + ## Methods ### addFile() @@ -80,7 +86,7 @@ An in-progress [Vrowzer.ready](#method-ready) is aborted and resolves to `false` a restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is terminated with the project files, Service Worker controller events are no longer forwarded, and all event handlers are removed right away. The Service Worker registration is kept for other -clients. +clients, and answers the requests under [Vrowzer.previewBasePath](#property-previewbasepath) with 404 from then on. File operations still waiting for the Web Worker 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 fabe3a1a..8a50a4c0 100644 --- a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md +++ b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md @@ -12,7 +12,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__/'`. | +| `basePath` _(optional)_ | `string` | The pathname that the preview URLs start with. The previews of each instance load from its own path under it, e.g. `/__preview__/0123456789ab/`, which [Vrowzer.previewBasePath](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#property-previewbasepath) returns. The Service Worker answers the requests within it. 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 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 answer 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 connect a restarted Service Worker to the Web Worker again. 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 `'/'`. | diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index d32382b6..fe635935 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -109,7 +109,11 @@ type FileOperation = 'addFile' | 'updateFile' | 'deleteFile' */ export interface VrowzerOptions { /** - * Preview URL pathname. + * The pathname that the preview URLs start with. + * + * The previews of each instance load from its own path under it, e.g. + * `/__preview__/0123456789ab/`, which {@link Vrowzer.previewBasePath} returns. The Service Worker + * answers the requests within it. * * 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, @@ -427,7 +431,7 @@ export interface Vrowzer extends Emittable { * a restarted Service Worker is stopped. Every preview session is unmounted, the Web Worker is * terminated with the project files, Service Worker controller events are no longer forwarded, and * all event handlers are removed right away. The Service Worker registration is kept for other - * clients. + * clients, and answers the requests under {@link Vrowzer.previewBasePath} with 404 from then on. * * File operations still waiting for the Web Worker reject. After disposal, `ready()` and the file * methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a