Skip to content

fix(vite-dev-server): answer missing files within the preview base path with 404 - #69

Merged
kazupon merged 3 commits into
mainfrom
fix/sw-404-network-fallback
Oct 1, 2026
Merged

kazupon merged 3 commits into
mainfrom
fix/sw-404-network-fallback

Conversation

@kazupon

@kazupon kazupon commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Closes #68

A request within the preview base path that the virtual project cannot serve now gets a 404 from the Service Worker, as with Vite, instead of the host server's response.

Cause

The Service Worker passed its Hono app to handle() from hono/service-worker without options. With the default options, the adapter fetches the request again from the network whenever the app answers with 404, and returns the host's response.

Most ordinary loads were not affected: for a GET or HEAD request whose Accept header has text/html or */*, the SPA fallback answers a missing file with /index.html. The other requests reached the host:

  • other methods, such as POST;
  • /favicon.ico;
  • narrower Accept headers;
  • every request, when /index.html itself was missing.

With @vrowzer/vite-plugin on the host, these requests got its guard page: a 503 "Waiting for Service Worker..." page that reloads itself after one second, although the Service Worker did control the page. When the preview HTML itself was missing, the bootstrap rendered that page, so the preview reloaded every second and reported a previewLoadError each time.

Fix

handle() now gets options without fetch, so the adapter returns the app's 404 as it is. Requests outside the base path are still left to the browser. The README states that requests within basePath that the virtual project cannot serve get a 404 from the Service Worker.

The fallback does not look intended:

  • handle(middlewares) has been called without options since the Service Worker module was first split out, with no recorded reason.
  • Requests that should go to the network are excluded explicitly instead: those outside the base path, and rolldown's internal assets.
  • The README already said that Vrowzer responds to requests within basePath.
  • With @vrowzer/vite-plugin, which the integration tests and the e2e fixtures use, the fallback could only ever return the guard's 503.

Behavior change

A request within the preview base path that the virtual project cannot serve gets a 404 instead of the host's response. With @vrowzer/vite-plugin on the host, that response was always the guard's 503, so no working setup depends on it. A host without the plugin that served files under the preview base path would now get 404s for them. The README never described that use.

Not included

  • A default /index.html in the Service Worker: the Web Worker writes a default /index.html, but the Service Worker does not, so a ready() without /index.html has no preview HTML. With this change, the preview reports one 404 load error instead of reloading. Whether to provide a default is a separate question.
  • The vite-plugin guard, which still answers requests that bypass a Service Worker that does not control the page yet.
  • A 500 response when the app's fetch() rejects: the error handler catches middleware errors, and fix(vite-dev-server): use Vite's error handler for Service Worker requests #58 removed the case where it threw.

Tests

Integration tests in the shared playground:

  • A missing file within the base path that the SPA fallback does not answer gets a 404 with an empty body: a GET with Accept: application/json, a POST, and /favicon.ico.
  • None of these requests reach the host. A new preview server plugin records the requests within the base path that reach the host. It runs before the guard (enforce: 'pre').
  • 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: the SPA fallback answers other missing paths with index.html, and removing index.html would break the other sessions.

Before the change, both tests failed. The three requests got the guard's 503 and were recorded at the host, and the preview reported a 503 three times within 2.5 seconds. Reverting the fix makes both tests fail again.

Validated locally with:

  • vp run test:unit: 119 files, 1,914 tests
  • vp run test:integration:vrowzer: 69 passed and 1 conditional skip, in three runs
  • vp run test:e2e: 46 passed, 6 existing test.skip
  • oxlint (no new warnings), knip, the format check of the changed test file, and typecheck

…th 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.
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.
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.
@kazupon
kazupon merged commit cdac4cd into main Oct 1, 2026
4 checks passed
@kazupon
kazupon deleted the fix/sw-404-network-fallback branch October 1, 2026 12:33
kazupon added a commit that referenced this pull request Oct 1, 2026
…s none (#71)

* fix(vrowzer): give both Workers a default index.html when ready() gets none

The Web Worker wrote a default /index.html during setup, but the Service Worker, which serves the preview HTML, never got one. A ready() without /index.html, as in the README usage example, therefore left the preview without HTML: since #69 it reported one 404 load error and stayed blank.

initialize() now adds a default /index.html to the files it copies from ready() when they have none, and sends it to both Workers. The default is minimal: an empty #app element and a module script that loads /main.js. An /index.html given to ready() is used as before.

* refactor(vite-dev-server): leave the default index.html to the runtime

setupVirtualFiles() wrote a default /index.html in the Web Worker. The Service Worker serves the preview HTML and never had this default, so it was never served. The vrowzer runtime now sends the same default /index.html to both Workers when ready() gets none, so the Web Worker no longer writes its own. /public/.gitkeep is still written.

* test(vrowzer): cover the README usage example in the browser

Opened with ?files=readme-example, the playground gives ready() only the /main.js of the README usage example. A new test opens it in a new browser context, which has a Service Worker of its own, and checks that the preview shows "Hello!", as the example expects.

Without the default /index.html, the preview stays blank and the test times out.

* docs(vrowzer): describe the default index.html

The README section of ready() and the JSDoc of VrowzerConfig.files say that, without /index.html, Vrowzer uses a default one: an empty #app element and a module script that loads /main.js. The API references are regenerated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return 404 for missing preview files instead of fetching them from the host

1 participant