Environment
- nitro: 3.0.260610-beta
- Node.js: 24.x
Reproduction
nitro.config.ts:
export default defineNitroConfig({
routes: {
"/**": { handler: "~/server/catchall.ts" },
},
});
server/catchall.ts serving a binary response for any path, e.g. a generated PNG:
export default defineHandler((event) => {
return send(event, generatePng(event.path), "image/png");
});
Request it in dev with an <img src="/whatever.png"> tag (browser sends Sec-Fetch-Dest: image), or with curl -H "Sec-Fetch-Dest: image" http://localhost:3000/whatever.png.
What happens
- The same request without the
Sec-Fetch-Dest header, or in production, works fine.
Why
I traced this to src/build/vite/dev.ts's nitroDevMiddlewarePre, which splits ambiguous catch-all matches into "opaque" (Nitro-only, a Vite miss must still be dispatched to Nitro) and "transparent" (Nitro sees everything, so a Vite miss must not fall back into it). The check is:
const isOpaqueHandler = (h?: { handler?: string }) =>
!!h?.handler &&
(h.handler === nitro.options.renderer?.handler ||
h.handler === (nitro.options.serverEntry && nitro.options.serverEntry.handler));
This only recognizes renderer/serverEntry handlers. A /** catch-all registered through routes: or handlers: in nitro.config merges into nitro.routing.routes with the same route: "/**" shape as a file-based catch-all (src/routing.ts), so it isn't an "explicit route" either. It falls into the ambiguous bucket, isOpaqueHandler returns false for it, and it gets classified transparent:
if (matchedHandlers.every(isOpaqueHandler)) {
req._nitroAssetCheck = true;
} else {
req._nitroHandled = true;
}
_nitroHandled = true makes nitroDevMiddleware return next() immediately without ever calling ctx.devApp!.fetch(req), confirmed reading the earlier block in the same file where _nitroHandled short-circuits before the fetch call. So the catch-all handler never runs for asset-tagged requests, and the request 404s through Vite/connect's finalhandler.
The renderer/serverEntry cases were fixed for exactly this class of bug in #4467, but a config-registered /** catch-all hits the same wall. It's a real registration path (routes:/handlers: with a wildcard key/route, no framework SSR involved) and I'd expect it to behave like a routes/[...path].ts file, except a file-based root catch-all is deliberately kept "transparent" on purpose (Nitro's nitro.routing.routes sees it completely, so Vite is authoritative for asset misses), whereas this config path can't be told apart from that case even though the underlying handler is opaque to Vite the same way serverEntry is.
Suggested fix
isOpaqueHandler needs a way to recognize config-registered catch-alls as opaque too, or the config path needs to be marked at registration time (similar to how serverEntry/renderer are tagged) rather than inferred purely from h.handler identity.
Environment
Reproduction
nitro.config.ts:
server/catchall.ts serving a binary response for any path, e.g. a generated PNG:
Request it in dev with an
<img src="/whatever.png">tag (browser sendsSec-Fetch-Dest: image), or withcurl -H "Sec-Fetch-Dest: image" http://localhost:3000/whatever.png.What happens
Sec-Fetch-Destheader, or in production, works fine.Why
I traced this to
src/build/vite/dev.ts'snitroDevMiddlewarePre, which splits ambiguous catch-all matches into "opaque" (Nitro-only, a Vite miss must still be dispatched to Nitro) and "transparent" (Nitro sees everything, so a Vite miss must not fall back into it). The check is:This only recognizes
renderer/serverEntryhandlers. A/**catch-all registered throughroutes:orhandlers:in nitro.config merges intonitro.routing.routeswith the sameroute: "/**"shape as a file-based catch-all (src/routing.ts), so it isn't an "explicit route" either. It falls into the ambiguous bucket,isOpaqueHandlerreturns false for it, and it gets classified transparent:_nitroHandled = truemakesnitroDevMiddlewarereturnnext()immediately without ever callingctx.devApp!.fetch(req), confirmed reading the earlier block in the same file where_nitroHandledshort-circuits before the fetch call. So the catch-all handler never runs for asset-tagged requests, and the request 404s through Vite/connect'sfinalhandler.The
renderer/serverEntrycases were fixed for exactly this class of bug in #4467, but a config-registered/**catch-all hits the same wall. It's a real registration path (routes:/handlers:with a wildcard key/route, no framework SSR involved) and I'd expect it to behave like aroutes/[...path].tsfile, except a file-based root catch-all is deliberately kept "transparent" on purpose (Nitro'snitro.routing.routessees it completely, so Vite is authoritative for asset misses), whereas this config path can't be told apart from that case even though the underlying handler is opaque to Vite the same wayserverEntryis.Suggested fix
isOpaqueHandlerneeds a way to recognize config-registered catch-alls as opaque too, or the config path needs to be marked at registration time (similar to howserverEntry/rendererare tagged) rather than inferred purely fromh.handleridentity.