feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place

A path that is no file falls back to index.html when the request is a navigation:
Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check,
Accept-Encoding and If-None-Match are all matched on the header bytes through the new
Request.headerView(name), so serving still allocates nothing. navigationOnly(false)
drops the check for an app that wants every GET miss to get the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 16:16:27 +00:00
co-authored by Claude Opus 5
parent 680bbca8c7
commit 003fd6d1f0
6 changed files with 116 additions and 29 deletions
@@ -55,6 +55,7 @@ in agreement.
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
| `devPort(int)` | `5173` | Vite's port in DEV. |
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
| `navigationOnly(boolean)` | `true` | Only browser navigations fall back to `index.html` (see below). `false` gives every `GET` that matches no file the page, as nginx's `try_files` does, which also turns API 404s into the page. Rarely wanted. |
| Plugin parameter | Default | |
|---|---|---|
@@ -103,11 +104,18 @@ prefers specific routes over the wildcard.
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
| no such file, and the request is a navigation | `index.html`, so the client-side router takes it |
| no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `404` |
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
| `HEAD` | the `GET` headers, `Content-Length` included, no body |
A navigation is what a browser sends when a person opens a URL: `Sec-Fetch-Mode: navigate`, which
every current browser sets for exactly this purpose, or an `Accept` that names `text/html`, which
covers older browsers, crawlers and `curl -H 'Accept: text/html'`. The check reads the header bytes
in place and allocates nothing. This is stricter than Vite's own dev-server fallback, which also
takes `Accept: */*` and so gives `fetch('/api/typo')` the page. It is the same rule service
workers use for their navigation fallback. With `navigationOnly(false)` the check is skipped.
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
`Vary: Accept-Encoding`.