Skip to content

Commit b6af42f

Browse files
turnkey: fold the response stub at the handler edge with commitEventResponse
A middleware that returns its own Response (an API handler that never calls next()) bypasses createSSRResponse, so stub writes made inside the request scope — cookies appended to event.response.headers, status — never reached the wire. The generated handler now applies the runtime's commitEventResponse(response, event) unconditionally at the handler edge, strictly after the outermost middleware returns: headers stay mutable through the whole unwind, committed page responses pass through untouched (the fold is idempotent), and the per-path applyResponseStub folds (raw entry.render Responses, server-function responses) collapse into the one edge fold. Resolved off a namespace import with a local fallback preserving the old partial-fold semantics until the .40 repin ships the export in @solidjs/web (TODO(.40-repin) in the codegen — a named import of a missing export is fatal in ESM). Turnkey e2e: codegen string assertions (the fold resolves off the runtime and runs after the unwind) pass against the pinned deps; the new wire assertion — a /blocked early-return's stub cookie arrives exactly once — verified 34/34 with the middleware mode against a local post-repin @solidjs/web build (the mode as committed requires > 2.0.0-beta.31 for composeMiddleware, per the previous changeset). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 65d6b51 commit b6af42f

4 files changed

Lines changed: 133 additions & 29 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'vite-plugin-solid': patch
3+
---
4+
5+
Turnkey SSR closes the middleware early-return gap with the runtime's `commitEventResponse`: a middleware that answers without calling `next()` (an API handler) bypasses `createSSRResponse`, so its request-scope stub writes — cookies appended to `event.response.headers`, status — never reached the wire. The generated handler now applies `commitEventResponse(response, event)` unconditionally at the handler edge, strictly after the outermost middleware returns: headers stay mutable through the whole unwind, committed page responses pass through untouched (the fold is idempotent), and the inner per-path folds (`applyResponseStub` on raw `entry.render` Responses and server-function responses) collapse into the one edge fold.
6+
7+
Until the next `@solidjs/web` repin ships `commitEventResponse` (TODO(.40-repin) in the codegen), the handler resolves it off a namespace import with a local fallback that preserves the previous partial fold semantics — a named import of a missing export would be fatal in ESM — so generated modules keep working against the current beta.31 line and upgrade to the runtime's fold (protocol-header denylist, committed-stub loudness) automatically at the repin.

