From 779376520b4151c0985e28e65de3698d44451d9f Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 21:23:33 +0900 Subject: [PATCH 1/3] fix(vite-dev-server): answer missing files within the preview base path with 404 The Service Worker passed its Hono app to handle() from hono/service-worker without options. With the default options, the adapter fetches a 404 response again from the network. So a request within the preview base path that the virtual project could not serve reached the host server, and the host's response was returned to the preview. With @vrowzer/vite-plugin on the host, such requests got its guard page: a 503 "Waiting for Service Worker..." page that reloads itself, although the Service Worker controlled the page. When the preview HTML itself was missing, the preview reloaded every second and reported a load error each time. handle() now gets options without fetch, so the app's 404 is returned as it is, as Vite does. Requests outside the base path are still left to the browser. --- packages/vite-dev-server/src/node/service-worker.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vite-dev-server/src/node/service-worker.ts b/packages/vite-dev-server/src/node/service-worker.ts index c1ea7b5..9675e1b 100644 --- a/packages/vite-dev-server/src/node/service-worker.ts +++ b/packages/vite-dev-server/src/node/service-worker.ts @@ -347,7 +347,10 @@ export function createServer( claimOnActivate: true, debug: createDebugger('vrowzer:svc-worker-server')!, }) - const honoFetchHandler = handle(middlewares) + // NOTE(kazupon): Without options, Hono's Service Worker adapter fetches a 404 response again + // from the network, so requests for missing preview files reached the host server. Requests + // within the base path belong to the virtual project, so answer them with the 404, as Vite does. + const honoFetchHandler = handle(middlewares, {}) const fetchHandler = (event: FetchEvent) => { // Leave requests outside the virtual project unanswered so the browser // performs the fetch with the controlled client's native semantics. From 34852c8963e8ea971462c91bce871979f7b1e9c6 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 21:24:32 +0900 Subject: [PATCH 2/3] test(vrowzer): cover missing preview files in the browser A missing file within the preview base path that the SPA fallback does not answer (a GET with a narrow Accept header, a POST and /favicon.ico) gets a 404 with an empty body from the Service Worker. A new preview server plugin records the requests within the base path that reach the host. It runs before the guard of @vrowzer/vite-plugin, and the test checks that none of these requests reach the host. A preview session whose HTML request ends in a 404 reports the load error once, with status 404, and reports nothing more after 2.5 seconds. The session loads /favicon.ico as its HTML, because the SPA fallback answers other missing paths with index.html, and removing index.html would break the other sessions. Both tests fail without the previous commit: the host's guard answers with 503, and the preview reloads and reports the error every second. --- .../integration/vrowzer.integration-test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/vrowzer/integration/vrowzer.integration-test.ts b/packages/vrowzer/integration/vrowzer.integration-test.ts index 8e6c641..bdce522 100644 --- a/packages/vrowzer/integration/vrowzer.integration-test.ts +++ b/packages/vrowzer/integration/vrowzer.integration-test.ts @@ -41,6 +41,8 @@ let crossOriginAssetServerUrl: string let pageConsoleLogs: string[] = [] let delayedServiceWorkerRequests = 0 let hmrClientProbeId = 0 +// Requests within the preview base path that reached the host server +const hostPreviewRequests: string[] = [] function delayServiceWorkerResponse(delay: number): Plugin { return { @@ -78,6 +80,26 @@ function serveHostFetchProbe(): Plugin { } } +/** + * Records the requests within the preview base path that reach the host server. It runs before + * the guard of `@vrowzer/vite-plugin`, which answers such requests. + */ +function recordHostPreviewRequests(): Plugin { + return { + name: 'vrowzer:test-record-host-preview-requests', + enforce: 'pre', + configurePreviewServer(server) { + server.middlewares.use((request, _response, next) => { + const pathname = new URL(request.url ?? '/', 'http://localhost').pathname + if (pathname.startsWith('/__preview__/')) { + hostPreviewRequests.push(`${request.method} ${pathname}`) + } + next() + }) + } + } +} + async function closeHttpServer(server: HttpServer | undefined): Promise { if (!server?.listening) { return @@ -398,6 +420,7 @@ beforeAll(async () => { server = await preview({ root: PLAYGROUND_DIR, plugins: [ + recordHostPreviewRequests(), serveHostFetchProbe(), ...(SERVICE_WORKER_RESPONSE_DELAY > 0 ? [delayServiceWorkerResponse(SERVICE_WORKER_RESPONSE_DELAY)] @@ -1252,6 +1275,21 @@ if (import.meta.hot) { expect(report.message).toContain('500') }) + test('reports a missing preview HTML once with 404', async () => { + 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') + + const report = await waitForLoadError('html-missing') + + expect(report).toMatchObject({ id: 'html-missing', stage: 'html', status: 404 }) + // A page that reloads the preview, such as the host's "Waiting for Service Worker..." page, + // would report the error again every second + await new Promise(resolve => setTimeout(resolve, 2500)) + expect(await readLoadErrors('html-missing')).toHaveLength(1) + }) + test('reports a rejected fetch of the preview HTML', async () => { await prepareFixtures() await mountSession('html-fetch', UNREACHABLE_URL) @@ -1442,6 +1480,32 @@ if (import.meta.hot) { expect(response?.status()).toBe(200) expect(response?.fromServiceWorker()).toBe(true) }) + + test('answers missing files within basePath with 404 without asking the host', async () => { + const hostRequestCount = hostPreviewRequests.length + + const responses = await page.evaluate(async () => { + const read = async (path: string, init?: RequestInit) => { + const response = await fetch(path, init) + return { status: response.status, body: await response.text() } + } + return { + narrowAccept: await read('/__preview__/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') + } + }) + + // The SPA fallback does not answer these requests, so they end in the 404 handler + expect(responses).toEqual({ + narrowAccept: { status: 404, body: '' }, + post: { status: 404, body: '' }, + favicon: { status: 404, body: '' } + }) + expect(hostPreviewRequests.slice(hostRequestCount)).toEqual([]) + }) }) describe('error responses', () => { From 3a7f74c8501a4e9a76bdee469f001a229886f94b Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 1 Oct 2026 21:24:44 +0900 Subject: [PATCH 3/3] docs(vrowzer): describe 404 responses within the preview base path The README section on the Service Worker scope says that requests within basePath which the virtual project cannot serve get a 404 response from the Service Worker, and are not sent to the host server. --- packages/vrowzer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index 15bbb25..c8838de 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. +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. `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: