|
| 1 | +# Vite Dev Middleware — Request Routing |
| 2 | + |
| 3 | +> Scope: the request-routing logic in `src/build/vite/dev.ts` — the two |
| 4 | +> `server.middlewares.use(...)` registrations (`nitroDevMiddlewarePre` and the |
| 5 | +> catch-all `nitroDevMiddleware`): why the current design looks the way it does. |
| 6 | +
|
| 7 | +## 1. Where it lives & how it is wired |
| 8 | + |
| 9 | +- Logic: `src/build/vite/dev.ts` → `configureViteDevServer(ctx, server)`. |
| 10 | +- Entry: `src/build/vite/plugin.ts:266` — the plugin's `configureServer` hook |
| 11 | + `return`s the result of `configureViteDevServer`. |
| 12 | + |
| 13 | +Vite's `configureServer` contract: middleware registered **synchronously** |
| 14 | +inside the hook runs **before** Vite's internal middlewares; a function |
| 15 | +**returned** from the hook runs **after** them (the "post" hook). Nitro uses |
| 16 | +both ends: |
| 17 | + |
| 18 | +| Registration | Site | Runs | Role | |
| 19 | +|---|---|---|---| |
| 20 | +| `nitroDevMiddlewarePre` (`const` form) | `dev.ts:284` | **before** Vite static/transform | Classifier. Route explicit-Nitro + definite navigations to Nitro immediately; let definite assets fall through to Vite, marking them `_nitroHandled` (transparent catch-all) or `_nitroAssetCheck` (opaque catch-all / no match). | |
| 21 | +| `nitroDevMiddleware` | defined at `dev.ts:213`, registered inside the returned `() => { ... }` | **after** Vite static/transform | Catch-all fallback. Wraps req as web `Request`, tries `ctx.devApp.fetch` then `nitroEnv.dispatchFetch`, honoring `baseURL`; inspects the response for `_nitroAssetCheck` requests. Skipped for `_nitroHandled`. | |
| 22 | + |
| 23 | +**Why two, and why pre?** Without the pre-pass, Vite's static/transform |
| 24 | +middleware serves files from the project root and would answer server routes |
| 25 | +before Nitro sees them (see upstream **vitejs/vite#20866**, which made Vite |
| 26 | +consult `sec-fetch-dest` to let document requests fall through — the same |
| 27 | +header Nitro's classifier leans on). |
| 28 | + |
| 29 | +Facts about Vite's side (verified against vite@8.1.4): Nitro defaults |
| 30 | +`appType` to `"custom"` (`plugin.ts:199` — user-overridable; the reasoning |
| 31 | +below assumes the default), so Vite registers none of |
| 32 | +`htmlFallback`/`indexHtml`/`notFound` middlewares, and **every plain miss |
| 33 | +`next()`s** (sirv and transformMiddleware never self-emit a 404 for a missing |
| 34 | +file/module). The only terminal 404 is connect's `finalhandler`, which sits |
| 35 | +**after** the Nitro post-hook. So Vite genuinely hands off every request it |
| 36 | +cannot serve — the post catch-all is the last handler before `finalhandler`. |
| 37 | + |
| 38 | +## 2. Classification |
| 39 | + |
| 40 | +`nitro.routing.routes` in an SSR app **always** contains a catch-all `/**`, so |
| 41 | +"does a Nitro route match?" is nearly always *yes*. The design distinguishes: |
| 42 | + |
| 43 | +- **Explicit route** — `route` set, `!== "/**"`, not `startsWith("/**:")` (a |
| 44 | + prefixed splat like `/api/photos/**` is explicit). Deterministic → **always |
| 45 | + Nitro**, no heuristic may override (#4108, #4241, #4252, #4270). |
| 46 | +- **Explicit public asset** — a public-asset dir under a non-root `baseURL` |
| 47 | + with `fallthrough: false` owns its subtree → Nitro, like an explicit route. |
| 48 | +- **Transparent catch-all** — a *user route file* at root level |
| 49 | + (`routes/[...].ts` → `/**`, `routes/[...slug].ts` → `/**:slug`). Nitro sees |
| 50 | + everything it can handle, so Vite stays the definitive asset handler: an |
| 51 | + asset-tagged request is marked `_nitroHandled` and a Vite miss must **not** |
| 52 | + fall back into it (#4234/#4266). |
| 53 | +- **Opaque catch-all** — the SSR renderer `/**` or a custom `serverEntry` `/**` |
| 54 | + (both registered in `routing.ts` with identifiable `handler` paths). Their |
| 55 | + *real* routes are invisible to the host (`server.ts` H3 app routes, framework |
| 56 | + routers like TanStack Start), so an asset-tagged miss from Vite must still be |
| 57 | + **dispatched** — marked `_nitroAssetCheck`, decided by response inspection. |
| 58 | +- **None** — no match. Extensionless → page navigation → Nitro; asset-tagged → |
| 59 | + same as opaque (`_nitroAssetCheck`, dispatch after Vite). |
| 60 | + |
| 61 | +## 3. The asset heuristic (catch-all / none case only) |
| 62 | + |
| 63 | +Set `Vary: sec-fetch-dest, accept`, then: |
| 64 | + |
| 65 | +- **`Sec-Fetch-Dest` present & concrete** (not `empty`): `document`/`iframe`/ |
| 66 | + `frame` = navigation → Nitro; anything else (`image`, `video`, `style`, …) = |
| 67 | + asset. |
| 68 | +- **Absent or `empty`**: a Vite `?import` module-graph marker |
| 69 | + (`/[?&]import(?:[&=]|$)/` on the URL) ⇒ asset — only Vite's module graph emits |
| 70 | + it, never a page navigation, so it stays authoritative for extensions left out |
| 71 | + of `ASSET_EXT_RE` like imported `.json` (#4433). Otherwise fall back to |
| 72 | + extension — `ASSET_EXT_RE.test(ext)` **and** no `text/html` in `Accept` ⇒ |
| 73 | + asset. (`empty` = fetch/XHR is ambiguous: it tags both API calls and |
| 74 | + `fetch()`ed assets.) |
| 75 | +- **Only `GET`/`HEAD` can be assets at all** — a `POST /upload.png` is never a |
| 76 | + browser asset load, so other methods bypass the heuristic entirely. |
| 77 | +- Non-asset + (matched or extensionless) → Nitro immediately (pre-Vite). |
| 78 | + |
| 79 | +Two regexes to keep intact: |
| 80 | +- `ASSET_EXT_RE` (`dev.ts:24`) — narrow on purpose, so dotted Nitro params like |
| 81 | + `/foo.bar.1` still reach Nitro (#4108). |
| 82 | +- Extension is extracted from the path only (query/hash stripped) so |
| 83 | + `?file=bar.png` does not misclassify. |
| 84 | + |
| 85 | +## 4. Response inspection (`_nitroAssetCheck`) |
| 86 | + |
| 87 | +An asset-tagged request that only an opaque catch-all could handle cannot be |
| 88 | +classified **a priori** — `/image.png` may be a real custom-entry route (#4252) |
| 89 | +or a genuinely missing asset that a naive SSR `/**` would render as a 200 page |
| 90 | +(#4234). It *can* be classified from the **response**: after Vite declines and |
| 91 | +the post catch-all dispatches to Nitro, a 2xx with a `text/html` content-type |
| 92 | +(inline check in the post middleware) means the catch-all rendered a page for a |
| 93 | +missing asset → the response is discarded via `next()` (connect `finalhandler` |
| 94 | +404, same as before). Anything else — real asset types, JSON, `text/plain`, no |
| 95 | +content-type, non-2xx (framework 404 pages, redirects) — passes through |
| 96 | +verbatim. |
| 97 | + |
| 98 | +Deny-list rationale (all verified empirically): |
| 99 | + |
| 100 | +- Only `text/html` counts as a swallow. `application/json` was originally |
| 101 | + denied too, but that broke real opaque frameworks: TanStack Start answers |
| 102 | + API routes tagged as asset loads with JSON on purpose |
| 103 | + (`<img src="/api/.../thumbnail">`, TanStack/router#7403, nitro PR #4274), |
| 104 | + and sourcemaps (`.map` ∈ `ASSET_EXT_RE`) are legitimately JSON. The |
| 105 | + accepted trade-off: an SSR entry that swallows missing assets with *JSON* |
| 106 | + (pathological — real naive SSR renders HTML) now returns 200 JSON instead |
| 107 | + of 404 in dev. |
| 108 | +- `text/plain` is excluded because a bare string returned from an h3 handler |
| 109 | + has **no** content-type at the h3 layer but arrives as `text/plain; |
| 110 | + charset=UTF-8` — srvx's node-adapter default applied on the worker's HTTP hop |
| 111 | + (`srvx/dist/adapters/node.mjs`; runner-dependent but converges) — and must |
| 112 | + pass through (custom-entry handlers returning strings). |
| 113 | +- Transparent (user-file) catch-alls can **not** use inspection instead of the |
| 114 | + divert: their string 200s arrive with no distinguishing content-type. |
| 115 | + |
| 116 | +This keeps everything zero-config, host-side only (no runtime/bundle change, |
| 117 | +no production behavior change). Known leftovers: |
| 118 | + |
| 119 | +- **Production** SSR still renders 200 HTML for missing-asset URLs — |
| 120 | + pre-existing behavior; changing it is a separate, deliberate breaking-change |
| 121 | + discussion. |
| 122 | +- Opaque semantics require registration via `renderer` / `serverEntry`. A `/**` |
| 123 | + catch-all added through `routes:` / `handlers:` config or a module is |
| 124 | + classified **transparent** and stays pre-empted for asset-tagged requests |
| 125 | + (#4252-class limitation) — a framework integrating its renderer that way |
| 126 | + should use `renderer` instead. |
| 127 | +- Dev-only cost: a missing asset matching an opaque catch-all triggers a full |
| 128 | + (discarded) SSR render before the 404. |
| 129 | + |
| 130 | +## 5. Issue / PR lineage (chronological) |
| 131 | + |
| 132 | +- **#3649** first crude "has extension ⇒ Vite" rule; **#3804/#3805/#3817** |
| 133 | + middleware improvements, mounted-path skip, internal-prefix skip (survives as |
| 134 | + the `^\/(?:__|@)` guard); **#4098** `sec-websocket-protocol` to tell Vite HMR |
| 135 | + sockets from Nitro websockets (the `upgrade` handler). |
| 136 | +- **#4108** baseURL-aware matching for dotted Nitro routes |
| 137 | + (TanStack/router#6903). |
| 138 | +- **#4234** non-loopback plain-HTTP origins omit `Sec-Fetch-*` → splat swallowed |
| 139 | + `<script src>` loads. Fixed by **#4238**: `Accept` + `ASSET_EXT_RE` fallback + |
| 140 | + the `_nitroHandled` marker. |
| 141 | +- **#4241** #4238 over-eager: `sec-fetch-dest: image` on a real route |
| 142 | + (`/api/image`) sent to Vite → 404. Fixed by `7d49dcae` + `08f2ec69` |
| 143 | + (query-string stripped before extension matching). |
| 144 | +- **#4252 / #4270** even explicit routes lost when the URL had an asset |
| 145 | + extension. Fixed by **#4272** (`5b7e152b`): explicit vs catch-all vs none |
| 146 | + classification — **an explicit route is a deterministic win no heuristic can |
| 147 | + touch**. |
| 148 | +- `7765bcb7` added `isExplicitPublicAsset`. |
| 149 | +- **#4252 follow-ups** (katywings, jantimon/TanStack/router#7403): routes the |
| 150 | + classifier cannot see — a custom `server.ts` H3 app serving `/image.png`, or |
| 151 | + an SSR framework serving assets — were pre-empted by `_nitroHandled` and |
| 152 | + could never run. Fixed by the opaque-catch-all + response-inspection design |
| 153 | + above (§2, §4): the pre-emption now applies only to transparent user-file |
| 154 | + catch-alls, and opaque dispatches are judged by their response content-type. |
| 155 | + |
| 156 | +## 6. Runtime dispatch flow |
| 157 | + |
| 158 | +The catch-all `nitroDevMiddleware` dispatches in two stages: |
| 159 | + |
| 160 | +1. **`ctx.devApp.fetch(req)`** — `NitroDevApp` (`src/dev/app.ts`), host-side: |
| 161 | + `devHandlers`, `/_vfs/**`, `/_nitro/tasks`, public asset dirs, `devProxy`. |
| 162 | + No catch-all → a 404 means "not mine, hand off"; any non-404 is returned |
| 163 | + directly (never inspected — deterministic host serves are authoritative). |
| 164 | +2. **`nitroEnv.dispatchFetch(req)`** → env-runner → worker → `nitroApp.fetch` → |
| 165 | + full route table, middleware, catch-alls. |
| 166 | + |
| 167 | +Catch-all registration (`src/routing.ts`): a custom `serverEntry` is pushed as |
| 168 | +`/**` with `handler = nitro.options.serverEntry.handler`; the SSR renderer as |
| 169 | +`/**` with `handler = nitro.options.renderer.handler` (set by `plugin.ts` |
| 170 | +`configResolved` to `internal/vite/ssr-renderer` when an `ssr` service exists). |
| 171 | +These handler paths are how the pre-pass identifies opaque catch-alls. |
| 172 | + |
| 173 | +## 7. Test coverage map |
| 174 | + |
| 175 | +All under `test/vite/`; run via `test:rollup` and `test:rolldown`. Each starts |
| 176 | +a real Vite dev server and `fetch()`es with hand-set headers. |
| 177 | + |
| 178 | +- `app.test.ts` (`app-fixture/`) — SSR `/**` path. #4234 swallow contracts |
| 179 | + (missing `.css`/`.js` under `style`/absent/`empty` dests must not 200 — the |
| 180 | + fixture entry renders an HTML page for extensioned misses), JSON API routes |
| 181 | + under `sec-fetch-dest: image` pass through (TanStack/router#7403), #4252 |
| 182 | + deliberate asset serve (`/dynamic-asset.png` → `image/png` passes |
| 183 | + inspection), `HTTPError` propagation, navigations, storage/config sharing. |
| 184 | +- `server-entry.test.ts` (`server-entry-fixture/`) — #4252 custom `server.ts` |
| 185 | + H3 app: asset-extensioned routes reachable under `image`/absent/`script` |
| 186 | + dests (including a no-content-type string return), JSON sourcemap |
| 187 | + (`/generated.js.map`) passes through, `POST` to an asset-extensioned route |
| 188 | + reaches its handler (non-GET/HEAD are never assets), missing-asset 404 |
| 189 | + contract, navigation. |
| 190 | +- `root-wildcard.test.ts` — transparent root catch-all `/**:path`: must never |
| 191 | + 200 for `/entry-client.ts` under `script`/`style`/`image`/absent dests |
| 192 | + (#4234/#4266), navigations still reach it. |
| 193 | +- `baseurl-dotted-param.test.ts` — explicit splat routes under `baseURL: |
| 194 | + /subdir/`: #4241, #4252, #4270, query-string extension, unmatched asset → |
| 195 | + Vite. |
| 196 | +- Tangential: `hmr.test.ts` (existing module served by Vite under `script` |
| 197 | + dest), `openapi.test.ts` (explicit routes), others (build/env, not routing). |
| 198 | + |
| 199 | +## 8. Gotchas |
| 200 | + |
| 201 | +- The `server.middlewares.stack` scan in `nitroDevMiddleware` skips requests |
| 202 | + whose URL starts with any other middleware's mounted `base` (#3805). |
| 203 | +- `_nitroHandled` is a one-way latch set by the pre-pass (transparent |
| 204 | + catch-all asset) and by the post catch-all itself (re-entry guard). Never set |
| 205 | + it for a request a real or opaque handler should still see (#4252 root |
| 206 | + cause). |
| 207 | +- Extension detection must stay path-only (strip `?#`) and `ASSET_EXT_RE` must |
| 208 | + stay narrow, or dotted Nitro params regress (#4108). |
| 209 | +- The inspection's html-only check must not grow `text/plain` (bridge default |
| 210 | + for string returns) or `application/json` (deliberate API serves, #7403), and it must only apply to `envRes.ok` — framework 404 pages |
| 211 | + and redirects pass through verbatim. |
| 212 | +- The websocket `upgrade` handler is a separate concern sharing the "Vite's or |
| 213 | + Nitro's?" theme. |
0 commit comments