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. 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: 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', () => {