Skip to content

Commit 1dfe4e8

Browse files
authored
fix(vite): route asset-tagged requests to opaque catch-alls in dev (#4467)
1 parent 0192bba commit 1dfe4e8

7 files changed

Lines changed: 450 additions & 13 deletions

File tree

.agents/vite-dev.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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`**: fall back to extension — `ASSET_EXT_RE.test(ext)`
69+
**and** no `text/html` in `Accept` ⇒ asset. (`empty` = fetch/XHR is ambiguous:
70+
it tags both API calls and `fetch()`ed assets.)
71+
- **Only `GET`/`HEAD` can be assets at all** — a `POST /upload.png` is never a
72+
browser asset load, so other methods bypass the heuristic entirely.
73+
- Non-asset + (matched or extensionless) → Nitro immediately (pre-Vite).
74+
75+
Two regexes to keep intact:
76+
- `ASSET_EXT_RE` (`dev.ts:24`) — narrow on purpose, so dotted Nitro params like
77+
`/foo.bar.1` still reach Nitro (#4108).
78+
- Extension is extracted from the path only (query/hash stripped) so
79+
`?file=bar.png` does not misclassify.
80+
81+
## 4. Response inspection (`_nitroAssetCheck`)
82+
83+
An asset-tagged request that only an opaque catch-all could handle cannot be
84+
classified **a priori**`/image.png` may be a real custom-entry route (#4252)
85+
or a genuinely missing asset that a naive SSR `/**` would render as a 200 page
86+
(#4234). It *can* be classified from the **response**: after Vite declines and
87+
the post catch-all dispatches to Nitro, a 2xx with a `text/html` content-type
88+
(inline check in the post middleware) means the catch-all rendered a page for a
89+
missing asset → the response is discarded via `next()` (connect `finalhandler`
90+
404, same as before). Anything else — real asset types, JSON, `text/plain`, no
91+
content-type, non-2xx (framework 404 pages, redirects) — passes through
92+
verbatim.
93+
94+
Deny-list rationale (all verified empirically):
95+
96+
- Only `text/html` counts as a swallow. `application/json` was originally
97+
denied too, but that broke real opaque frameworks: TanStack Start answers
98+
API routes tagged as asset loads with JSON on purpose
99+
(`<img src="/api/.../thumbnail">`, TanStack/router#7403, nitro PR #4274),
100+
and sourcemaps (`.map``ASSET_EXT_RE`) are legitimately JSON. The
101+
accepted trade-off: an SSR entry that swallows missing assets with *JSON*
102+
(pathological — real naive SSR renders HTML) now returns 200 JSON instead
103+
of 404 in dev.
104+
- `text/plain` is excluded because a bare string returned from an h3 handler
105+
has **no** content-type at the h3 layer but arrives as `text/plain;
106+
charset=UTF-8` — srvx's node-adapter default applied on the worker's HTTP hop
107+
(`srvx/dist/adapters/node.mjs`; runner-dependent but converges) — and must
108+
pass through (custom-entry handlers returning strings).
109+
- Transparent (user-file) catch-alls can **not** use inspection instead of the
110+
divert: their string 200s arrive with no distinguishing content-type.
111+
112+
This keeps everything zero-config, host-side only (no runtime/bundle change,
113+
no production behavior change). Known leftovers:
114+
115+
- **Production** SSR still renders 200 HTML for missing-asset URLs —
116+
pre-existing behavior; changing it is a separate, deliberate breaking-change
117+
discussion.
118+
- Opaque semantics require registration via `renderer` / `serverEntry`. A `/**`
119+
catch-all added through `routes:` / `handlers:` config or a module is
120+
classified **transparent** and stays pre-empted for asset-tagged requests
121+
(#4252-class limitation) — a framework integrating its renderer that way
122+
should use `renderer` instead.
123+
- Dev-only cost: a missing asset matching an opaque catch-all triggers a full
124+
(discarded) SSR render before the 404.
125+
126+
## 5. Issue / PR lineage (chronological)
127+
128+
- **#3649** first crude "has extension ⇒ Vite" rule; **#3804/#3805/#3817**
129+
middleware improvements, mounted-path skip, internal-prefix skip (survives as
130+
the `^\/(?:__|@)` guard); **#4098** `sec-websocket-protocol` to tell Vite HMR
131+
sockets from Nitro websockets (the `upgrade` handler).
132+
- **#4108** baseURL-aware matching for dotted Nitro routes
133+
(TanStack/router#6903).
134+
- **#4234** non-loopback plain-HTTP origins omit `Sec-Fetch-*` → splat swallowed
135+
`<script src>` loads. Fixed by **#4238**: `Accept` + `ASSET_EXT_RE` fallback +
136+
the `_nitroHandled` marker.
137+
- **#4241** #4238 over-eager: `sec-fetch-dest: image` on a real route
138+
(`/api/image`) sent to Vite → 404. Fixed by `7d49dcae` + `08f2ec69`
139+
(query-string stripped before extension matching).
140+
- **#4252 / #4270** even explicit routes lost when the URL had an asset
141+
extension. Fixed by **#4272** (`5b7e152b`): explicit vs catch-all vs none
142+
classification — **an explicit route is a deterministic win no heuristic can
143+
touch**.
144+
- `7765bcb7` added `isExplicitPublicAsset`.
145+
- **#4252 follow-ups** (katywings, jantimon/TanStack/router#7403): routes the
146+
classifier cannot see — a custom `server.ts` H3 app serving `/image.png`, or
147+
an SSR framework serving assets — were pre-empted by `_nitroHandled` and
148+
could never run. Fixed by the opaque-catch-all + response-inspection design
149+
above (§2, §4): the pre-emption now applies only to transparent user-file
150+
catch-alls, and opaque dispatches are judged by their response content-type.
151+
152+
## 6. Runtime dispatch flow
153+
154+
The catch-all `nitroDevMiddleware` dispatches in two stages:
155+
156+
1. **`ctx.devApp.fetch(req)`**`NitroDevApp` (`src/dev/app.ts`), host-side:
157+
`devHandlers`, `/_vfs/**`, `/_nitro/tasks`, public asset dirs, `devProxy`.
158+
No catch-all → a 404 means "not mine, hand off"; any non-404 is returned
159+
directly (never inspected — deterministic host serves are authoritative).
160+
2. **`nitroEnv.dispatchFetch(req)`** → env-runner → worker → `nitroApp.fetch`
161+
full route table, middleware, catch-alls.
162+
163+
Catch-all registration (`src/routing.ts`): a custom `serverEntry` is pushed as
164+
`/**` with `handler = nitro.options.serverEntry.handler`; the SSR renderer as
165+
`/**` with `handler = nitro.options.renderer.handler` (set by `plugin.ts`
166+
`configResolved` to `internal/vite/ssr-renderer` when an `ssr` service exists).
167+
These handler paths are how the pre-pass identifies opaque catch-alls.
168+
169+
## 7. Test coverage map
170+
171+
All under `test/vite/`; run via `test:rollup` and `test:rolldown`. Each starts
172+
a real Vite dev server and `fetch()`es with hand-set headers.
173+
174+
- `app.test.ts` (`app-fixture/`) — SSR `/**` path. #4234 swallow contracts
175+
(missing `.css`/`.js` under `style`/absent/`empty` dests must not 200 — the
176+
fixture entry renders an HTML page for extensioned misses), JSON API routes
177+
under `sec-fetch-dest: image` pass through (TanStack/router#7403), #4252
178+
deliberate asset serve (`/dynamic-asset.png``image/png` passes
179+
inspection), `HTTPError` propagation, navigations, storage/config sharing.
180+
- `server-entry.test.ts` (`server-entry-fixture/`) — #4252 custom `server.ts`
181+
H3 app: asset-extensioned routes reachable under `image`/absent/`script`
182+
dests (including a no-content-type string return), JSON sourcemap
183+
(`/generated.js.map`) passes through, `POST` to an asset-extensioned route
184+
reaches its handler (non-GET/HEAD are never assets), missing-asset 404
185+
contract, navigation.
186+
- `root-wildcard.test.ts` — transparent root catch-all `/**:path`: must never
187+
200 for `/entry-client.ts` under `script`/`style`/`image`/absent dests
188+
(#4234/#4266), navigations still reach it.
189+
- `baseurl-dotted-param.test.ts` — explicit splat routes under `baseURL:
190+
/subdir/`: #4241, #4252, #4270, query-string extension, unmatched asset →
191+
Vite.
192+
- Tangential: `hmr.test.ts` (existing module served by Vite under `script`
193+
dest), `openapi.test.ts` (explicit routes), others (build/env, not routing).
194+
195+
## 8. Gotchas
196+
197+
- The `server.middlewares.stack` scan in `nitroDevMiddleware` skips requests
198+
whose URL starts with any other middleware's mounted `base` (#3805).
199+
- `_nitroHandled` is a one-way latch set by the pre-pass (transparent
200+
catch-all asset) and by the post catch-all itself (re-entry guard). Never set
201+
it for a request a real or opaque handler should still see (#4252 root
202+
cause).
203+
- Extension detection must stay path-only (strip `?#`) and `ASSET_EXT_RE` must
204+
stay narrow, or dotted Nitro params regress (#4108).
205+
- The inspection's html-only check must not grow `text/plain` (bridge default
206+
for string returns) or `application/json` (deliberate API serves, #7403), and it must only apply to `envRes.ok` — framework 404 pages
207+
and redirects pass through verbatim.
208+
- The websocket `upgrade` handler is a separate concern sharing the "Vite's or
209+
Nitro's?" theme.

src/build/vite/dev.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const WORKERD_BUILTIN_RE = /^(?:cloudflare|workerd):/;
3434

3535
export type FetchHandler = (req: Request) => Promise<Response>;
3636

37+
type NitroDevRequest = IncomingMessage & {
38+
_nitroHandled?: boolean;
39+
_nitroAssetCheck?: boolean;
40+
};
41+
3742
export interface DevServer extends RunnerRPCHooks {
3843
fetch: FetchHandler;
3944
init?: () => void | Promise<void>;
@@ -206,7 +211,7 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
206211
});
207212

208213
const nitroDevMiddleware = async (
209-
nodeReq: IncomingMessage & { _nitroHandled?: boolean },
214+
nodeReq: NitroDevRequest,
210215
nodeRes: ServerResponse,
211216
next: (error?: unknown) => void
212217
) => {
@@ -215,9 +220,7 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
215220
!nodeReq.url ||
216221
/^\/@(?:vite|fs|id)\//.test(nodeReq.url) ||
217222
nodeReq._nitroHandled ||
218-
server.middlewares.stack
219-
.map((mw) => mw.route)
220-
.some((base) => base && nodeReq.url!.startsWith(base))
223+
server.middlewares.stack.some((mw) => mw.route && nodeReq.url!.startsWith(mw.route))
221224
) {
222225
return next();
223226
}
@@ -246,6 +249,20 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
246249
if (nodeRes.writableEnded || nodeRes.headersSent) {
247250
return;
248251
}
252+
// An asset-tagged request Vite already declined that only an opaque catch-all could
253+
// handle: a 2xx `text/html` page means the catch-all swallowed a missing asset (#4234) —
254+
// fall through to the 404 instead. Anything else passes through untouched: JSON is how
255+
// opaque frameworks deliberately answer API routes tagged as asset loads and sourcemaps
256+
// (#4252, TanStack/router#7403), and `text/plain` is the bridge default for bare string
257+
// returns.
258+
if (
259+
nodeReq._nitroAssetCheck &&
260+
envRes.ok &&
261+
/^text\/html\b/i.test(envRes.headers.get("content-type") || "")
262+
) {
263+
await envRes.body?.cancel();
264+
return next();
265+
}
249266
return await sendNodeResponse(nodeRes, envRes);
250267
} catch (error) {
251268
return next(error);
@@ -256,9 +273,19 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
256273
}
257274
};
258275

276+
// Opaque catch-alls: the SSR renderer and a custom server entry (see .agents/vite-dev.md §2).
277+
const isOpaqueHandler = (h?: { handler?: string }) =>
278+
!!h?.handler &&
279+
(h.handler === nitro.options.renderer?.handler ||
280+
h.handler === (nitro.options.serverEntry && nitro.options.serverEntry.handler));
281+
259282
// Handle server routes first to avoid conflicts with static assets served by Vite from the root
260283
// https://github.com/vitejs/vite/pull/20866
261-
server.middlewares.use(function nitroDevMiddlewarePre(req, res, next) {
284+
const nitroDevMiddlewarePre = (
285+
req: NitroDevRequest,
286+
res: ServerResponse,
287+
next: (error?: unknown) => void
288+
) => {
262289
// Vite-internal prefixes (/@vite/client, /__vue-router/auto-routes, ...) are never Nitro's.
263290
if (/^\/(?:__|@)/.test(req.url!)) {
264291
return next();
@@ -309,11 +336,13 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
309336
!!ext && ASSET_EXT_RE.test(ext) && !/\btext\/html\b/.test(req.headers["accept"] || "");
310337

311338
// `document`/`iframe`/`frame` are definite navigations; any other concrete `Sec-Fetch-Dest`
312-
// (`image`, `video`, `style`, ...) is a definite asset load.
339+
// (`image`, `video`, `style`, ...) is a definite asset load. Only `GET`/`HEAD` can be
340+
// browser asset loads at all — other methods are never assets.
313341
const isAsset =
314-
typeof fetchDest === "string" && fetchDest !== "empty"
342+
(!req.method || req.method === "GET" || req.method === "HEAD") &&
343+
(typeof fetchDest === "string" && fetchDest !== "empty"
315344
? !/^(?:document|iframe|frame)$/.test(fetchDest)
316-
: isAssetByExt;
345+
: isAssetByExt);
317346

318347
// Non-asset requests go to Nitro: the catch-all (`matchedHandlers` are all catch-all here,
319348
// since explicit routes already returned) renders them, and bare (extensionless) unmatched
@@ -323,12 +352,20 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
323352
}
324353

325354
if (isAsset) {
326-
// Vite is the definitive handler — mark the request so the catch-all `nitroDevMiddleware`
327-
// registered after Vite doesn't fall back into a splat Nitro route on a 404.
328-
(req as IncomingMessage & { _nitroHandled?: boolean })._nitroHandled = true;
355+
// Opaque catch-alls (the SSR renderer and a custom server entry) route requests Nitro
356+
// cannot see in `nitro.routing.routes` (#4252), so an asset-tagged miss from Vite must
357+
// still be dispatched — the response content-type then decides (`_nitroAssetCheck`).
358+
// A user-file root catch-all is transparent: Nitro sees everything it can handle, so
359+
// Vite stays the definitive asset handler and a Vite miss must not fall back into it.
360+
if (matchedHandlers.every(isOpaqueHandler)) {
361+
req._nitroAssetCheck = true;
362+
} else {
363+
req._nitroHandled = true;
364+
}
329365
}
330366
next();
331-
});
367+
};
368+
server.middlewares.use(nitroDevMiddlewarePre);
332369

333370
return () => {
334371
server.middlewares.use(nitroDevMiddleware);

test/vite/app-fixture/app/entry-server.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ export default {
77
if (req.url.includes("?error")) {
88
throw new HTTPError({ status: 418, headers: { "x-test": "123" } });
99
}
10+
const pathname = new URL(req.url).pathname;
11+
if (pathname === "/dynamic-asset.png") {
12+
return new Response("PNGDATA", { headers: { "content-type": "image/png" } });
13+
}
14+
if (pathname.startsWith("/api-json/")) {
15+
return Response.json({ path: pathname });
16+
}
17+
if (/\.[a-z0-9]+$/i.test(pathname)) {
18+
// Naive SSR shape for extensioned misses: render an HTML page instead of a 404.
19+
// Extensionless paths keep the JSON payload below for the storage/config tests.
20+
return new Response(`<!doctype html><h1>${pathname}</h1>`, {
21+
headers: { "content-type": "text/html" },
22+
});
23+
}
1024
const storage = useStorage();
1125
const config = useRuntimeConfig();
1226
await storage.set("test:key", "value-from-ssr");

0 commit comments

Comments
 (0)