Skip to content

Middleware exceptions escape the generated handleRequest and bypass configureServerErrors #382

Description

@everton-dgn

Problem

In Start mode, the generated handleRequest runs the start.middleware chain without a catch:

`export async function handleRequest(request, options = {}) {`,
// `options.event` is the public wrapper->event extension seam: extra
// fields (conventionally `nativeEvent`, the platform's raw request
// object) spread over the event's defaults at creation, so hosts and
// custom server entries can extend what getRequestEvent() answers
// with — no new convention beyond createRequestEvent's own init
// parameter (spreading undefined is a no-op).
` const event = createRequestEvent(request, options.event);`,
// Middleware runs inside the request scope, after event creation —
// getRequestEvent() answers in middleware exactly as in app code, and
// nothing reaches the wire until the outermost middleware returns.
` const response = await provideRequestEvent(event, () =>`,
` runMiddleware(request, (req) => dispatchRequest(req || request, event, options)),`,
` );`,
// The fold runs strictly AFTER the outermost middleware returned:
// headers stay mutable through the whole unwind, and a middleware
// early return (an API handler that never called next()) gets its
// stub writes — cookies set inside the request scope, status — onto
// the wire. Unconditional: page responses come back from
// createSSRResponse committed and pass through untouched.
` return commitEventResponse(response, event);`,

When a middleware throws or rejects in a production build, the exception leaves handleRequest and each host deals with it in its own way:

  • Nitro (h3) logs the original error with console.error and answers 500 with {"error":true,"status":500,"unhandled":true} as JSON. The headers and cookies the chain already wrote to the response stub are lost.
  • start.node logs it and answers a plain-text 500 (src/node-entry/index.ts#L154-L161).
  • The examples/start-ssr server sends e.message back to the client (examples/start-ssr/server.js#L78-L81).

Two consequences go beyond the inconsistent response:

  1. The hook registered with configureServerErrors({ onError }) never sees these failures, although its docs describe it as seeing "the failure that fails a request". Apps that rely on it to keep original errors out of logs, or to forward them to an APM (as start.instrument in feat: start.instrument — a server module awaited to completion before the handler graph loads #365 suggests), lose that for middleware, API handlers mounted in middleware, and start.setup / start.renderMode failures.
  2. A thrown control response turns into a 500. return redirect('/login') from a middleware works, but throw redirect('/login') reaches h3 as a non-Error rejection and becomes an unhandled 500. The server-function endpoint, served by the same handler, already treats a thrown Response as the response.

Reproduction

With @solidjs/vite-plugin 3.0.0-next.46, @solidjs/web 2.0.0-rc.11 and Nitro 3 beta:

// the module passed to start.middleware
export default async function middleware(request: Request, next: () => Promise<Response>) {
  if (new URL(request.url).pathname === '/boom') throw new Error('token=abc123')
  return next()
}

Build for production, serve it and request /boom: the response is Nitro's JSON 500, the server log shows Error: token=abc123 with its stack, and a hook registered with configureServerErrors is not called.

Expected

In production builds, the generated handler contains request failures the way the server-function endpoint does:

  • a thrown Response (except Response.error()) or response envelope becomes the response;
  • anything else goes through the configured server error policy and yields a generic 500 that still carries the stub's headers and cookies;
  • dev keeps the current behavior, so the Vite overlay still shows the original error.

Notes

  • There is no public API today to run a value through the configured policy outside a render, so the handler would have to call the hook registered under the configureServerErrors symbol. A public entry point in @solidjs/web for request failures, with its own kind in ServerErrorSite, would remove that coupling.
  • A later step could render the app's error boundary when the chain fails before the page render, so a failed navigation gets an HTML error page instead of an empty 500. Apps can do that in userland today, but it depends on the single-render rule and on excluding the server-function endpoint.
  • Related: Define the portable application deployment adapter contract #328, since a deploy adapter would benefit from a handler that does not reject for application failures.

I'll follow up with a PR for the containment part.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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