Skip to content

Vite dev: config-registered /** catch-all handlers (routes:/handlers:) are classified transparent and never dispatched for asset-tagged requests #4531

Description

@MILLERMARRU

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

  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions