#4606·nitro

Multi-service dispatch (bun preset): validate resolved service exports a fetch handler instead of a silent 500 on n.fetch is not a function

Author: AhmedElBanna80Created Sep 9, 2026Updated Sep 9, 2026

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):

javascript
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

  • nitro 3.0.260610-beta, preset bun, runtime Bun 1.4.0 (also seen on 1.3.14).
  • Reproduces on the uncompiled .output/server/index.mjs under bun (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.