Skip to content
209 changes: 209 additions & 0 deletions .agents/vite-dev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# Vite Dev Middleware β€” Request Routing

> Scope: the request-routing logic in `src/build/vite/dev.ts` β€” the two
> `server.middlewares.use(...)` registrations (`nitroDevMiddlewarePre` and the
> catch-all `nitroDevMiddleware`): why the current design looks the way it does.

## 1. Where it lives & how it is wired

- Logic: `src/build/vite/dev.ts` β†’ `configureViteDevServer(ctx, server)`.
- Entry: `src/build/vite/plugin.ts:266` β€” the plugin's `configureServer` hook
`return`s the result of `configureViteDevServer`.

Vite's `configureServer` contract: middleware registered **synchronously**
inside the hook runs **before** Vite's internal middlewares; a function
**returned** from the hook runs **after** them (the "post" hook). Nitro uses
both ends:

| Registration | Site | Runs | Role |
|---|---|---|---|
| `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). |
| `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`. |

**Why two, and why pre?** Without the pre-pass, Vite's static/transform
middleware serves files from the project root and would answer server routes
before Nitro sees them (see upstream **vitejs/vite#20866**, which made Vite
consult `sec-fetch-dest` to let document requests fall through β€” the same
header Nitro's classifier leans on).

Facts about Vite's side (verified against vite@8.1.4): Nitro defaults
`appType` to `"custom"` (`plugin.ts:199` β€” user-overridable; the reasoning
below assumes the default), so Vite registers none of
`htmlFallback`/`indexHtml`/`notFound` middlewares, and **every plain miss
`next()`s** (sirv and transformMiddleware never self-emit a 404 for a missing
file/module). The only terminal 404 is connect's `finalhandler`, which sits
**after** the Nitro post-hook. So Vite genuinely hands off every request it
cannot serve β€” the post catch-all is the last handler before `finalhandler`.

## 2. Classification

`nitro.routing.routes` in an SSR app **always** contains a catch-all `/**`, so
"does a Nitro route match?" is nearly always *yes*. The design distinguishes:

- **Explicit route** β€” `route` set, `!== "/**"`, not `startsWith("/**:")` (a
prefixed splat like `/api/photos/**` is explicit). Deterministic β†’ **always
Nitro**, no heuristic may override (#4108, #4241, #4252, #4270).
- **Explicit public asset** β€” a public-asset dir under a non-root `baseURL`
with `fallthrough: false` owns its subtree β†’ Nitro, like an explicit route.
- **Transparent catch-all** β€” a *user route file* at root level
(`routes/[...].ts` β†’ `/**`, `routes/[...slug].ts` β†’ `/**:slug`). Nitro sees
everything it can handle, so Vite stays the definitive asset handler: an
asset-tagged request is marked `_nitroHandled` and a Vite miss must **not**
fall back into it (#4234/#4266).
- **Opaque catch-all** β€” the SSR renderer `/**` or a custom `serverEntry` `/**`
(both registered in `routing.ts` with identifiable `handler` paths). Their
*real* routes are invisible to the host (`server.ts` H3 app routes, framework
routers like TanStack Start), so an asset-tagged miss from Vite must still be
**dispatched** β€” marked `_nitroAssetCheck`, decided by response inspection.
- **None** β€” no match. Extensionless β†’ page navigation β†’ Nitro; asset-tagged β†’
same as opaque (`_nitroAssetCheck`, dispatch after Vite).

## 3. The asset heuristic (catch-all / none case only)

Set `Vary: sec-fetch-dest, accept`, then:

- **`Sec-Fetch-Dest` present & concrete** (not `empty`): `document`/`iframe`/
`frame` = navigation β†’ Nitro; anything else (`image`, `video`, `style`, …) =
asset.
- **Absent or `empty`**: fall back to extension β€” `ASSET_EXT_RE.test(ext)`
**and** no `text/html` in `Accept` β‡’ asset. (`empty` = fetch/XHR is ambiguous:
it tags both API calls and `fetch()`ed assets.)
- **Only `GET`/`HEAD` can be assets at all** β€” a `POST /upload.png` is never a
browser asset load, so other methods bypass the heuristic entirely.
- Non-asset + (matched or extensionless) β†’ Nitro immediately (pre-Vite).

Two regexes to keep intact:
- `ASSET_EXT_RE` (`dev.ts:24`) β€” narrow on purpose, so dotted Nitro params like
`/foo.bar.1` still reach Nitro (#4108).
- Extension is extracted from the path only (query/hash stripped) so
`?file=bar.png` does not misclassify.

## 4. Response inspection (`_nitroAssetCheck`)

An asset-tagged request that only an opaque catch-all could handle cannot be
classified **a priori** β€” `/image.png` may be a real custom-entry route (#4252)
or a genuinely missing asset that a naive SSR `/**` would render as a 200 page
(#4234). It *can* be classified from the **response**: after Vite declines and
the post catch-all dispatches to Nitro, a 2xx with a `text/html` content-type
(inline check in the post middleware) means the catch-all rendered a page for a
missing asset β†’ the response is discarded via `next()` (connect `finalhandler`
404, same as before). Anything else β€” real asset types, JSON, `text/plain`, no
content-type, non-2xx (framework 404 pages, redirects) β€” passes through
verbatim.

Deny-list rationale (all verified empirically):

- Only `text/html` counts as a swallow. `application/json` was originally
denied too, but that broke real opaque frameworks: TanStack Start answers
API routes tagged as asset loads with JSON on purpose
(`<img src="/api/.../thumbnail">`, TanStack/router#7403, nitro PR #4274),
and sourcemaps (`.map` ∈ `ASSET_EXT_RE`) are legitimately JSON. The
accepted trade-off: an SSR entry that swallows missing assets with *JSON*
(pathological β€” real naive SSR renders HTML) now returns 200 JSON instead
of 404 in dev.
- `text/plain` is excluded because a bare string returned from an h3 handler
has **no** content-type at the h3 layer but arrives as `text/plain;
charset=UTF-8` β€” srvx's node-adapter default applied on the worker's HTTP hop
(`srvx/dist/adapters/node.mjs`; runner-dependent but converges) β€” and must
pass through (custom-entry handlers returning strings).
- Transparent (user-file) catch-alls can **not** use inspection instead of the
divert: their string 200s arrive with no distinguishing content-type.

This keeps everything zero-config, host-side only (no runtime/bundle change,
no production behavior change). Known leftovers:

- **Production** SSR still renders 200 HTML for missing-asset URLs β€”
pre-existing behavior; changing it is a separate, deliberate breaking-change
discussion.
- Opaque semantics require registration via `renderer` / `serverEntry`. A `/**`
catch-all added through `routes:` / `handlers:` config or a module is
classified **transparent** and stays pre-empted for asset-tagged requests
(#4252-class limitation) β€” a framework integrating its renderer that way
should use `renderer` instead.
- Dev-only cost: a missing asset matching an opaque catch-all triggers a full
(discarded) SSR render before the 404.

## 5. Issue / PR lineage (chronological)

- **#3649** first crude "has extension β‡’ Vite" rule; **#3804/#3805/#3817**
middleware improvements, mounted-path skip, internal-prefix skip (survives as
the `^\/(?:__|@)` guard); **#4098** `sec-websocket-protocol` to tell Vite HMR
sockets from Nitro websockets (the `upgrade` handler).
- **#4108** baseURL-aware matching for dotted Nitro routes
(TanStack/router#6903).
- **#4234** non-loopback plain-HTTP origins omit `Sec-Fetch-*` β†’ splat swallowed
`<script src>` loads. Fixed by **#4238**: `Accept` + `ASSET_EXT_RE` fallback +
the `_nitroHandled` marker.
- **#4241** #4238 over-eager: `sec-fetch-dest: image` on a real route
(`/api/image`) sent to Vite β†’ 404. Fixed by `7d49dcae` + `08f2ec69`
(query-string stripped before extension matching).
- **#4252 / #4270** even explicit routes lost when the URL had an asset
extension. Fixed by **#4272** (`5b7e152b`): explicit vs catch-all vs none
classification β€” **an explicit route is a deterministic win no heuristic can
touch**.
- `7765bcb7` added `isExplicitPublicAsset`.
- **#4252 follow-ups** (katywings, jantimon/TanStack/router#7403): routes the
classifier cannot see β€” a custom `server.ts` H3 app serving `/image.png`, or
an SSR framework serving assets β€” were pre-empted by `_nitroHandled` and
could never run. Fixed by the opaque-catch-all + response-inspection design
above (Β§2, Β§4): the pre-emption now applies only to transparent user-file
catch-alls, and opaque dispatches are judged by their response content-type.

## 6. Runtime dispatch flow

The catch-all `nitroDevMiddleware` dispatches in two stages:

1. **`ctx.devApp.fetch(req)`** β€” `NitroDevApp` (`src/dev/app.ts`), host-side:
`devHandlers`, `/_vfs/**`, `/_nitro/tasks`, public asset dirs, `devProxy`.
No catch-all β†’ a 404 means "not mine, hand off"; any non-404 is returned
directly (never inspected β€” deterministic host serves are authoritative).
2. **`nitroEnv.dispatchFetch(req)`** β†’ env-runner β†’ worker β†’ `nitroApp.fetch` β†’
full route table, middleware, catch-alls.

Catch-all registration (`src/routing.ts`): a custom `serverEntry` is pushed as
`/**` with `handler = nitro.options.serverEntry.handler`; the SSR renderer as
`/**` with `handler = nitro.options.renderer.handler` (set by `plugin.ts`
`configResolved` to `internal/vite/ssr-renderer` when an `ssr` service exists).
These handler paths are how the pre-pass identifies opaque catch-alls.

## 7. Test coverage map

All under `test/vite/`; run via `test:rollup` and `test:rolldown`. Each starts
a real Vite dev server and `fetch()`es with hand-set headers.

- `app.test.ts` (`app-fixture/`) β€” SSR `/**` path. #4234 swallow contracts
(missing `.css`/`.js` under `style`/absent/`empty` dests must not 200 β€” the
fixture entry renders an HTML page for extensioned misses), JSON API routes
under `sec-fetch-dest: image` pass through (TanStack/router#7403), #4252
deliberate asset serve (`/dynamic-asset.png` β†’ `image/png` passes
inspection), `HTTPError` propagation, navigations, storage/config sharing.
- `server-entry.test.ts` (`server-entry-fixture/`) β€” #4252 custom `server.ts`
H3 app: asset-extensioned routes reachable under `image`/absent/`script`
dests (including a no-content-type string return), JSON sourcemap
(`/generated.js.map`) passes through, `POST` to an asset-extensioned route
reaches its handler (non-GET/HEAD are never assets), missing-asset 404
contract, navigation.
- `root-wildcard.test.ts` β€” transparent root catch-all `/**:path`: must never
200 for `/entry-client.ts` under `script`/`style`/`image`/absent dests
(#4234/#4266), navigations still reach it.
- `baseurl-dotted-param.test.ts` β€” explicit splat routes under `baseURL:
/subdir/`: #4241, #4252, #4270, query-string extension, unmatched asset β†’
Vite.
- Tangential: `hmr.test.ts` (existing module served by Vite under `script`
dest), `openapi.test.ts` (explicit routes), others (build/env, not routing).

## 8. Gotchas

- The `server.middlewares.stack` scan in `nitroDevMiddleware` skips requests
whose URL starts with any other middleware's mounted `base` (#3805).
- `_nitroHandled` is a one-way latch set by the pre-pass (transparent
catch-all asset) and by the post catch-all itself (re-entry guard). Never set
it for a request a real or opaque handler should still see (#4252 root
cause).
- Extension detection must stay path-only (strip `?#`) and `ASSET_EXT_RE` must
stay narrow, or dotted Nitro params regress (#4108).
- The inspection's html-only check must not grow `text/plain` (bridge default
for string returns) or `application/json` (deliberate API serves, #7403), and it must only apply to `envRes.ok` β€” framework 404 pages
and redirects pass through verbatim.
- The websocket `upgrade` handler is a separate concern sharing the "Vite's or
Nitro's?" theme.
61 changes: 49 additions & 12 deletions src/build/vite/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const WORKERD_BUILTIN_RE = /^(?:cloudflare|workerd):/;

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

type NitroDevRequest = IncomingMessage & {
_nitroHandled?: boolean;
_nitroAssetCheck?: boolean;
};

export interface DevServer extends RunnerRPCHooks {
fetch: FetchHandler;
init?: () => void | Promise<void>;
Expand Down Expand Up @@ -206,7 +211,7 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
});

const nitroDevMiddleware = async (
nodeReq: IncomingMessage & { _nitroHandled?: boolean },
nodeReq: NitroDevRequest,
nodeRes: ServerResponse,
next: (error?: unknown) => void
) => {
Expand All @@ -215,9 +220,7 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
!nodeReq.url ||
/^\/@(?:vite|fs|id)\//.test(nodeReq.url) ||
nodeReq._nitroHandled ||
server.middlewares.stack
.map((mw) => mw.route)
.some((base) => base && nodeReq.url!.startsWith(base))
server.middlewares.stack.some((mw) => mw.route && nodeReq.url!.startsWith(mw.route))
) {
return next();
}
Expand Down Expand Up @@ -246,6 +249,20 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
if (nodeRes.writableEnded || nodeRes.headersSent) {
return;
}
// An asset-tagged request Vite already declined that only an opaque catch-all could
// handle: a 2xx `text/html` page means the catch-all swallowed a missing asset (#4234) β€”
// fall through to the 404 instead. Anything else passes through untouched: JSON is how
// opaque frameworks deliberately answer API routes tagged as asset loads and sourcemaps
// (#4252, TanStack/router#7403), and `text/plain` is the bridge default for bare string
// returns.
if (
nodeReq._nitroAssetCheck &&
envRes.ok &&
/^text\/html\b/i.test(envRes.headers.get("content-type") || "")
) {
await envRes.body?.cancel();
return next();
}
return await sendNodeResponse(nodeRes, envRes);
} catch (error) {
return next(error);
Expand All @@ -256,9 +273,19 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
}
};

// Opaque catch-alls: the SSR renderer and a custom server entry (see .agents/vite-dev.md Β§2).
const isOpaqueHandler = (h?: { handler?: string }) =>
!!h?.handler &&
(h.handler === nitro.options.renderer?.handler ||
h.handler === (nitro.options.serverEntry && nitro.options.serverEntry.handler));

// Handle server routes first to avoid conflicts with static assets served by Vite from the root
// https://github.com/vitejs/vite/pull/20866
server.middlewares.use(function nitroDevMiddlewarePre(req, res, next) {
const nitroDevMiddlewarePre = (
req: NitroDevRequest,
res: ServerResponse,
next: (error?: unknown) => void
) => {
// Vite-internal prefixes (/@vite/client, /__vue-router/auto-routes, ...) are never Nitro's.
if (/^\/(?:__|@)/.test(req.url!)) {
return next();
Expand Down Expand Up @@ -309,11 +336,13 @@ export async function configureViteDevServer(ctx: NitroPluginContext, server: Vi
!!ext && ASSET_EXT_RE.test(ext) && !/\btext\/html\b/.test(req.headers["accept"] || "");

// `document`/`iframe`/`frame` are definite navigations; any other concrete `Sec-Fetch-Dest`
// (`image`, `video`, `style`, ...) is a definite asset load.
// (`image`, `video`, `style`, ...) is a definite asset load. Only `GET`/`HEAD` can be
// browser asset loads at all β€” other methods are never assets.
const isAsset =
typeof fetchDest === "string" && fetchDest !== "empty"
(!req.method || req.method === "GET" || req.method === "HEAD") &&
(typeof fetchDest === "string" && fetchDest !== "empty"
? !/^(?:document|iframe|frame)$/.test(fetchDest)
: isAssetByExt;
: isAssetByExt);

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

if (isAsset) {
// Vite is the definitive handler β€” mark the request so the catch-all `nitroDevMiddleware`
// registered after Vite doesn't fall back into a splat Nitro route on a 404.
(req as IncomingMessage & { _nitroHandled?: boolean })._nitroHandled = true;
// Opaque catch-alls (the SSR renderer and a custom server entry) route requests Nitro
// cannot see in `nitro.routing.routes` (#4252), so an asset-tagged miss from Vite must
// still be dispatched β€” the response content-type then decides (`_nitroAssetCheck`).
// A user-file root catch-all is transparent: Nitro sees everything it can handle, so
// Vite stays the definitive asset handler and a Vite miss must not fall back into it.
if (matchedHandlers.every(isOpaqueHandler)) {
req._nitroAssetCheck = true;
} else {
req._nitroHandled = true;
}
}
next();
});
};
server.middlewares.use(nitroDevMiddlewarePre);

return () => {
server.middlewares.use(nitroDevMiddleware);
Expand Down
14 changes: 14 additions & 0 deletions test/vite/app-fixture/app/entry-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ export default {
if (req.url.includes("?error")) {
throw new HTTPError({ status: 418, headers: { "x-test": "123" } });
}
const pathname = new URL(req.url).pathname;
if (pathname === "/dynamic-asset.png") {
return new Response("PNGDATA", { headers: { "content-type": "image/png" } });
}
if (pathname.startsWith("/api-json/")) {
return Response.json({ path: pathname });
}
if (/\.[a-z0-9]+$/i.test(pathname)) {
// Naive SSR shape for extensioned misses: render an HTML page instead of a 404.
// Extensionless paths keep the JSON payload below for the storage/config tests.
return new Response(`<!doctype html><h1>${pathname}</h1>`, {
headers: { "content-type": "text/html" },
});
}
Comment thread
pi0 marked this conversation as resolved.
const storage = useStorage();
const config = useRuntimeConfig();
await storage.set("test:key", "value-from-ssr");
Expand Down
Loading
Loading