Multi-service dispatch (bun preset): validate resolved service exports a fetch handler instead of a silent 500 on n.fetch is not a function
Summary
Nitro's multi-service dispatch (observed via the bun preset, nitro 3.0.260610-beta) dereferences a resolved service module's .fetch without validating that the module is a WinterCG { fetch } handler. When a service is registered against a module that does not export a fetch handler, the result is a raw TypeError: <x>.fetch is not a function, wrapped as an opaque {"error":true,"status":500,"unhandled":true} on every request routed to that service — with no indication of which service or why.
This is a defensive / DX request: fail loudly at wire-up (or first dispatch) with an actionable error naming the offending service and the expected handler shape, instead of a silent 500 that gives consumers nothing to debug.
Root cause of our concrete case is upstream in the consumer (vinext registers the wrong export) — filed at cloudflare/vinext#3197. Nitro is not "wrong" here; it's doing exactly what it always does. But a validation guard would have turned a multi-day root-cause hunt into a one-line error. Filing per that issue's suggestion.
Where
The generated multi-service dispatch wrapper (source form, from a built bun-preset server):
function n(e) { // lazy, memoized service handle
let t, n;
return {
fetch(r) {
return n
? n.fetch(r)
: (t ||= e().then(e => n = e.default || e), // <-- normalises ESM namespace
t.then(e => e.fetch(r))); // <-- assumes `.fetch` exists
}
};
}
// service registered against a module whose chosen export is NOT a {fetch} handler:
var r = { ssr: n(() => import(`../_ssr/entry.mjs`).then(e => e.t)) };n = e.default || e normalises the module namespace, but nothing checks the result actually has a callable fetch. If it doesn't, n.fetch(r) throws, nitro's prod handler wraps it as unhandled (error.unhandled ?? !HTTPError.isError(error) → true), and the response is the generic 500 envelope. The server-side TypeError is only visible in stderr; the HTTP response carries nothing.
Observed
TypeError: n.fetch is not a function. (In 'n.fetch(r)', 'n.fetch' is undefined)→ {"error":true,"status":500,"unhandled":true} on every dynamic request.
Requested (defensive validation)
At service resolution (or first dispatch), assert the resolved module exposes a callable fetch and throw a clear, actionable error otherwise, e.g.:
Nitro: service "ssr" resolved to a module that does not export a WinterCG `fetch(request)` handler
(got: object with keys [buildId, handleApiRoute, renderPage, ...], no `fetch`, no `default`).
The service entry must default-export (or export) a { fetch(request): Response } handler.This does not fix a mis-registered service (that's the consumer's job) — it converts a silent, un-attributable 500 into an immediate, named, debuggable failure. Ideally validate once at wire-up so it fails at build/boot rather than per-request.
Environment
nitro3.0.260610-beta, presetbun, runtime Bun 1.4.0 (also seen on 1.3.14).- Reproduces on the uncompiled
.output/server/index.mjsunderbun(not a downstream compile/bundle artifact). - Consumer:
[email protected]/beta.9(SSR service registration) — the cause is there (cloudflare/vinext#3197); this issue is only about nitro surfacing the failure usefully.
Minimal repro
Any nitro bun-preset build where a registered service module lacks a fetch export reproduces the silent 500. A concrete end-to-end repro (vinext SSR app) is in cloudflare/vinext#3197; the nitro-specific behaviour is the un-validated .fetch dereference above.
Source: nitrojs/nitro