examples/turnkey/src/middleware.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
// - runs inside the request-event scope: getRequestEvent() answers, locals
66
// decoration is visible to the page render and to server functions,
77
// - composition order (first → second → dispatch, unwinding in reverse),
8-
// - short-circuiting (/blocked never reaches the render),
8+
// - short-circuiting (/blocked never reaches the render), with a stub
9+
// cookie set inside the request scope that only the handler edge's
10+
// commit fold can carry onto the early-return Response,
911
// - error middleware (try/catch around next() turns a render throw into a
1012
// controlled 500),
1113
// - the post-next() mutation window: headers set after `await next()` land
@@ -20,6 +22,11 @@ async function first(request: Request, next: Next): Promise<Response> {
2022
event.locals.order = ['first'];
2123
event.locals.user = 'mw-user';
2224
if (new URL(request.url).pathname === '/blocked') {
25+
// Early return: this Response never goes through createSSRResponse, so
26+
// the stub write below only reaches the wire through the handler
27+
// edge's commitEventResponse fold after the chain unwinds — the e2e
28+
// asserts the cookie arrives exactly once (fold ran, and only once).
29+
event.response.headers.append('set-cookie', 'mw-blocked=1; Path=/');
2330
return new Response('blocked-by-middleware', { status: 403 });
2431
}
2532
try {

examples/turnkey/test/run.mjs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,13 @@
8383
// emits the script-redirect fallback on the streamed 200,
8484
// - `ssr.middleware` (SSR_MIDDLEWARE=1, src/middleware.ts): composition
8585
// order, locals decoration visible to the page and to a server function
86-
// over /_server (one request event fronts both), short-circuiting,
87-
// error middleware catching a render throw, and the post-next()
86+
// over /_server (one request event fronts both), short-circuiting —
87+
// with the handler edge's commitEventResponse fold carrying an
88+
// early-return's stub cookie onto the wire exactly once — error
89+
// middleware catching a render throw, and the post-next()
8890
// header-mutation window on a streamed response — in dev and prod,
91+
// plus codegen string assertions that the generated handler resolves
92+
// commitEventResponse from @solidjs/web and folds after the unwind,
8993
// - `vite preview` serves the production artifact with no server file:
9094
// dist/client statically, everything else (pages, /_server, middleware,
9195
// the lifecycle) through the built handler.
@@ -1924,7 +1928,9 @@ async function runFramesMode() {
19241928
// hit the wire before the chain unwound,
19251929
// - locals decoration visible to the page render (/whoami) and to a server
19261930
// function over /_server (the endpoint shares the chain's request event),
1927-
// - short-circuit (/blocked never reaches the render),
1931+
// - short-circuit (/blocked never reaches the render), and the handler
1932+
// edge's commit fold: the stub cookie the middleware appended inside the
1933+
// request scope arrives on the early-return Response exactly once,
19281934
// - error middleware (/boom: a render throw becomes the middleware's 500).
19291935
async function runMiddlewareChecksOverHttp(mode, origin, functionId) {
19301936
const page = await fetchStreamed(origin + '/');
@@ -1964,6 +1970,20 @@ async function runMiddlewareChecksOverHttp(mode, origin, functionId) {
19641970
blocked.status === 403 && blockedBody === 'blocked-by-middleware',
19651971
`status ${blocked.status}, body ${JSON.stringify(blockedBody.slice(0, 60))}`,
19661972
);
1973+
// The early return skipped createSSRResponse, so the stub cookie the
1974+
// middleware appended inside the request scope can only arrive through
1975+
// the handler edge's commitEventResponse fold — and exactly once (the
1976+
// fold is idempotent; nothing double-applies it).
1977+
const blockedCookies = (blocked.headers.getSetCookie ? blocked.headers.getSetCookie() : []).filter(
1978+
(cookie) => cookie.startsWith('mw-blocked='),
1979+
);
1980+
record(
1981+
mode,
1982+
'mw',
1983+
'early-return stub cookie arrives exactly once (edge commit fold)',
1984+
blockedCookies.length === 1 && blockedCookies[0].startsWith('mw-blocked=1'),
1985+
`set-cookie: ${JSON.stringify(blockedCookies)}`,
1986+
);
19671987

19681988
const boom = await fetch(origin + '/boom', { headers: { accept: 'text/html' } });
19691989
const boomBody = await boom.text();
@@ -2012,6 +2032,41 @@ async function runMiddlewareMode() {
20122032
};
20132033
let functionId = null;
20142034
try {
2035+
// ---- Codegen: the generated handler folds at the edge ----------------
2036+
// String assertions on the handler module the plugin generates (no
2037+
// execution): every response leaves through commitEventResponse —
2038+
// resolved off the runtime with a local fallback until the .40 repin
2039+
// ships the export in @solidjs/web — strictly AFTER the middleware
2040+
// chain unwinds, so early-return Responses get their stub writes and
2041+
// post-next() header mutation stays possible through the whole unwind.
2042+
process.env.SSR_MIDDLEWARE = '1';
2043+
let probe;
2044+
try {
2045+
probe = await createServer({ root: exampleDir, server: { middlewareMode: true } });
2046+
const transformed = await probe.environments.ssr.transformRequest('virtual:solid-ssr-handler');
2047+
const code = transformed?.code || '';
2048+
const unwind = code.indexOf('runMiddleware(request');
2049+
const fold = code.indexOf('return commitEventResponse(response, event)');
2050+
record(
2051+
'mw-codegen',
2052+
'gen',
2053+
'handler resolves commitEventResponse from @solidjs/web (fallback until .40 repin)',
2054+
// `.commitEventResponse ??` survives the SSR transform (the
2055+
// namespace binding itself is rewritten to a vite import handle).
2056+
code.includes('.commitEventResponse ??'),
2057+
);
2058+
record(
2059+
'mw-codegen',
2060+
'gen',
2061+
'edge fold runs after the middleware chain unwinds',
2062+
unwind !== -1 && fold !== -1 && fold > unwind,
2063+
`runMiddleware @ ${unwind}, fold @ ${fold}`,
2064+
);
2065+
} finally {
2066+
await probe?.close();
2067+
delete process.env.SSR_MIDDLEWARE;
2068+
}
2069+
20152070
// ---- Dev: the chain fronts the dev middlewares -----------------------
20162071
const devOrigin = `http://localhost:${devPort}`;
20172072
server = startProcess('pnpm', ['exec', 'vite', '--port', String(devPort), '--strictPort'], {

src/ssr/index.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -381,10 +381,14 @@ export function ssrServe(
381381
// they differ in how the client entry URL is known (baked dev URL vs a
382382
// manifest scan) and what gets injected into <head> (Vite client + style
383383
// patch in dev). The response-head lifecycle is the runtime's
384-
// (`createRequestEvent`/`createSSRResponse` from @solidjs/web): every
385-
// request runs under a stub-backed event, `httpStatus`/`httpHeader`
386-
// writes land on the wire at shell flush, a pre-flush redirect becomes a
387-
// real 3xx and a post-flush one the script fallback. When
384+
// (`createRequestEvent`/`createSSRResponse`/`commitEventResponse` from
385+
// @solidjs/web): every request runs under a stub-backed event,
386+
// `httpStatus`/`httpHeader` writes land on the wire at shell flush, a
387+
// pre-flush redirect becomes a real 3xx and a post-flush one the script
388+
// fallback, and a Response that skipped the render lifecycle (middleware
389+
// early return, raw entry.render Response, server functions) has the
390+
// stub folded on at the handler edge after the middleware chain fully
391+
// unwinds. When
388392
// `serverFunctions` is enabled the endpoint is dispatched here on every
389393
// surface (the runnable-dev middleware routes through this module), so
390394
// user middleware and the shared request event front it identically.
@@ -394,6 +398,10 @@ export function ssrServe(
394398

395399
const lines = [
396400
`import { createRequestEvent, createSSRResponse${middlewarePath ? ', composeMiddleware' : ''} } from '@solidjs/web';`,
401+
// Namespace import so the handler also loads against runtimes that
402+
// predate `commitEventResponse` (a named import of a missing export
403+
// is fatal in ESM) — see the resolution + fallback below.
404+
`import * as _webRuntime from '@solidjs/web';`,
397405
`import { provideRequestEvent } from ${JSON.stringify(STORAGE_SOURCE)};`,
398406
`import * as entry from ${JSON.stringify(entryServerSpec())};`,
399407
...(middlewarePath
@@ -512,23 +520,38 @@ export function ssrServe(
512520

513521
lines.push(
514522
``,
515-
// Responses that don't run the render lifecycle (raw Responses from
516-
// entry.render, server-function responses) still honor stub header
517-
// writes made before they were produced — matching what an
518-
// h3/framework layer merging `event.response` would do. Status stays
519-
// the response's own; `set-cookie` values append entry by entry.
520-
`function applyResponseStub(response, stub) {`,
521-
` if (!stub || stub.committed) return response;`,
522-
` stub.committed = true;`,
523-
` try {`,
524-
` stub.headers.forEach((value, key) => {`,
525-
` if (key !== 'set-cookie' && !response.headers.has(key)) response.headers.set(key, value);`,
526-
` });`,
527-
` const cookies = stub.headers.getSetCookie ? stub.headers.getSetCookie() : [];`,
528-
` for (const cookie of cookies) response.headers.append('set-cookie', cookie);`,
529-
` } catch {}`,
530-
` return response;`,
531-
`}`,
523+
// The handler-edge commit fold — the runtime's `commitEventResponse`,
524+
// the second of the response lifecycle's two exits: page results
525+
// leave through `createSSRResponse`, any other Response (a middleware
526+
// early return, a raw Response from entry.render, a server-function
527+
// response) leaves through `commitEventResponse`, which folds the
528+
// event's response stub onto it (cookies append entry-by-entry, other
529+
// headers gap-fill, status stays the response's own) and commits the
530+
// stub. Committed stubs pass through untouched, so the edge applies
531+
// it unconditionally.
532+
//
533+
// TODO(.40-repin): collapse to the named import above and delete the
534+
// fallback — `commitEventResponse` ships in @solidjs/web after the
535+
// .40 repin. Until then the fallback preserves this handler's
536+
// previous partial fold (no protocol denylist, no commit loudness) so
537+
// the generated module keeps working against the published beta.31
538+
// line, and middleware early returns get their stub writes onto the
539+
// wire on both runtimes.
540+
`const commitEventResponse =`,
541+
` _webRuntime.commitEventResponse ??`,
542+
` ((response, event) => {`,
543+
` const stub = event && event.response;`,
544+
` if (!stub || stub.committed) return response;`,
545+
` stub.committed = true;`,
546+
` try {`,
547+
` stub.headers.forEach((value, key) => {`,
548+
` if (key !== 'set-cookie' && !response.headers.has(key)) response.headers.set(key, value);`,
549+
` });`,
550+
` const cookies = stub.headers.getSetCookie ? stub.headers.getSetCookie() : [];`,
551+
` for (const cookie of cookies) response.headers.append('set-cookie', cookie);`,
552+
` } catch {}`,
553+
` return response;`,
554+
` });`,
532555
``,
533556
`async function dispatchRequest(request, event, options) {`,
534557
);
@@ -537,11 +560,13 @@ export function ssrServe(
537560
` if (new URL(request.url).pathname === endpoint) {`,
538561
// The call shares the middleware chain's event (locals decoration,
539562
// the response stub); an explicit host-provided createEvent wins.
540-
` const response = await handleServerFunctionRequest(request, {`,
563+
// No fold here: the runtime's server-function handler runs the
564+
// commit seam itself, and anything it left uncommitted is caught by
565+
// the unconditional edge fold in handleRequest.
566+
` return handleServerFunctionRequest(request, {`,
541567
` createEvent: () => event,`,
542568
` ...options.serverFunctions,`,
543569
` });`,
544-
` return applyResponseStub(response, event.response);`,
545570
` }`,
546571
);
547572
}
@@ -556,7 +581,10 @@ export function ssrServe(
556581
` if (result && typeof result.pipe !== 'function' && typeof result.then === 'function') {`,
557582
` result = await result;`,
558583
` }`,
559-
` if (result instanceof Response) return applyResponseStub(result, event.response);`,
584+
// Raw Responses fold at the handler edge (handleRequest), after the
585+
// middleware chain unwinds — not here, where middleware above this
586+
// frame could still legitimately mutate headers.
587+
` if (result instanceof Response) return result;`,
560588
// The runtime's response-head lifecycle: commit at shell flush,
561589
// pre-flush Location as a real redirect, post-flush Location as the
562590
// script fallback; the transform injects the doctype/head pieces.
@@ -572,9 +600,16 @@ export function ssrServe(
572600
// Middleware runs inside the request scope, after event creation —
573601
// getRequestEvent() answers in middleware exactly as in app code, and
574602
// nothing reaches the wire until the outermost middleware returns.
575-
` return provideRequestEvent(event, () =>`,
603+
` const response = await provideRequestEvent(event, () =>`,
576604
` runMiddleware(request, (req) => dispatchRequest(req || request, event, options)),`,
577605
` );`,
606+
// The fold runs strictly AFTER the outermost middleware returned:
607+
// headers stay mutable through the whole unwind, and a middleware
608+
// early return (an API handler that never called next()) gets its
609+
// stub writes — cookies set inside the request scope, status — onto
610+
// the wire. Unconditional: page responses come back from
611+
// createSSRResponse committed and pass through untouched.
612+
` return commitEventResponse(response, event);`,
578613
`}`,
579614
);
580615

0 commit comments

Comments
 (0)