Skip to content
Merged
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/event-first-middleware-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': minor
---

BREAKING: start-mode middleware is event-first, `(event, next) => Response | Promise<Response>`, 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 `<meta property="csp-nonce">` 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.
105 changes: 57 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,42 +422,61 @@ real 3xx redirect, and one set after it (streamed responses) falls back to
a `<script>window.location=...</script>` tail.

**`middleware`** points at a server-only module default-exporting one
fetch-style middleware — `(request, next) => Response | Promise<Response>`
— or an array of them, composed in order:
middleware — `(event, next) => Response | Promise<Response>`, 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
Expand Down Expand Up @@ -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
`<Loading>` fallback in place, and streams the boundaries' content behind it
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions examples/start-client/src/shell-nonce.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions examples/start-client/test/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script') &&
!noncedShell.includes('nonce') &&
!noncedShell.includes('baked-nonce'),
`exit ${nonceBuild.code}`,
);
record(
'prod',
'prerender',
'the build warns that event.nonce is ignored for the client-mode shell',
nonceBuild.output.includes('event.nonce is ignored for the client-mode shell'),
nonceBuild.output.slice(-400),
);

rmSync(path.join(exampleDir, 'dist'), { recursive: true, force: true });
await runCommand('pnpm', ['exec', 'vite', 'build'], { cwd: exampleDir });

Expand Down
9 changes: 8 additions & 1 deletion examples/start-client/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,23 @@ import solidPlugin from '@solidjs/vite-plugin';
// 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.
//
// SOLID_SHELL_NONCE=1 (prod mode) wires src/shell-nonce.ts instead: the
// chain sets `event.nonce` during the prerender, and the static shell must
// not carry it.
const startNode = !!process.env.SOLID_START_NODE || !!process.env.SOLID_START_NODE_ONLY;
const shellFail = !!process.env.SOLID_SHELL_FAIL;
const shellNonce = !!process.env.SOLID_SHELL_NONCE;
export default defineConfig({
plugins: [
solidPlugin({
start: startNode
? { node: true }
: shellFail
? { middleware: './src/shell-failure.ts' }
: true,
: shellNonce
? { middleware: './src/shell-nonce.ts' }
: true,
ssr: !!process.env.SOLID_FLIP_SSR,
...(process.env.SOLID_START_NODE ? { serverFunctions: true } : {}),
}),
Expand Down
5 changes: 1 addition & 4 deletions examples/start-env/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import { env } from 'virtual:env/server';
// server side sees every var — the secret itself never leaves the server
// (only its length does), and ENV_CHECK_PORT arrives as the schema's
// *validated output* (a defaulted number, not a raw env string).
export default async function envCheck(
request: Request,
next: (request?: Request) => Promise<Response>,
) {
export default async function envCheck(_event, next: () => Promise<Response>) {
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));
Expand Down
85 changes: 71 additions & 14 deletions examples/start-ssr/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -66,14 +75,51 @@ if (process.env.SSR_SERVER_ERRORS) {
});
}

type Next = (request?: Request) => Promise<Response>;
type Next = () => Promise<Response>;

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<Response> {
async function api(event: RequestEvent, next: Next): Promise<Response> {
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') {
Expand Down Expand Up @@ -156,8 +202,10 @@ async function api(request: Request, next: Next): Promise<Response> {
return next();
}

async function first(request: Request, next: Next): Promise<Response> {
const event = getRequestEvent()!;
async function first(event: RequestEvent, next: Next): Promise<Response> {
// 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);
Expand Down Expand Up @@ -208,9 +256,18 @@ async function first(request: Request, next: Next): Promise<Response> {
}
}

function second(request: Request, next: Next): Promise<Response> {
(getRequestEvent()!.locals.order as string[]).push('second');
function second(event: RequestEvent, next: Next): Promise<Response> {
(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<Response>)(event.request);
}
return next();
}

export default [first, second, api];
export default [renderPolicy, first, second, api];
Loading
Loading