Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/middleware-failure-containment.md
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Component |
void>`. The generated server entry awaits it after the middleware chain has
Expand Down Expand Up @@ -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()` /
Expand Down
8 changes: 8 additions & 0 deletions examples/start-client/src/shell-failure.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
throw new Error('shell-failure-secret');
}
47 changes: 46 additions & 1 deletion examples/start-client/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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 });

Expand Down
12 changes: 11 additions & 1 deletion examples/start-client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
}),
Expand Down
8 changes: 8 additions & 0 deletions examples/start-ssr/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never> {
throw new Error('token=direct-throw-secret');
}
68 changes: 66 additions & 2 deletions examples/start-ssr/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Response>;

// A minimal filesystem-routing/createAPIHandler stand-in: owns /api/* and
Expand All @@ -39,6 +76,9 @@ async function api(request: Request, next: Next): Promise<Response> {
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
Expand Down Expand Up @@ -120,7 +160,31 @@ async function first(request: Request, next: Next): Promise<Response> {
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
Expand Down
6 changes: 5 additions & 1 deletion examples/start-ssr/src/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).user ?? 'anonymous');
const cookieHeader = event.request.headers.get('cookie');
// One-shot: cleared whether or not it decodes (a tampered or stale cookie
Expand Down
Loading