From 1702a19873449d78b618c87605bc134179ed9a9e Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 13:11:20 -0700 Subject: [PATCH] feat: put the CSP nonce and render mode on the request event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start middleware is now (event, next), and the page render runs inside next(), so a per-request nonce and render mode are set on the event before the call. The renderMode module form only ever shipped in prereleases and is removed. Co-authored-by: Éverton Toffanetto Co-authored-by: Cursor --- .changeset/event-first-middleware-nonce.md | 5 + README.md | 105 +++--- examples/start-client/src/shell-nonce.ts | 13 + examples/start-client/test/run.mjs | 38 +++ examples/start-client/vite.config.ts | 9 +- examples/start-env/src/middleware.ts | 5 +- examples/start-ssr/src/middleware.ts | 85 ++++- examples/start-ssr/src/render-mode.ts | 21 -- examples/start-ssr/test/run.mjs | 363 ++++++++++++++++++-- examples/start-ssr/vite.config.ts | 33 +- src/index.ts | 1 + src/request-event.ts | 43 +++ src/ssr/index.ts | 374 ++++++++++++--------- virtual-solid-manifest.d.ts | 21 +- 14 files changed, 821 insertions(+), 295 deletions(-) create mode 100644 .changeset/event-first-middleware-nonce.md create mode 100644 examples/start-client/src/shell-nonce.ts delete mode 100644 examples/start-ssr/src/render-mode.ts create mode 100644 src/request-event.ts diff --git a/.changeset/event-first-middleware-nonce.md b/.changeset/event-first-middleware-nonce.md new file mode 100644 index 00000000..55f89162 --- /dev/null +++ b/.changeset/event-first-middleware-nonce.md @@ -0,0 +1,5 @@ +--- +'@solidjs/vite-plugin': minor +--- + +BREAKING: start-mode middleware is event-first, `(event, next) => Response | Promise`, with the request at `event.request`. The plugin composes the chain itself. `next()` takes no arguments — assign `event.request` before calling it to substitute the request downstream; `next(request)` throws a migration error. Per-request render inputs live on the event, set before `next()` (the render runs inside it): `event.nonce` (a string or `{ script, style }`) and `event.renderMode` (`'stream'` | `'async'`). `handleRequest(request, { nonce, renderMode })` is seeded onto the event before the chain and wins over a middleware write, then the event field, then static `start.renderMode`, then `'stream'`. Invalid host options reject the call before the chain. Generated entries pass `event.nonce` to `renderToStream`; authored entries must forward the `context.nonce` the handler passes them. The injected client-entry script, redirect fallback, dev head, dev styles, and (when there is a style nonce) a `` carry it too. The `start.renderMode` module form, which only shipped in 3.0.0-next prereleases, is removed — a path is a config error pointing at `event.renderMode` in middleware. The static `'stream'` | `'async'` shorthand stays. A nonce set while prerendering the client-mode shell is not baked into `dist/client/index.html` (the build warns). In dev, changing `event.nonce` or `event.renderMode` after the render has read them warns. diff --git a/README.md b/README.md index 2aa6ab0a..2ae8e119 100644 --- a/README.md +++ b/README.md @@ -422,42 +422,61 @@ real 3xx redirect, and one set after it (streamed responses) falls back to a `` tail. **`middleware`** points at a server-only module default-exporting one -fetch-style middleware — `(request, next) => Response | Promise` -— or an array of them, composed in order: +middleware — `(event, next) => Response | Promise`, the request +at `event.request` — or an array of them. The plugin composes the chain +itself. `next()` takes no arguments: assign `event.request = new Request(...)` +before calling it to hand a different request downstream (calling +`next(request)`, the previous shape, throws a migration error). + +The page render runs inside `next()`, so per-request render inputs go on +the event before that call. `event.nonce` is the CSP nonce (a string, or +`{ script, style }` with each a non-empty string or `false`). +`event.renderMode` is `'stream'` or `'async'` for this request, overriding +the static `renderMode` below. +`handleRequest(request, { nonce, renderMode })` is seeded onto the event +before the chain and wins over a middleware write. ```ts // vite.config.ts solid({ start: { middleware: './src/middleware.ts' }, ssr: true }); // src/middleware.ts -import { getRequestEvent } from '@solidjs/web'; - -export default async function auth(request: Request, next) { - getRequestEvent().locals.user = await userFromCookie(request); - try { - const response = await next(); - response.headers.set('server-timing', 'app'); // pre-wire window - return response; - } catch (error) { - return new Response('oops', { status: 500 }); +export default async (event, next) => { + event.nonce ??= crypto.randomUUID(); + if (/bot|crawler/i.test(event.request.headers.get('user-agent') ?? '')) { + event.renderMode = 'async'; } -} + const response = await next(); + const scriptNonce = typeof event.nonce === 'string' ? event.nonce : event.nonce?.script; + if (scriptNonce && response.headers.get('content-type')?.startsWith('text/html')) { + response.headers.set( + 'content-security-policy', + `script-src 'nonce-${scriptNonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`, + ); + } + return response; +}; ``` +Generated entries pass `event.nonce` into `renderToStream`. An authored +`entry-server` must forward the `context.nonce` the handler passes it +(`renderToStream(app, { manifest, nonce: context.nonce })`). + The chain fronts every request the plugin dispatches — page SSR and the server-function endpoint, dev, production, and preview alike — and runs -inside the request-event scope, so `getRequestEvent()` works exactly as in -application code (the endpoint shares the chain's event, so `locals` +inside the request-event scope, so `getRequestEvent()` answers with the +same event as the `event` argument (the endpoint shares it, so `locals` 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. +`next()` even for streamed responses. In dev, writing `event.nonce` or +`event.renderMode` after `await next()` warns: the render already read them. 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 +throw, a `setup` failure, or an invalid `event.renderMode` / `event.nonce`) 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 @@ -538,7 +557,7 @@ server build must keep code splitting on (the default), since inlining dynamic imports would hoist the handler graph back above the instrument. **`renderMode`** — how a page render becomes a response body: `'stream'` -(the default) or `'async'`, or a module path deciding per request. +(the default) or `'async'`. Streaming flushes the document shell as soon as it is ready, with every `` fallback in place, and streams the boundaries' content behind it @@ -566,43 +585,33 @@ header written mid-render — the post-flush script redirect in stream mode — becomes a real 3xx with no body, which is exactly what a no-JS client needs. Most apps want streaming for browsers and a complete document for the few -clients that cannot run the swap. The per-request form is a module path -(relative to the Vite root, following the `middleware`/`setup` convention -— a Vite config cannot serialize a closure into the generated handler) -default-exporting `(event) => 'stream' | 'async' | Promise<'stream' | -'async'>`. It runs inside the request scope after the middleware chain, so -`event.locals` is decorated by the time it decides: +clients that cannot run the swap. Decide that per request in middleware, +before `next()` — see the middleware example above: ```ts -// vite.config.ts -solid({ start: { renderMode: './src/render-mode.ts' }, ssr: true }); - -// src/render-mode.ts -import type { RequestEvent } from '@solidjs/web'; - -const CRAWLER = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i; - -export default function renderMode(event: RequestEvent) { - const { request } = event; - if (new URL(request.url).searchParams.has('nojs')) return 'async'; - if (CRAWLER.test(request.headers.get('user-agent') ?? '')) return 'async'; - return 'stream'; +if (/bot|crawler/i.test(event.request.headers.get('user-agent') ?? '')) { + event.renderMode = 'async'; } ``` +The module form (`renderMode: './src/render-mode.ts'`) shipped only in +3.0.0-next prereleases and is a config error pointing at `event.renderMode`. + 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 (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()` / -`httpHeader()` declarations survive either mode: the runtime freezes the -response head when the awaited render completes (`@solidjs/web` 2.0.0-rc.7+), -just as streaming freezes it at shell flush. Server mode only — in client mode the served shell has no boundaries to -settle, so the option is a documented no-op there. +option, then `event.renderMode`, then this static value. A bad +`handleRequest` option rejects the call before the chain runs; a bad +`event.renderMode` 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()` / `httpHeader()` declarations survive either mode: the +runtime freezes the response head when the awaited render completes +(`@solidjs/web` 2.0.0-rc.7+), just as streaming freezes it at shell flush. +Server mode only — in client mode the served shell has no boundaries to +settle, so the option is a documented no-op there. A nonce set during the +client-mode shell prerender is ignored (and the build warns): baking one +into `dist/client/index.html` would replay it on every load. **`env`** — first-party typed environment variables. A schema file at the project root — `env.ts` (or `env.js`), probed automatically; point diff --git a/examples/start-client/src/shell-nonce.ts b/examples/start-client/src/shell-nonce.ts new file mode 100644 index 00000000..b6bdd703 --- /dev/null +++ b/examples/start-client/src/shell-nonce.ts @@ -0,0 +1,13 @@ +// Prerender nonce fixture (prod mode): vite.config.ts wires this through +// `start.middleware` only when SOLID_SHELL_NONCE=1. The middleware puts a +// CSP nonce on the event while the build prerenders the shell; a nonce baked +// into the static dist/client/index.html would be no nonce at all, so the +// shell must render without it (and the build warns). +import type { StartMiddleware } from '@solidjs/vite-plugin'; + +const shellNonce: StartMiddleware = (event, next) => { + event.nonce = 'baked-nonce'; + return next(); +}; + +export default shellNonce; diff --git a/examples/start-client/test/run.mjs b/examples/start-client/test/run.mjs index e9dd7594..641a2bfc 100644 --- a/examples/start-client/test/run.mjs +++ b/examples/start-client/test/run.mjs @@ -379,6 +379,44 @@ async function prodMode() { !existsSync(path.join(exampleDir, 'dist/client/index.html')), ); + // A middleware nonce never reaches the prerendered shell: a nonce baked + // into a static file would be replayed on every load. The build warns. + rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true }); + const nonceBuild = await new Promise((resolve) => { + let output = ''; + const child = spawn('pnpm', ['exec', 'vite', 'build'], { + cwd: exampleDir, + env: { ...process.env, SOLID_SHELL_NONCE: '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 }); + }); + }); + const noncedShellPath = path.join(exampleDir, 'dist/client/index.html'); + const noncedShell = existsSync(noncedShellPath) ? readFileSync(noncedShellPath, 'utf-8') : ''; + record( + 'prod', + 'prerender', + 'a middleware nonce is not baked into the prerendered shell', + nonceBuild.code === 0 && + noncedShell.includes(' Promise, -) { +export default async function envCheck(_event, next: () => Promise) { const response = await next(); response.headers.set('x-env-secret-len', String(env.SESSION_SECRET.length)); response.headers.set('x-env-port', JSON.stringify(env.ENV_CHECK_PORT)); diff --git a/examples/start-ssr/src/middleware.ts b/examples/start-ssr/src/middleware.ts index cc657d83..7e3e3dac 100644 --- a/examples/start-ssr/src/middleware.ts +++ b/examples/start-ssr/src/middleware.ts @@ -1,9 +1,16 @@ -// Fetch-style middleware chain for the middleware/preview e2e modes -// (SSR_MIDDLEWARE=1 wires it through `start.middleware` in vite.config.ts). -// Server-only: only the generated handler imports it. Exercises the whole -// contract: -// - runs inside the request-event scope: getRequestEvent() answers, locals -// decoration is visible to the page render and to server functions, +// Event-first middleware chain — `(event, next)`, the request at +// `event.request` — for the middleware/preview e2e modes (SSR_MIDDLEWARE=1 +// wires it through `start.middleware` in vite.config.ts) and the +// render-mode/nonce modes. Server-only: only the generated handler imports +// it. Exercises the whole contract: +// - runs inside the request-event scope: getRequestEvent() answers with the +// same event, locals decoration is visible to the page render and to +// server functions, +// - per-request render inputs on the event, set before next() (the render +// runs inside it): `renderPolicy` picks `event.renderMode` (a header, a +// crawler user agent, `?nojs` → 'async') and `event.nonce` (test headers), +// and sets the CSP header on HTML responses only; `x-late-nonce` writes +// the nonce after `await next()` — too late, which dev warns about, // - composition order (first → second → dispatch, unwinding in reverse), // - short-circuiting (/blocked never reaches the render), with a stub // cookie set inside the request scope that only the handler edge's @@ -34,7 +41,9 @@ import { isResponseEnvelope, redirect, respond, + type RequestEvent, } from '@solidjs/web'; +import type { StartMiddleware } from '@solidjs/vite-plugin'; import { failDirect } from './api'; // `start.instrument` evidence (SSR_INSTRUMENT=1): this module evaluates as @@ -66,14 +75,51 @@ if (process.env.SSR_SERVER_ERRORS) { }); } -type Next = (request?: Request) => Promise; +type Next = () => Promise; + +const CRAWLER_UA = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i; + +// The README recipe: one complete, settled document for clients that will +// never run the streaming swap scripts — crawlers and an explicit `?nojs` +// opt-in — and streaming for everyone else (`x-render-mode` is the test's +// deterministic switch). Plus the CSP nonce: `x-csp-nonce` (a string) or +// `x-csp-nonce-json` (any shape, for the `{ script, style }` pair and the +// validation checks) put it on the event, and the policy header goes on +// HTML responses only. +const renderPolicy: StartMiddleware = async (event, next) => { + const { request } = event; + const header = request.headers.get('x-render-mode'); + if (header) event.renderMode = header as RequestEvent['renderMode']; + else if (new URL(request.url).searchParams.has('nojs')) event.renderMode = 'async'; + else if (CRAWLER_UA.test(request.headers.get('user-agent') || '')) event.renderMode = 'async'; + // What a host's handleRequest nonce looks like from here (seeded onto the + // event before the chain): `event.nonce ??= ...` would adopt it. + if (request.headers.get('x-csp-host')) event.locals.seenNonce = event.nonce ?? null; + const nonce = request.headers.get('x-csp-nonce'); + if (nonce) event.nonce = nonce; + const nonceJson = request.headers.get('x-csp-nonce-json'); + if (nonceJson) event.nonce = JSON.parse(nonceJson); + const response = await next(); + if (request.headers.get('x-late-nonce')) event.nonce = 'too-late'; + const scriptNonce = typeof event.nonce === 'string' ? event.nonce : event.nonce?.script; + if (scriptNonce && response.headers.get('content-type')?.startsWith('text/html')) { + response.headers.set( + 'content-security-policy', + `script-src 'nonce-${scriptNonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`, + ); + } + if (event.locals.seenNonce !== undefined) { + response.headers.set('x-seen-nonce', JSON.stringify(event.locals.seenNonce)); + } + return response; +}; // A minimal filesystem-routing/createAPIHandler stand-in: owns /api/* and // the no-JS form endpoint, passes everything else down the chain. -async function api(request: Request, next: Next): Promise { +async function api(event: RequestEvent, next: Next): Promise { + const { request } = event; const { pathname } = new URL(request.url); if (request.method === 'GET' && pathname === '/api/info') { - const event = getRequestEvent()!; return Response.json({ user: event.locals.user, order: event.locals.order }); } if (request.method === 'GET' && pathname === '/api/server-errors') { @@ -156,8 +202,10 @@ async function api(request: Request, next: Next): Promise { return next(); } -async function first(request: Request, next: Next): Promise { - const event = getRequestEvent()!; +async function first(event: RequestEvent, next: Next): Promise { + // The argument and the ambient lookup are the same event. + if (getRequestEvent() !== event) throw new Error('middleware event is not the request event'); + const { request } = event; event.locals.order = ['first']; event.locals.user = 'mw-user'; const { pathname } = new URL(request.url); @@ -208,9 +256,18 @@ async function first(request: Request, next: Next): Promise { } } -function second(request: Request, next: Next): Promise { - (getRequestEvent()!.locals.order as string[]).push('second'); +function second(event: RequestEvent, next: Next): Promise { + (event.locals.order as string[]).push('second'); + if (new URL(event.request.url).pathname === '/rewrite-me') { + // Request substitution: assigned on the event before next(), so the + // middleware after this one and the render both see the new request. + event.request = new Request(new URL('/api/info', event.request.url), event.request); + } + if (new URL(event.request.url).pathname === '/next-arg') { + // The retired request-first shape: next() rejects with the migration. + return (next as unknown as (request: Request) => Promise)(event.request); + } return next(); } -export default [first, second, api]; +export default [renderPolicy, first, second, api]; diff --git a/examples/start-ssr/src/render-mode.ts b/examples/start-ssr/src/render-mode.ts deleted file mode 100644 index 5493f3d3..00000000 --- a/examples/start-ssr/src/render-mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Per-request render-mode policy for the render-mode e2e mode -// (SSR_RENDER_MODE=module wires it through `start.renderMode` in -// vite.config.ts). Server-only: only the generated handler imports it. The -// recipe from the README: serve one complete, settled document to clients -// that will never run the streaming swap scripts — crawlers and an explicit -// `?nojs` opt-in — and stream for everyone else. The `x-render-mode` header -// is the test's deterministic switch. Runs inside the request scope after -// the middleware chain (`event.locals` is decorated by then), and may be -// async — the handler awaits it before the render starts. -import type { RequestEvent } from '@solidjs/web'; - -const CRAWLER_UA = /Googlebot|bingbot|DuckDuckBot|Slurp|Baiduspider|YandexBot/i; - -export default function renderMode(event: RequestEvent): 'stream' | 'async' { - const { request } = event; - const header = request.headers.get('x-render-mode'); - if (header === 'stream' || header === 'async') return header; - if (new URL(request.url).searchParams.has('nojs')) return 'async'; - if (CRAWLER_UA.test(request.headers.get('user-agent') || '')) return 'async'; - return 'stream'; -} diff --git a/examples/start-ssr/test/run.mjs b/examples/start-ssr/test/run.mjs index 9995755b..229d575b 100644 --- a/examples/start-ssr/test/run.mjs +++ b/examples/start-ssr/test/run.mjs @@ -112,12 +112,19 @@ // one settled document — no Loading fallback markup, no swap scripts, // boundary content in place, hydration data intact (the streamed page's // browser checks pass against it) — with httpStatus/httpHeader still on -// the wire and a mid-render Location as a real 3xx; the per-request -// module form (src/render-mode.ts) switches modes within one server by -// header / crawler UA / `?nojs`; the `handleRequest(request, +// the wire and a mid-render Location as a real 3xx; `event.renderMode` +// set by middleware (src/middleware.ts) switches modes within one server +// by header / crawler UA / `?nojs`; the `handleRequest(request, // { renderMode })` override wins over both; authored entries get the same -// treatment; invalid config and runtime values are rejected with the fix -// in the message, +// treatment; invalid config (the retired module form included) and +// runtime values are rejected with the fix in the message, +// - the CSP nonce (nonce mode, SSR_NONCE): `event.nonce` set by middleware +// reaches every script, style and preload of the page, the client-entry +// tag, the redirect fallback, the dev head and the csp-nonce meta, in +// dev and in a build (default Fetchable included); handleRequest's nonce +// is seen by middleware and wins; invalid values are rejected; a nonce +// written after `await next()` warns in dev; request substitution via +// `event.request` and the next(request) migration error, // - lazy asset keys survive module identities beyond plain root-relative // paths (the /lazy-assets surface, dev and prod): a query-suffixed lazy // import keeps its query through the manifest key / dev URL (#299), and @@ -158,7 +165,7 @@ // // Requires the plugin built (pnpm build at the repo root) and Google Chrome. // Usage: node test/run.mjs -// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] +// [dev|prod|document|css-filter|entries|endpoint|configure|no-middleware|middleware|preview|render-mode|nonce|base|builder-order|builder-prepare|extra-input|file-names|babel-hmr|frames|external|observe|perf-tracks|detect|vitest|node] // (default: all) import { spawn, execSync, execFileSync } from 'node:child_process'; @@ -3905,7 +3912,7 @@ async function runMiddlewareMode() { ); const transformed = await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler-impl'); const code = transformed?.code || ''; - const unwind = code.indexOf('runMiddleware(request'); + const unwind = code.indexOf('runMiddleware(event'); // The SSR transform rewrites the imported binding to a member access // on the vite import handle (`(0, handle.commitEventResponse)(...)`), // so match the rewritten call site rather than the source text. @@ -4305,9 +4312,9 @@ async function runPreviewMode() { // real 3xx with no body, // - stream mode is byte-for-byte the streaming story it always was (the // default: dev/prod modes above; here re-asserted next to async), -// - the per-request module form (src/render-mode.ts) switches modes within -// one server — a header, a crawler user agent, `?nojs` → async; everyone -// else streams, +// - `event.renderMode` set in middleware (src/middleware.ts) switches modes +// within one server — a header, a crawler user agent, `?nojs` → async; +// everyone else streams, // - the `handleRequest(request, { renderMode })` runtime override wins over // both the module function and the static config, and rejects an invalid // value with an actionable error, @@ -4479,6 +4486,7 @@ async function runRenderModeMode() { for (const [value, label] of [ ['bogus', 'unknown literal'], ['./src/no-such-render-mode.ts', 'missing module path'], + ['./src/middleware.ts', 'retired module form (existing file)'], ]) { process.env.SSR_RENDER_MODE = value; let error = ''; @@ -4496,7 +4504,8 @@ async function runRenderModeMode() { error.includes('start.renderMode') && error.includes("'stream'") && error.includes("'async'") && - error.includes('module') && + error.includes('event.renderMode') && + error.includes('start.middleware') && error.includes(value), error ? error.slice(0, 300) : 'config resolved without error', ); @@ -4549,8 +4558,8 @@ async function runRenderModeMode() { record( mode, 'codegen', - 'no render-mode module import without the option', - !!transformed && !transformed.code.includes('render-mode.ts'), + 'no middleware import without the option', + !!transformed && !transformed.code.includes('middleware.ts'), ); } catch (e) { record(mode, 'override', 'direct dispatch completed', false, String(e)); @@ -4559,7 +4568,7 @@ async function runRenderModeMode() { } // ---- Direct dispatch: override beats the per-request module too ----------- - process.env.SSR_RENDER_MODE = 'module'; + process.env.SSR_RENDER_MODE = 'middleware'; try { probe = await withNodeEnvRestored(() => createServer({ root: exampleDir, server: { middlewareMode: true } }), @@ -4572,7 +4581,7 @@ async function runRenderModeMode() { record( mode, 'override', - 'module function answers per request (header → async)', + 'middleware event.renderMode answers per request (header → async)', viaModule.status === 200 && !isStreamedMarkup(viaModule.html) && viaModule.html.includes('STREAMED-ASYNC-CONTENT'), ); const forcedStream = await readAll( @@ -4583,18 +4592,31 @@ async function runRenderModeMode() { record( mode, 'override', - 'handleRequest({ renderMode: "stream" }) beats the module function (precedence)', + 'handleRequest({ renderMode: "stream" }) beats event.renderMode (precedence)', forcedStream.status === 200 && isStreamedMarkup(forcedStream.html), ); - const transformed = await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler'); + // The throw happens inside next(), so the example's error middleware + // turns it into a caught 500 whose body names the field. Without that + // catch, dev rethrows and production contains it the same way. + let invalid = ''; + try { + const invalidResponse = await handler.handleRequest( + new Request('http://localhost/', { headers: { accept: 'text/html', 'x-render-mode': 'bogus' } }), + ); + const invalidBody = await invalidResponse.text(); + invalid = invalidResponse.status === 500 ? invalidBody : ''; + } catch (e) { + invalid = String(e && e.message ? e.message : e); + } record( mode, - 'codegen', - 'handler imports the configured render-mode module', - !!transformed && transformed.code.includes('render-mode.ts'), + 'override', + 'an invalid event.renderMode is rejected naming the field', + invalid.includes('event.renderMode') && invalid.includes('bogus'), + invalid.slice(0, 200) || 'resolved without error', ); } catch (e) { - record(mode, 'override', 'module-config direct dispatch completed', false, String(e)); + record(mode, 'override', 'middleware-policy direct dispatch completed', false, String(e)); } finally { await probe?.close(); delete process.env.SSR_RENDER_MODE; @@ -4611,13 +4633,15 @@ async function runRenderModeMode() { record('rm-dev-async', 'ssr', 'responds 200', page.status === 200, `status ${page.status}`); record('rm-dev-async', 'ssr', 'app server-rendered', page.html.includes('SSR Start Mode')); assertComplete('rm-dev-async', 'ssr', page.html); + const hasViteClient = page.html.includes('/@vite/client'); + const hasClientEntry = page.html.includes('virtual:solid-ssr-entry-client.tsx'); + const hasAppCss = /]*data-vite-dev-id="[^"]*App\.css"/.test(page.html); record( 'rm-dev-async', 'dev', 'dev head still injected on the string path (Vite client + entry CSS)', - page.html.includes('/@vite/client') && - page.html.includes('virtual:solid-ssr-entry-client.tsx') && - /]*data-vite-dev-id="[^"]*App\.css"/.test(page.html), + hasViteClient && hasClientEntry && hasAppCss, + `vite client ${hasViteClient}, entry ${hasClientEntry}, app css ${hasAppCss}; head ${page.html.slice(0, 500)}`, ); await assertAsyncHttp('rm-dev-async', devAsyncOrigin); // The async document must still hydrate and be interactive — the same @@ -4690,7 +4714,7 @@ async function runRenderModeMode() { ); }; try { - server = spawnDev(devModulePort, { SSR_RENDER_MODE: 'module' }); + server = spawnDev(devModulePort, { SSR_RENDER_MODE: 'middleware' }); captureLog(server); await waitForHttp(devModuleOrigin + '/src/api.ts', 30000); await perRequestChecks('rm-dev-module', devModuleOrigin); @@ -4791,12 +4815,12 @@ async function runRenderModeMode() { const prodModuleOrigin = `http://localhost:${prodModulePort}`; try { console.log(' building (module)…'); - build({ SSR_RENDER_MODE: 'module' }); + build({ SSR_RENDER_MODE: 'middleware' }); const serverBundle = readFileSync(path.join(exampleDir, 'dist/server/server.js'), 'utf-8'); record( 'rm-prod-module', 'build', - 'render-mode module bundled into the handler chunk', + 'render-mode policy (middleware) bundled into the handler chunk', serverBundle.includes('x-render-mode'), ); const assetsDir = path.join(exampleDir, 'dist/client/assets'); @@ -4806,11 +4830,11 @@ async function runRenderModeMode() { record( 'rm-prod-module', 'build', - 'render-mode module absent from client assets', + 'render-mode policy (middleware) absent from client assets', leaks.length === 0, leaks.join(', '), ); - server = spawnProd(prodModulePort, { SSR_RENDER_MODE: 'module' }); + server = spawnProd(prodModulePort, { SSR_RENDER_MODE: 'middleware' }); captureLog(server); await waitForHttp(prodModuleOrigin + '/', 30000, { headers: { accept: 'text/html' } }); await perRequestChecks('rm-prod-module', prodModuleOrigin); @@ -4859,14 +4883,14 @@ async function runRenderModeMode() { kill(server); server = undefined; - server = spawnDev(authoredDevPort, { SSR_RENDER_MODE: 'module' }); + server = spawnDev(authoredDevPort, { SSR_RENDER_MODE: 'middleware' }); captureLog(server); await waitForHttp(authoredDevOrigin + '/src/api.ts', 30000); const streamed = await fetchStreamed(authoredDevOrigin + '/'); record( 'rm-authored', 'per-request', - 'authored entry streams by default under the module form', + 'authored entry streams by default under the middleware policy', streamed.chunks.length > 1 && isStreamedMarkup(streamed.html), `${streamed.chunks.length} chunk(s)`, ); @@ -4875,7 +4899,7 @@ async function runRenderModeMode() { record( 'rm-authored', 'per-request', - 'authored entry settles for ?nojs under the module form', + 'authored entry settles for ?nojs under the middleware policy', settled.status === 200 && !isStreamedMarkup(settledHtml) && settledHtml.includes('STREAMED-ASYNC-CONTENT'), ); kill(server); @@ -4908,7 +4932,9 @@ async function runRenderModeMode() { 'run', 'authored completed', false, - String(e) + (serverLog ? `\nserver: ${serverLog.slice(-2000)}` : ''), + String(e) + + (e && e.cause ? ` cause: ${e.cause}` : '') + + (serverLog ? `\nserver: ${serverLog.slice(-2000)}` : ''), ); } finally { kill(server); @@ -6508,6 +6534,275 @@ async function runVitestMode() { ); } +// The CSP nonce on the request event (SSR_NONCE=1 wires src/middleware.ts, +// whose `renderPolicy` copies the test's nonce headers onto `event.nonce` +// before next() and sets the CSP header on HTML responses). Asserted on +// in-process dispatch against the dev handler and against a build (named +// handleRequest and the default Fetchable). Also covers the event-first +// chain's request substitution and the next(request) migration error. +async function runNonceMode() { + const mode = 'nonce'; + console.log(`\n=== ${mode.toUpperCase()} ===`); + const page = (headers = {}, pathname = '/') => + new Request('http://localhost' + pathname, { headers: { accept: 'text/html', ...headers } }); + const escaped = (value) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(/ html.match(new RegExp(`<${name}(?=[\\s>])[^>]*>`, 'g')) || []; + // Every tag of a kind carries the expected nonce attribute (and there is + // at least one of them). + const allCarry = (html, name, nonce) => { + const found = tags(html, name); + const missing = found.filter((tag) => !tag.includes(` nonce="${escaped(nonce)}"`)); + return { ok: found.length > 0 && missing.length === 0, detail: `${found.length} <${name}>, missing: ${missing.slice(0, 3).join(' ')}` }; + }; + const assertNonced = (tag, phase, response, html, { script, style }) => { + const scripts = allCarry(html, 'script', script); + record(tag, phase, `every ` + - ``; - lines.push(``, `const DEV_HEAD = ${JSON.stringify(devHead)};`); + // Built per request from the nonce attributes: under a nonce-based CSP + // the two scripts carry the script nonce (`'strict-dynamic'` then + // trusts the modules the Vite client loads) and the collected dev + // styles carry the style nonce. + lines.push( + ``, + `function devHead(nonceAttr) {`, + ` return '' + ${JSON.stringify(devStylePatch)} + '' +`, + ` '