diff --git a/.changeset/middleware-failure-containment.md b/.changeset/middleware-failure-containment.md new file mode 100644 index 00000000..9912cae7 --- /dev/null +++ b/.changeset/middleware-failure-containment.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': patch +--- + +The Start handler now settles failures that escape the middleware chain instead of letting them reject to the host. A thrown `Response`, or the `Response` a thrown `respond()` envelope carries, becomes the response in dev and production, so `throw redirect()` works from middleware. In a production build any other failure (a middleware throw, a `start.setup` or `start.renderMode` module failure) is reported once to the `configureServerErrors` hook as `{ kind: 'render', handling: 'failed' }` with the request event, or logged with `console.error` without a hook, and answered with a bodyless 500. That 500 keeps the headers and cookies written to the request event while the response head is still open, that is, unless `next()` already returned a rendered page. In dev those failures still reject, so the dev server sees the original error. An invalid `renderMode` passed to `handleRequest` still rejects the call. A client-mode build now fails when prerendering the shell answers a non-2xx status, instead of writing that response to `index.html`. diff --git a/README.md b/README.md index 4c22648a..5b5a25f5 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,25 @@ decoration is visible to server functions too). Nothing reaches the wire until the outermost middleware returns: headers stay mutable after `next()` even for streamed responses. +Whatever escapes the chain is settled at the handler edge. A thrown +`Response` is the response, so `throw redirect('/login')` answers the 302 +(as the server-function endpoint does), and so is the `Response` a thrown +`respond()` envelope carries; `Response.error()` is not a response and +counts as a failure. In a production build any other failure (a middleware +throw, a `setup` or `renderMode` module failure) is contained by the +handler instead of rejecting to the host. It is reported once to the hook +registered with `configureServerErrors` from `@solidjs/web`, with the site +a failed render reports (`{ kind: 'render', handling: 'failed' }` and the +request `event`), or logged with `console.error` when no hook is +registered, and the client gets a bodyless 500. That 500 carries the +headers and cookies written to the request event only while the response +head is still open: once `next()` has returned a rendered page, the head +was committed with that page, and a later throw drops them. An error +middleware still sees a throw first, and with `errorBoundary` on, render +errors are handled by the boundary before they get this far. In dev those +failures still reject, so the dev server sees the original error (with the +built-in dev middleware, Vite's error middleware and its overlay). + **`setup`** points at a server-only module default-exporting a per-request app-setup hook: `(event, App) => Component | void | Promise`. The generated server entry awaits it after the middleware chain has @@ -574,7 +593,9 @@ export default function renderMode(event: RequestEvent) { Hosts driving the handler directly can decide per call instead: `handleRequest(request, { renderMode: 'async' })`. Precedence is that runtime option, then the module function's result, then the static config; -an unknown value from any of the three is an error naming its source. The +an unknown value from any of the three is an error naming its source (a +bad runtime option rejects the `handleRequest` call; a bad module result is +a request failure, contained in production like any other). The mode applies to generated and authored entries alike — an authored `render()` returning a `renderToStream` result is awaited the same way (and in production its client-entry reference is still rewritten). `httpStatus()` / diff --git a/examples/start-client/src/shell-failure.ts b/examples/start-client/src/shell-failure.ts new file mode 100644 index 00000000..b69108d2 --- /dev/null +++ b/examples/start-client/src/shell-failure.ts @@ -0,0 +1,8 @@ +// Prerender failure fixture (prod mode): vite.config.ts wires this through +// `start.middleware` only when SOLID_SHELL_FAIL=1. The chain throws while +// the build prerenders the shell; the built handler contains that as a +// bodyless 500, and the client-mode build must fail instead of writing the +// 500 to dist/client/index.html. +export default async function shellFailure(): Promise { + throw new Error('shell-failure-secret'); +} diff --git a/examples/start-client/test/run.mjs b/examples/start-client/test/run.mjs index 33b3fc3c..e9dd7594 100644 --- a/examples/start-client/test/run.mjs +++ b/examples/start-client/test/run.mjs @@ -14,7 +14,9 @@ // script from client-mode shells), // - build: `vite build` emits a purely static dist/client — index.html is // the shell prerendered through the built handler, referencing the -// hashed entry script and CSS links — and NO dist/server, +// hashed entry script and CSS links — and NO dist/server; a shell the +// handler answers with a non-2xx status (SOLID_SHELL_FAIL=1: a +// middleware throw it contains as a 500) fails the build instead, // - preview: `vite preview` serves the static build with history fallback // and the app boots from it. // @@ -334,6 +336,49 @@ async function devMode() { async function prodMode() { console.log('\n== prod =='); + // A shell that fails to prerender fails the build. The fixture middleware + // (SOLID_SHELL_FAIL=1) throws while the build prerenders the shell; the + // built handler contains that as a bodyless 500 instead of rejecting, so + // without the check an empty index.html would ship. + rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true }); + const failedBuild = await new Promise((resolve) => { + let output = ''; + const child = spawn('pnpm', ['exec', 'vite', 'build'], { + cwd: exampleDir, + env: { ...process.env, SOLID_SHELL_FAIL: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.add(child); + child.stdout.on('data', (d) => (output += d)); + child.stderr.on('data', (d) => (output += d)); + child.on('exit', (code) => { + children.delete(child); + resolve({ code, output }); + }); + }); + record( + 'prod', + 'prerender', + 'a shell prerender that answers non-2xx fails the build', + failedBuild.code !== 0 && + failedBuild.output.includes( + 'prerendering the client-mode shell failed: the handler answered 500', + ), + `exit ${failedBuild.code}: ${failedBuild.output.slice(-400)}`, + ); + record( + 'prod', + 'prerender', + 'the contained failure reaches console.error (no hook registered)', + failedBuild.output.includes('shell-failure-secret'), + ); + record( + 'prod', + 'prerender', + 'no dist/client/index.html written for the failed shell', + !existsSync(path.join(exampleDir, 'dist/client/index.html')), + ); + rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true }); await runCommand('pnpm', ['exec', 'vite', 'build'], { cwd: exampleDir }); diff --git a/examples/start-client/vite.config.ts b/examples/start-client/vite.config.ts index 5220899d..b22f02c4 100644 --- a/examples/start-client/vite.config.ts +++ b/examples/start-client/vite.config.ts @@ -23,11 +23,21 @@ import solidPlugin from '@solidjs/vite-plugin'; // kept handler. SOLID_START_NODE_ONLY=1 sets `start.node` alone (no server // functions): the purely static build has no server bundle to wrap, so the // build warns and emits nothing. +// +// SOLID_SHELL_FAIL=1 (prod mode's failing build) wires src/shell-failure.ts +// through `start.middleware`: the chain throws while the build prerenders the +// shell, and the build must fail rather than write the handler's contained +// 500 to dist/client/index.html. const startNode = !!process.env.SOLID_START_NODE || !!process.env.SOLID_START_NODE_ONLY; +const shellFail = !!process.env.SOLID_SHELL_FAIL; export default defineConfig({ plugins: [ solidPlugin({ - start: startNode ? { node: true } : true, + start: startNode + ? { node: true } + : shellFail + ? { middleware: './src/shell-failure.ts' } + : true, ssr: !!process.env.SOLID_FLIP_SSR, ...(process.env.SOLID_START_NODE ? { serverFunctions: true } : {}), }), diff --git a/examples/start-ssr/src/api.ts b/examples/start-ssr/src/api.ts index 6a9c40fb..510240f5 100644 --- a/examples/start-ssr/src/api.ts +++ b/examples/start-ssr/src/api.ts @@ -58,3 +58,11 @@ export async function nativeAddress() { export async function configureProbe() { return 'configure-probe'; } + +// Containment probe (src/middleware.ts, /mw-direct-throw): called in-process +// from the outermost middleware and left uncaught. The runtime reports the +// direct call's failure before rethrowing, so the configureServerErrors hook +// must hear it once even though the handler's containment sees it too. +export async function failDirect(): Promise { + throw new Error('token=direct-throw-secret'); +} diff --git a/examples/start-ssr/src/middleware.ts b/examples/start-ssr/src/middleware.ts index 4ae1dd74..cc657d83 100644 --- a/examples/start-ssr/src/middleware.ts +++ b/examples/start-ssr/src/middleware.ts @@ -17,7 +17,25 @@ // with bodies, and no-JS form POSTs must all reach the chain — in dev // exactly as in production — while non-page requests the chain does NOT // handle fall back to Vite's own pipeline in dev. -import { getRequestEvent } from '@solidjs/web'; +// - failures escaping the whole chain (the handler's containment), all from +// the outermost middleware and outside its try/catch, so nothing in the +// chain catches them: /mw-throw writes a stub cookie and throws an Error +// with a secret-looking message, /mw-throw-late throws after next() +// returned the page, /mw-redirect throws redirect(), /mw-envelope throws a +// respond() envelope, /mw-response-error throws Response.error(), +// /mw-direct-throw lets an in-process server-function failure escape, and +// /setup-throw skips the error middleware so the start.setup failure +// (src/setup.tsx) reaches the handler. SSR_SERVER_ERRORS=1 registers a +// configureServerErrors hook that records what it hears (read back from +// /api/server-errors) instead of logging. +import { + configureServerErrors, + getRequestEvent, + isResponseEnvelope, + redirect, + respond, +} from '@solidjs/web'; +import { failDirect } from './api'; // `start.instrument` evidence (SSR_INSTRUMENT=1): this module evaluates as // part of the handler graph — after `@solidjs/web` above — so what the @@ -29,6 +47,25 @@ const instrumentAtLoad = globalThis.__solidInstrument : null; globalThis.__solidInstrument?.order.push('middleware'); +const serverErrors: { message: string; kind: string; handling: string; event: boolean }[] = []; +if (process.env.SSR_SERVER_ERRORS) { + configureServerErrors({ + onError(error, { kind, handling, event }) { + // Non-Error values are named by shape so the checks can tell a thrown + // Response (never reported) from Response.error() (a failure). + const message = + error instanceof Error + ? error.message + : error instanceof Response + ? `Response ${error.status}` + : isResponseEnvelope(error) + ? `ResponseEnvelope ${error.response.status}` + : String(error); + serverErrors.push({ message, kind, handling, event: !!event }); + }, + }); +} + type Next = (request?: Request) => Promise; // A minimal filesystem-routing/createAPIHandler stand-in: owns /api/* and @@ -39,6 +76,9 @@ async function api(request: Request, next: Next): Promise { const event = getRequestEvent()!; return Response.json({ user: event.locals.user, order: event.locals.order }); } + if (request.method === 'GET' && pathname === '/api/server-errors') { + return Response.json(serverErrors); + } if (request.method === 'GET' && pathname === '/api/native') { // The `options.event` seam: the plugin's dev/preview middlewares (and a // Node production entry like server.js) pass the raw Node request as @@ -120,7 +160,31 @@ async function first(request: Request, next: Next): Promise { const event = getRequestEvent()!; event.locals.order = ['first']; event.locals.user = 'mw-user'; - if (new URL(request.url).pathname === '/blocked') { + const { pathname } = new URL(request.url); + if (pathname === '/mw-throw') { + // Uncaught: only the handler's containment can still carry this stub + // cookie onto the wire, and must keep the message off it. + event.response.headers.append('set-cookie', 'mw-throw=1; Path=/'); + throw new Error('token=mw-throw-secret'); + } + if (pathname === '/mw-throw-late') { + // The page came back (its render committed the stub), then the chain + // fails anyway: contained all the same, with that page dropped. + await next(); + throw new Error('token=late-throw-secret'); + } + if (pathname === '/mw-redirect') throw redirect('/redirected-target'); + if (pathname === '/mw-envelope') { + throw respond({ contained: 'envelope' }, { status: 409, headers: { 'x-envelope': '1' } }); + } + if (pathname === '/mw-response-error') throw Response.error(); + // The runtime reports a failed in-process server-function call itself + // before rethrowing; the handler must not report it a second time. + if (pathname === '/mw-direct-throw') await failDirect(); + // Past the error middleware below on purpose: the start.setup failure + // must escape the chain. + if (pathname === '/setup-throw') return next(); + if (pathname === '/blocked') { // Early return: this Response never goes through createSSRResponse, so // the stub write below only reaches the wire through the handler // edge's commitEventResponse fold after the chain unwinds — the e2e diff --git a/examples/start-ssr/src/setup.tsx b/examples/start-ssr/src/setup.tsx index ef26771f..c0a04c7f 100644 --- a/examples/start-ssr/src/setup.tsx +++ b/examples/start-ssr/src/setup.tsx @@ -26,8 +26,12 @@ export default async function setup(event: RequestEvent, App: Component) { // Simulates the router's pre-render load; must complete before the shell // streams, so the marker below always lands in the first chunk. await new Promise((resolve) => setTimeout(resolve, 10)); - const seq = ++invocations; const pathname = new URL(event.request.url).pathname; + // Containment probe: a setup failure escapes the chain (src/middleware.ts + // lets this path past its error middleware), so the handler contains it. + // Thrown before the count, which stays one per rendered request. + if (pathname === '/setup-throw') throw new Error('token=setup-throw-secret'); + const seq = ++invocations; const user = String((event.locals as Record).user ?? 'anonymous'); const cookieHeader = event.request.headers.get('cookie'); // One-shot: cleared whether or not it decodes (a tampered or stale cookie diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 4880a89b..28b9773e 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -97,7 +97,14 @@ // middleware catching a render throw, and the post-next() // header-mutation window on a streamed response — in dev and prod, // plus codegen string assertions that the generated handler resolves -// commitEventResponse from @solidjs/web and folds after the unwind, +// commitEventResponse from @solidjs/web and folds after the unwind; +// a thrown redirect() or respond() envelope escaping the chain is the +// response (dev included), and any other escaping failure (middleware, +// start.setup, an in-process server-function call) is contained in +// production (preview and node too): a generic 500 with the stub +// cookie, the configureServerErrors hook hearing each error once (or +// console.error without one), while dev still hands it to the Vite +// overlay and its handler reports nothing itself, // - `vite preview` serves the production artifact with no server file: // dist/client statically, everything else (pages, /_server, middleware, // the lifecycle) through the built handler, @@ -3246,6 +3253,197 @@ async function runMiddlewareChecksOverHttp(mode, origin, functionId) { } } +// Failures escaping the whole middleware chain (src/middleware.ts throws +// from `first`, the outermost, outside its error middleware). A thrown +// Response, or the Response a thrown respond() envelope carries, is the +// response in dev and production alike. Production builds contain +// everything else in the generated handler: the configureServerErrors hook +// (or console.error without one) hears it once and the client gets a +// bodyless 500, carrying the stub's cookies while the response head is still +// open, so no host sees a rejection. Dev rethrows it to Vite's error +// middleware (the overlay). `hooked`: the server runs with +// SSR_SERVER_ERRORS=1, so middleware.ts registered a recording hook. +// `setup`: start.setup is wired (SSR_SETUP=1), so /setup-throw fails there. +async function runContainmentChecks( + mode, + origin, + { dev = false, hooked = false, setup = false, getLog } = {}, +) { + const marker = 'mw-throw-secret'; + const markers = ['mw-throw-secret', 'late-throw-secret', 'direct-throw-secret']; + if (setup) markers.push('setup-throw-secret'); + // Only what the server logs from here on (the log may span servers). + const logStart = getLog ? getLog().length : 0; + const logSince = () => getLog().slice(logStart); + const heardErrors = async () => { + const res = await fetch(origin + '/api/server-errors', { + headers: { accept: 'application/json' }, + }); + return res.ok ? await res.json() : null; + }; + const get = (pathname) => + fetch(origin + pathname, { redirect: 'manual', headers: { accept: 'text/html' } }); + + // Thrown control responses: the response itself, on every surface. + const redirected = await get('/mw-redirect'); + await redirected.arrayBuffer(); + record( + mode, + 'contain', + 'thrown redirect() becomes the response (302 + Location)', + redirected.status === 302 && redirected.headers.get('location') === '/redirected-target', + `status ${redirected.status}, location ${JSON.stringify(redirected.headers.get('location'))}`, + ); + const enveloped = await get('/mw-envelope'); + const envelopeBody = await enveloped.text(); + record( + mode, + 'contain', + 'thrown respond() envelope answers its Response (status, header, JSON body)', + enveloped.status === 409 && + enveloped.headers.get('x-envelope') === '1' && + envelopeBody === JSON.stringify({ contained: 'envelope' }), + `status ${enveloped.status}, x-envelope ${enveloped.headers.get('x-envelope')}, body ${JSON.stringify(envelopeBody.slice(0, 80))}`, + ); + + const thrown = await get('/mw-throw'); + const thrownBody = await thrown.text(); + if (dev) { + record( + mode, + 'contain', + 'dev: an escaping middleware throw still reaches the Vite overlay (500 with the error)', + thrown.status === 500 && thrownBody.includes(marker), + `status ${thrown.status}, body ${JSON.stringify(thrownBody.slice(0, 80))}`, + ); + if (hooked) { + const heard = await heardErrors(); + record( + mode, + 'contain', + 'dev: the handler reports nothing itself (the hook never hears the throw)', + Array.isArray(heard) && !heard.some((e) => e.message.includes(marker)), + JSON.stringify(heard), + ); + } + return; + } + record( + mode, + 'contain', + 'escaping middleware throw answers a generic 500 (no body, no error details)', + thrown.status === 500 && thrownBody === '', + `status ${thrown.status}, body ${JSON.stringify(thrownBody.slice(0, 80))}`, + ); + const thrownCookies = (thrown.headers.getSetCookie ? thrown.headers.getSetCookie() : []).filter( + (cookie) => cookie.startsWith('mw-throw='), + ); + record( + mode, + 'contain', + 'stub cookie written before the throw arrives exactly once', + thrownCookies.length === 1 && thrownCookies[0].startsWith('mw-throw=1'), + `set-cookie: ${JSON.stringify(thrownCookies)}`, + ); + // The other escaping shapes all answer the same generic 500: a throw + // after next() returned the page, Response.error() (not a response to + // send), an uncaught in-process server-function failure, and a + // start.setup failure. + const generic = [ + ['/mw-throw-late', 'a throw after next() returned the page'], + ['/mw-response-error', 'thrown Response.error()'], + ['/mw-direct-throw', 'an uncaught in-process server-function failure'], + ...(setup ? [['/setup-throw', 'a start.setup failure']] : []), + ]; + for (const [pathname, label] of generic) { + const res = await get(pathname); + const body = await res.text(); + record( + mode, + 'contain', + `${label} answers the generic 500`, + res.status === 500 && body === '', + `status ${res.status}, body ${JSON.stringify(body.slice(0, 80))}`, + ); + } + if (hooked) { + const heard = await heardErrors(); + const heardFor = (secret) => + Array.isArray(heard) ? heard.filter((e) => e.message.includes(secret)) : []; + const failedOnce = (secret) => { + const failures = heardFor(secret); + return ( + failures.length === 1 && + failures[0].kind === 'render' && + failures[0].handling === 'failed' && + failures[0].event === true + ); + }; + record( + mode, + 'contain', + 'configureServerErrors hook hears the failure once (render/failed, with the event)', + failedOnce('mw-throw-secret') && failedOnce('late-throw-secret'), + JSON.stringify(heard), + ); + if (setup) { + record( + mode, + 'contain', + 'a start.setup failure reaches the hook once (render/failed, with the event)', + failedOnce('setup-throw-secret'), + JSON.stringify(heard), + ); + } + // The runtime reported the direct call first (server-function/thrown); + // the handler's report of the same error object must not repeat it. + const direct = heardFor('direct-throw-secret'); + record( + mode, + 'contain', + 'an in-process server-function failure is heard once, as the runtime first reported it', + direct.length === 1 && + direct[0].kind === 'server-function' && + direct[0].handling === 'thrown', + JSON.stringify(direct), + ); + record( + mode, + 'contain', + 'thrown Responses and envelopes are not reported; Response.error() is', + Array.isArray(heard) && + !heard.some( + (e) => e.message === 'Response 302' || e.message.startsWith('ResponseEnvelope'), + ) && + heard.filter((e) => e.message === 'Response 0').length === 1, + JSON.stringify(heard), + ); + // The hook replaced the log: give stderr a moment, then no original + // may be in it. + await new Promise((r) => setTimeout(r, 250)); + record( + mode, + 'contain', + 'with a hook, the original errors stay out of the server log', + !markers.some((secret) => logSince().includes(secret)), + logSince().slice(-300), + ); + } else { + let logged = false; + for (let i = 0; i < 20 && !logged; i++) { + logged = markers.every((secret) => logSince().includes(secret)); + if (!logged) await new Promise((r) => setTimeout(r, 100)); + } + record( + mode, + 'contain', + 'without a hook, the original errors go to console.error', + logged, + logSince().slice(-300), + ); + } +} + async function runMiddlewareMode() { console.log(`\n=== MIDDLEWARE ===`); const devPort = 3172; @@ -3255,11 +3453,14 @@ async function runMiddlewareMode() { // in front anyway. // SSR_INSTRUMENT rides along too: the instrument module's evidence is a // header the middleware sets, so it needs the chain in front as well. + // SSR_SERVER_ERRORS: the recording configureServerErrors hook the + // containment checks read back. const env = { ...process.env, SSR_MIDDLEWARE: '1', SSR_SETUP: '1', SSR_INSTRUMENT: '1', + SSR_SERVER_ERRORS: '1', SSR_DEVTOOLS: '0', }; @@ -3327,6 +3528,19 @@ async function runMiddlewareMode() { unwind !== -1 && fold !== -1 && fold > unwind, `runMiddleware @ ${unwind}, fold @ ${fold}`, ); + // Dev containment is the thrown-Response half only: the catch answers + // a thrown Response or envelope and rethrows everything else to + // Vite's error middleware. Reporting (reportServerError from + // solid-js/internal) and the bodyless 500 are build-only. + record( + 'mw-codegen', + 'gen', + 'dev handler catch rethrows failures (no reportServerError, no synthesized 500)', + code.includes('function containFailure') && + code.includes('throw error') && + !code.includes('reportServerError') && + !code.includes('solid-js/internal'), + ); // The generated entry-server threads start.setup: awaited with the // request event before renderToStream, its result (or App) rendered. const entry = await probe.environments.ssr.transformRequest( @@ -3399,6 +3613,7 @@ async function runMiddlewareMode() { const clientModule = await (await fetch(devOrigin + '/src/api.ts')).text(); functionId = extractFunctionId(clientModule, 'whoAmI'); await runMiddlewareChecksOverHttp('mw-dev', devOrigin, functionId); + await runContainmentChecks('mw-dev', devOrigin, { dev: true, hooked: true }); // Dev-only: a non-page request the chain does NOT handle falls back to // Vite's pipeline (its 404) instead of getting the page rendered at it. const unhandledPost = await fetch(devOrigin + '/no-such-route', { method: 'POST' }); @@ -3444,6 +3659,11 @@ async function runMiddlewareMode() { // Identity-keyed ids are the same in dev and prod (solidjs/solid#3109). const prodId = functionId; await runMiddlewareChecksOverHttp('mw-prod', prodOrigin, prodId); + await runContainmentChecks('mw-prod', prodOrigin, { + hooked: true, + setup: true, + getLog: () => serverLog, + }); await runHttpChecks('mw-prod', prodOrigin); } catch (e) { record( @@ -3480,7 +3700,15 @@ async function runPreviewMode() { // entry that threads it exactly like dev and prod. // SSR_INSTRUMENT too: `vite preview` serves the built handler, so the // instrument sequencing is asserted on the third surface here. - const env = { ...process.env, SSR_MIDDLEWARE: '1', SSR_SETUP: '1', SSR_INSTRUMENT: '1' }; + // SSR_SERVER_ERRORS: the containment checks' recording hook, as in + // middleware mode. + const env = { + ...process.env, + SSR_MIDDLEWARE: '1', + SSR_SETUP: '1', + SSR_INSTRUMENT: '1', + SSR_SERVER_ERRORS: '1', + }; let server; let serverLog = ''; @@ -3638,6 +3866,11 @@ async function runPreviewMode() { // The full chain contract — API GETs/POSTs and no-JS form POSTs // included — holds under preview like dev and prod. await runMiddlewareChecksOverHttp(mode, origin, null); + await runContainmentChecks(mode, origin, { + hooked: true, + setup: true, + getLog: () => serverLog, + }); await runHttpChecks(mode, origin); } catch (e) { @@ -4109,6 +4342,24 @@ async function runRenderModeMode() { 'handleRequest({ renderMode: "stream" }) beats the static async config', forcedStream.status === 200 && isStreamedMarkup(forcedStream.html), ); + // A bad per-call option is the host's own error: the built handler + // rejects it up front instead of containing it as a request failure. + let prodRejection = ''; + try { + await built.handleRequest( + new Request(prodAsyncOrigin + '/', { headers: { accept: 'text/html' } }), + { renderMode: 'bogus' }, + ); + } catch (e) { + prodRejection = String(e && e.message ? e.message : e); + } + record( + 'rm-prod-async', + 'override', + 'invalid runtime renderMode still rejects from the built handler (not contained)', + prodRejection.includes('renderMode') && prodRejection.includes('bogus'), + prodRejection.slice(0, 200) || 'resolved without error', + ); server = spawnProd(prodAsyncPort, { SSR_RENDER_MODE: 'async' }); captureLog(server); @@ -5297,6 +5548,9 @@ async function runNodeMode() { echo.status === 200 && echoBody?.echoed?.via === 'node-entry', `status ${echo.status}, body ${JSON.stringify(echoBody)}`, ); + // The handler contains a middleware failure before the entry's own + // catch could; no hook registered here, so the fallback log is asserted. + await runContainmentChecks(mode, origin, { getLog: () => serverLog }); const bogus = await fetch(origin + '/_server/bogus-0', { method: 'POST' }); record( mode, diff --git a/src/ssr/index.ts b/src/ssr/index.ts index edd419bb..c8a29b39 100644 --- a/src/ssr/index.ts +++ b/src/ssr/index.ts @@ -1149,7 +1149,8 @@ export function startServe( const composeServerFunctions = internal.serverFunctions; const lines = [ - `import { createRequestEvent, createSSRResponse, commitEventResponse${middlewarePath ? ', composeMiddleware' : ''} } from '@solidjs/web';`, + `import { createRequestEvent, createSSRResponse, commitEventResponse${middlewarePath ? ', composeMiddleware' : ''}, isResponseEnvelope } from '@solidjs/web';`, + ...(isBuild ? [`import { reportServerError } from 'solid-js/internal';`] : []), `import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`, `import * as entry from ${JSON.stringify(entryServerSpec())};`, ...(middlewarePath @@ -1430,7 +1431,71 @@ export function startServe( ` });`, `}`, ``, + ); + + // Failure containment for whatever escapes the chain. A thrown Response + // is the response, like the server-function endpoint's plain-HTTP answer + // (`throw redirect()` included), and so is the Response a thrown + // envelope carries (its plain-HTTP form); `Response.error()` (status 0) + // is not a response to send. The classification is guarded, so a + // hostile thrown value (a revoked Proxy) reads as an ordinary failure + // instead of escaping the catch. + // + // In dev that is all: any other failure rethrows, so the dev server sees + // the original error (with the built-in dev middleware, Vite's error + // middleware and its overlay). + // + // In a build any other failure (a middleware throw, a start.setup or + // start.renderMode module failure, a render that throws before + // renderToStream returns) must not leave handleRequest: each host would + // answer it its own way and the configured error policy would never see + // it. It is reported the way `failRender` reports a failed render, + // through the runtime's `reportServerError` (from solid-js/internal, as + // @solidjs/web does; there is no public entry point for request + // failures) with the site `{ kind: 'render', handling: 'failed' }` and + // the event, and to console.error when no `configureServerErrors` hook + // is registered. The runtime's ledger calls the hook once per error + // object, so a failure it already reported (an uncaught in-process + // server-function call, heard as `server-function`/`thrown`) is not + // heard twice. The answer is a bodyless 500 like the endpoint's. The + // edge fold below adds the stub's headers and cookies while the + // response head is still open; once next() returned a rendered page, + // the stub was committed with that page and the 500 goes out without + // them. + lines.push( + ...(isBuild + ? [ + `const SERVER_ERRORS = Symbol.for('solid-js/server/errors');`, + ``, + `function containFailure(error, event) {`, + ` try {`, + ` const thrown = isResponseEnvelope(error) ? error.response : error;`, + ` if (thrown instanceof Response && thrown.status !== 0) return thrown;`, + ` } catch {}`, + ` reportServerError(error, { kind: 'render', handling: 'failed', event });`, + ` const slot = globalThis[SERVER_ERRORS];`, + ` if (!(slot && slot.hook)) console.error(error);`, + ` return new Response(null, { status: 500 });`, + `}`, + ] + : [ + `function containFailure(error) {`, + ` try {`, + ` const thrown = isResponseEnvelope(error) ? error.response : error;`, + ` if (thrown instanceof Response && thrown.status !== 0) return thrown;`, + ` } catch {}`, + ` throw error;`, + `}`, + ]), + ``, + ); + + lines.push( `export async function handleRequest(request, options = {}) {`, + // A bad per-call option is the host's own error, not the app's: it + // rejects up front, before the chain runs, instead of being contained + // as a request failure. + ` if (options.renderMode !== undefined) assertRenderMode(options.renderMode, 'handleRequest options.renderMode');`, // `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 @@ -1441,9 +1506,13 @@ export function startServe( // 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)),`, - ` );`, + ` const response = await provideRequestEvent(event, async () => {`, + ` try {`, + ` return await runMiddleware(request, (req) => dispatchRequest(req || request, event, options));`, + ` } catch (error) {`, + ` return containFailure(error, event);`, + ` }`, + ` });`, // 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 @@ -2030,6 +2099,14 @@ export function startServe( const response: Response = await handler.handleRequest( new Request(new URL(base || '/', 'http://localhost')), ); + // The built handler answers failures with a 500 instead of + // rejecting; a shell that did not render must fail the + // build, not become index.html. + if (!response.ok) { + throw new Error( + `[@solidjs/vite-plugin] prerendering the client-mode shell failed: the handler answered ${response.status}`, + ); + } writeFileSync(path.resolve(root, 'dist/client/index.html'), await response.text()); if (!internal.serverFunctions) { rmSync(serverDir, { recursive: true, force: true });