From 2ae334e5a07935acc18c189db7623dba94c71c33 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 9 Oct 2026 08:50:55 -0700 Subject: [PATCH 1/5] fix(web): do not cache a server component that throws during render respond() headers describe the success. A synchronous throw is still a 200 whose body carries the error, so an author's cache-control stored the failure. The render marks that response and the transport finalizer sends it no-store, then strips the mark. A returned error value keeps the headers the author set. Co-authored-by: Grok via Cursor Co-authored-by: Cursor --- .changeset/frame-error-not-cached.md | 5 ++ packages/web/frames/src/frame-sink.ts | 45 +++++++--- packages/web/server-functions/src/server.ts | 83 +++++++++-------- packages/web/server-functions/src/shared.ts | 12 +++ .../server/frame-error-not-cached.spec.tsx | 88 +++++++++++++++++++ 5 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 .changeset/frame-error-not-cached.md create mode 100644 packages/web/test/server/frame-error-not-cached.spec.tsx diff --git a/.changeset/frame-error-not-cached.md b/.changeset/frame-error-not-cached.md new file mode 100644 index 000000000..959a6e2c5 --- /dev/null +++ b/.changeset/frame-error-not-cached.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +A server component that throws during its synchronous render is no longer cacheable. `respond(View, { headers })` puts those headers on the frame response, and the response is a 200 whose body carries the error, so an author's `cache-control: public, max-age` stored the failure. The render marks the response when it fails, and the transport finalizer declines to store it (`no-store`) and strips the mark, so the mark never leaves. The status stays 200 and the body still carries the error record. A render that succeeds keeps the author's headers, and so does a returned `{ error }` value — the author chose that value and the headers that travel with it. A failure that lands after a suspended shell has already left can no longer change the head. diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 406f0c550..9b34c739d 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -203,6 +203,7 @@ import { isContainerTraced, toBorderForm } from "./frame-container-plugin.js"; import { DESCEND, rewriteTree } from "./tree-rewrite.js"; import { ChunkReader, + FAILED_VALUE_HEADER, createChunk, createEventChunk, frameAddress, @@ -380,6 +381,9 @@ function withHoles(chunk, holes) { * `hooks.onYield` is called after every sweep that emitted something — the * visible effect of one commit (a source yielding, a promise settling): the * producer's bound on a plain response counts these (see `frameStream`). + * `hooks.onError` is called when the frame itself fails (an unkeyed error): + * the render's own record of it, read by the response head. A keyed error + * is a fragment's diagnostic and is not the frame failing. * * @param {(chunk: object) => void} emit * @param {{ id: string, version: number }} frame @@ -746,6 +750,9 @@ export function createFrameSink(write, frame, have, hooks) { emit(chunk); }, error(errorId, error) { + // No key: the frame as one value failed. A keyed error names a + // fragment or a hole and is not that. + if (!errorId && hooks && hooks.onError) hooks.onError(); emit({ type: "error", id, version, key: errorId, error }); }, // A named slot invocation from the slot props proxy: the client's @@ -967,6 +974,10 @@ function frameStream(makeCode, options) { const bounded = !options.live; const maxYields = bounded ? (options.maxYields ?? DEFAULT_MAX_YIELDS) : 0; const maxDurationMs = bounded ? (options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS) : 0; + // Set by the sink when this frame fails as one value (an unkeyed error). + // The response head reads it after the sync pass; a failure after the + // shell has flushed cannot change that head. + let failed = false; function stream(w) { // Observe tier: the server half of the `"frame"` record // (`OBSERVE.records`, see `FrameProducedEvent`) — start → complete, with @@ -1026,18 +1037,18 @@ function frameStream(makeCode, options) { timer = setTimeout(() => cut("time"), maxDurationMs); } }; - const sink = createFrameSink( - emit, - frame, - options.resume && options.resume.have, - bounded - ? { - onYield() { - if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); - } + const sink = createFrameSink(emit, frame, options.resume && options.resume.have, { + // The frame itself failing, as opposed to a keyed fragment diagnostic. + // Read after this pass by the response head (`FAILED_VALUE_HEADER`). + onError() { + failed = true; + }, + onYield: bounded + ? () => { + if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); } : undefined - ); + }); // A `live` response is the wire tier's: the standing connection the // client holds after this answer is that tier's code. if (options.live) sink.needs("wire"); @@ -1089,6 +1100,9 @@ function frameStream(makeCode, options) { } return { pipe: stream, + get failed() { + return failed; + }, then(onFulfilled, onRejected) { return new Promise((resolve, reject) => { const chunks = []; @@ -2877,6 +2891,17 @@ export function serverComponentResponse(component, options = {}, init = {}) { teardown.abort({ [DISCONNECTED]: true }); } }); + // The author's Cache-Control arrived with the head (`respond()`) and + // describes the success. A sync render failure is a 200 whose body is + // the `:error` record; the render recorded it (`stream.failed`) before + // this head exists. Mark the response so the transport finalizer + // declines to store it — the framing pipe does not decide cache policy, + // and the mark itself never leaves (the finalizer strips it). Status + // stays 200: the client reads the frame stream, and a 4xx/5xx without + // the body-format header is a transport failure instead of the + // component's error. A failure after this, once a suspended shell has + // to leave, can no longer change the head. + if (stream.failed) headers.set(FAILED_VALUE_HEADER, "1"); return new Response(body, { status: init.status || 200, headers }); } diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 47863c145..97e1afd88 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -30,6 +30,7 @@ import { BODY_FORMAT_HEADER, BodyFormat, ERROR_HEADER, + FAILED_VALUE_HEADER, LIVE_SOURCE, REDIRECT_HEADER, SERVER_FUNCTION_INVOKE, @@ -4650,47 +4651,59 @@ function ownResponse(response) { * WIRE the way the docs describe it in prose. Without the default, CDN * zones with override-TTL or "cache everything" rules store per-user RPC * responses (#3071). + * - A value that failed while it was produced (`FAILED_VALUE_HEADER`, a + * server component that threw during its synchronous render) is not + * stored, even when the author set `Cache-Control`. Those headers + * described the success; the throw happened after `respond()` returned. + * The mark is stripped here, before CORS, so it never leaves. A returned + * `{ error }` value is not this: the author chose that value and the + * headers that travel with it. * - HEAD responses drop their body, as HTTP requires — the function still * ran (HEAD is gated identically to GET), so status and headers are those * of the equivalent GET (#3069). */ function finalizeTransportResponse(response, method) { const stripBody = method === "HEAD" && response.body !== null; - // Never onto a 304: a 304 is not a stored response, it is an UPDATE to - // one — RFC 9111 §4.3.4 has the cache freshen its stored entry with the - // header fields the 304 carries. `no-store` here would not decline to - // store this answer; it would instruct the cache to DROP the entry the - // conditional request was sent to keep alive, leaving the read worse off - // than uncached — a conditional round trip and then a full refetch, - // every other read, forever (#3134). An author who echoes Cache-Control - // on the 304 (RFC 9110 §15.4.5) was always untouched; this covers the - // minimal correct 304 the dev warning's own advice leads to. 204/205 are - // ordinary answers, not cache updates, and keep the default. - const defaultsCache = !response.headers.has("Cache-Control") && response.status !== 304; - if (stripBody || defaultsCache) { - try { - if (defaultsCache) { - response.headers.set("Cache-Control", "no-store"); - } - if (!stripBody) return response; - // discard, don't leak: the encoded body may be a live codec stream - response.body.cancel().catch(() => {}); - return new Response(null, { - status: response.status, - statusText: response.statusText, - headers: response.headers - }); - } catch { - // immutable headers (e.g. a raw fetch() Response passed through) - const headers = new Headers(response.headers); - if (defaultsCache && !headers.has("Cache-Control")) headers.set("Cache-Control", "no-store"); - if (stripBody) response.body.cancel().catch(() => {}); - return new Response(stripBody ? null : response.body, { - status: response.status, - statusText: response.statusText, - headers - }); + const failedValue = response.headers.has(FAILED_VALUE_HEADER); + // Never `no-store` onto a 304: a 304 is not a stored response, it is an + // UPDATE to one — RFC 9111 §4.3.4 has the cache freshen its stored entry + // with the header fields the 304 carries. `no-store` here would not + // decline to store this answer; it would instruct the cache to DROP the + // entry the conditional request was sent to keep alive, leaving the read + // worse off than uncached — a conditional round trip and then a full + // refetch, every other read, forever (#3134). An author who echoes + // Cache-Control on the 304 (RFC 9110 §15.4.5) was always untouched; this + // covers the minimal correct 304 the dev warning's own advice leads to. + // 204/205 are ordinary answers, not cache updates, and keep the default. + const settle = headers => { + if (failedValue) { + headers.delete(FAILED_VALUE_HEADER); + if (response.status !== 304) headers.set("Cache-Control", "no-store"); + return; } + if (!headers.has("Cache-Control") && response.status !== 304) { + headers.set("Cache-Control", "no-store"); + } + }; + try { + settle(response.headers); + if (!stripBody) return response; + // discard, don't leak: the encoded body may be a live codec stream + response.body.cancel().catch(() => {}); + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } catch { + // immutable headers (e.g. a raw fetch() Response passed through) + const headers = new Headers(response.headers); + settle(headers); + if (stripBody) response.body.cancel().catch(() => {}); + return new Response(stripBody ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers + }); } - return response; } diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index 4379a31b1..11871ffaa 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -735,6 +735,18 @@ export function serverFunctionUrlFor(endpoint, fn, args) { */ export const ERROR_HEADER = "X-Server-Function-Error"; +/** + * Internal mark that a response's value failed while it was being produced + * — a server component that threw during its synchronous render — as + * distinct from a thrown call (`ERROR_HEADER`). The call itself succeeded: + * the status stays 200 and the error rides in the body. The handler's + * transport finalizer consumes the mark on the way out (the author's + * `Cache-Control` described the success and does not apply) and the header + * never leaves. + * @internal + */ +export const FAILED_VALUE_HEADER = "X-Solid-Failed-Value"; + // HTTP header values are ByteStrings (latin1): Headers.set throws on code // points above U+00FF (or corrupts them, per platform), which used to turn a // thrown error with a CJK/emoji message into a bare 500 with nothing for the diff --git a/packages/web/test/server/frame-error-not-cached.spec.tsx b/packages/web/test/server/frame-error-not-cached.spec.tsx new file mode 100644 index 000000000..750121b7c --- /dev/null +++ b/packages/web/test/server/frame-error-not-cached.spec.tsx @@ -0,0 +1,88 @@ +/** + * @jsxImportSource @solidjs/web + * + * A server component wrapped in `respond(View, { headers })` contributes + * those headers to the frame response. The component renders while the + * response is being built, so a throw in that pass is known before the head + * leaves: the render marks the response, and the transport finalizer + * declines to store it. The status stays 200 and the body still carries the + * `:error` record — that is how the client surfaces it. The mark itself + * never leaves. A render that succeeds keeps the author's headers, and so + * does a returned `{ error }` value: the author chose that value and the + * headers that travel with it. + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { respond } from "@solidjs/web"; +import { frameTransformResult } from "../../frames/src/frame-sink.js"; +import { + GET, + createServerReference, + handleServerFunctionRequest, + registerServerReference +} from "../../server-functions/src/server.js"; +import { ChunkReader, FAILED_VALUE_HEADER } from "../../server-functions/src/shared.js"; + +const RequestContext = Symbol.for("solid.RequestContext"); +beforeAll(() => { + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); +}); +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +const handle = (request: Request) => + handleServerFunctionRequest(request, { transformResult: frameTransformResult }); + +const cacheable = { headers: { "cache-control": "public, max-age=60" } }; + +function declare(id: string, fn: () => unknown) { + return GET(createServerReference(registerServerReference(id, fn))); +} + +const get = (id: string) => new Request(`http://localhost/_server/data/${id}`, { method: "GET" }); + +async function chunksOf(response: Response) { + const reader: any = new ChunkReader(response.body!); + const out: any[] = []; + await reader.drain((data: string) => out.push(JSON.parse(data))); + return out; +} + +describe("a thrown server component is not cached behind respond() headers", () => { + it("a sync throw answers 200, no-store, with the error record still in the body", async () => { + declare("frame-error-cache-throw", async () => { + const user = null; + return respond(() =>

User : {user!.id}

, cacheable); + }); + const response = await handle(get("frame-error-cache-throw")); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); + expect(response.headers.get("X-Frame-Stream")).toBe("frame-error-cache-throw"); + const chunks = await chunksOf(response); + const error = chunks.find(c => c.type === "error" && !c.key); + expect(error).toBeTruthy(); + expect(String(error.error)).toContain("id"); + }); + + it("a render that succeeds keeps the author's Cache-Control", async () => { + declare("frame-error-cache-ok", async () => respond(() =>

User : ada

, cacheable)); + const response = await handle(get("frame-error-cache-ok")); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("public, max-age=60"); + expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); + const chunks = await chunksOf(response); + expect(chunks.map(c => c.type)).toEqual(["start", "html", "complete"]); + }); + + it("a returned { error } value keeps the author's Cache-Control", async () => { + declare("frame-error-cache-value", async () => respond({ error: "nope" }, cacheable)); + const response = await handle(get("frame-error-cache-value")); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("public, max-age=60"); + expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); + expect(response.headers.get("X-Frame-Stream")).toBeNull(); + expect(await response.json()).toEqual({ error: "nope" }); + }); +}); From 1a6b791983397fea343d5dd0b68b2578455f0431 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 9 Oct 2026 13:58:54 -0700 Subject: [PATCH 2/5] fix(web): re-export the failed-value mark for the frames server bundle The frames server artifact resolves its shared.js imports through the server-functions server entry. FAILED_VALUE_HEADER was read there and never re-exported, so example SSR builds failed with a missing export. Co-authored-by: Grok via Cursor Co-authored-by: Cursor --- packages/web/server-functions/src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 97e1afd88..006552573 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -71,6 +71,7 @@ export { // building blocks, not for hand-written code. ChunkReader, ERROR_HEADER, + FAILED_VALUE_HEADER, FLASH_COOKIE, REDIRECT_HEADER, SERVER_FUNCTION_INVOKE, From 47a87ed4a61d06764764184e0a166268e44efe8f Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 12 Oct 2026 08:18:21 -0700 Subject: [PATCH 3/5] test(web): type the null user so the throw still typechecks A null literal asserted non-null is never, so user!.id failed the package typecheck. The value stays null at runtime. Co-authored-by: Grok via Cursor Co-authored-by: Cursor --- packages/web/test/server/frame-error-not-cached.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/test/server/frame-error-not-cached.spec.tsx b/packages/web/test/server/frame-error-not-cached.spec.tsx index 750121b7c..8da0a670e 100644 --- a/packages/web/test/server/frame-error-not-cached.spec.tsx +++ b/packages/web/test/server/frame-error-not-cached.spec.tsx @@ -52,7 +52,7 @@ async function chunksOf(response: Response) { describe("a thrown server component is not cached behind respond() headers", () => { it("a sync throw answers 200, no-store, with the error record still in the body", async () => { declare("frame-error-cache-throw", async () => { - const user = null; + const user: { id: string } | null = null; return respond(() =>

User : {user!.id}

, cacheable); }); const response = await handle(get("frame-error-cache-throw")); From f654b0b796af50d61b7d785bb046c7f9787e07e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:57:38 -0700 Subject: [PATCH 4/5] fix(web): decide a failed frame's cache policy in serverComponentResponse Rework of the internal-mark approach: no new export, no internal header and no change to the public renderServerComponent result or createFrameSink hooks. serverComponentResponse already sees every record of the sync pass in its own write; an unkeyed error there makes the response no-store before the head is built, and drops CDN-Cache-Control, -CDN-Cache-Control and Surrogate-Control, which would override Cache-Control at a CDN (RFC 9213). A direct serverComponentResponse caller gets the same policy. FAILED_VALUE_HEADER and the finalizer change are reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PDt8Jv6uf5xRDsrux4UwF --- .changeset/frame-error-not-cached.md | 2 +- packages/web/frames/src/frame-sink.ts | 64 +++++++------- packages/web/server-functions/src/server.ts | 84 ++++++++----------- packages/web/server-functions/src/shared.ts | 12 --- .../server/frame-error-not-cached.spec.tsx | 63 +++++++++++--- 5 files changed, 117 insertions(+), 108 deletions(-) diff --git a/.changeset/frame-error-not-cached.md b/.changeset/frame-error-not-cached.md index 959a6e2c5..d671a04cd 100644 --- a/.changeset/frame-error-not-cached.md +++ b/.changeset/frame-error-not-cached.md @@ -2,4 +2,4 @@ "@solidjs/web": patch --- -A server component that throws during its synchronous render is no longer cacheable. `respond(View, { headers })` puts those headers on the frame response, and the response is a 200 whose body carries the error, so an author's `cache-control: public, max-age` stored the failure. The render marks the response when it fails, and the transport finalizer declines to store it (`no-store`) and strips the mark, so the mark never leaves. The status stays 200 and the body still carries the error record. A render that succeeds keeps the author's headers, and so does a returned `{ error }` value — the author chose that value and the headers that travel with it. A failure that lands after a suspended shell has already left can no longer change the head. +A server component that throws during its synchronous render is no longer cacheable. `respond(View, { headers })` puts those headers on the frame response, and the response is a 200 whose body carries the error, so an author's `cache-control: public, max-age` stored the failure. When the render fails before the head is built, `serverComponentResponse` sends `Cache-Control: no-store` and drops the CDN-targeted fields that would override it at a CDN (`CDN-Cache-Control`, `-CDN-Cache-Control`, `Surrogate-Control`). The status stays 200 and the body still carries the error record. A render that succeeds keeps the author's headers, and so does a returned `{ error }` value: the author chose that value and the headers that travel with it. A failure that lands after a suspended shell has already left can no longer change the head. diff --git a/packages/web/frames/src/frame-sink.ts b/packages/web/frames/src/frame-sink.ts index 9b34c739d..9a2add4b1 100644 --- a/packages/web/frames/src/frame-sink.ts +++ b/packages/web/frames/src/frame-sink.ts @@ -203,7 +203,6 @@ import { isContainerTraced, toBorderForm } from "./frame-container-plugin.js"; import { DESCEND, rewriteTree } from "./tree-rewrite.js"; import { ChunkReader, - FAILED_VALUE_HEADER, createChunk, createEventChunk, frameAddress, @@ -381,9 +380,6 @@ function withHoles(chunk, holes) { * `hooks.onYield` is called after every sweep that emitted something — the * visible effect of one commit (a source yielding, a promise settling): the * producer's bound on a plain response counts these (see `frameStream`). - * `hooks.onError` is called when the frame itself fails (an unkeyed error): - * the render's own record of it, read by the response head. A keyed error - * is a fragment's diagnostic and is not the frame failing. * * @param {(chunk: object) => void} emit * @param {{ id: string, version: number }} frame @@ -750,9 +746,6 @@ export function createFrameSink(write, frame, have, hooks) { emit(chunk); }, error(errorId, error) { - // No key: the frame as one value failed. A keyed error names a - // fragment or a hole and is not that. - if (!errorId && hooks && hooks.onError) hooks.onError(); emit({ type: "error", id, version, key: errorId, error }); }, // A named slot invocation from the slot props proxy: the client's @@ -974,10 +967,6 @@ function frameStream(makeCode, options) { const bounded = !options.live; const maxYields = bounded ? (options.maxYields ?? DEFAULT_MAX_YIELDS) : 0; const maxDurationMs = bounded ? (options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS) : 0; - // Set by the sink when this frame fails as one value (an unkeyed error). - // The response head reads it after the sync pass; a failure after the - // shell has flushed cannot change that head. - let failed = false; function stream(w) { // Observe tier: the server half of the `"frame"` record // (`OBSERVE.records`, see `FrameProducedEvent`) — start → complete, with @@ -1037,18 +1026,18 @@ function frameStream(makeCode, options) { timer = setTimeout(() => cut("time"), maxDurationMs); } }; - const sink = createFrameSink(emit, frame, options.resume && options.resume.have, { - // The frame itself failing, as opposed to a keyed fragment diagnostic. - // Read after this pass by the response head (`FAILED_VALUE_HEADER`). - onError() { - failed = true; - }, - onYield: bounded - ? () => { - if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); + const sink = createFrameSink( + emit, + frame, + options.resume && options.resume.have, + bounded + ? { + onYield() { + if (flushed && maxYields > 0 && ++yields >= maxYields) cut("yields"); + } } : undefined - }); + ); // A `live` response is the wire tier's: the standing connection the // client holds after this answer is that tier's code. if (options.live) sink.needs("wire"); @@ -1100,9 +1089,6 @@ function frameStream(makeCode, options) { } return { pipe: stream, - get failed() { - return failed; - }, then(onFulfilled, onRejected) { return new Promise((resolve, reject) => { const chunks = []; @@ -2836,6 +2822,10 @@ export function serverComponentResponse(component, options = {}, init = {}) { // The live body's heartbeat and dev chaos (see armLiveBody); disarmed on // every road the body ends by. let stopLive = null; + // The frame itself failed as one value (an unkeyed `error` record; a keyed + // one is a fragment's diagnostic). Seen during the sync pass, it decides + // the head below. + let failed = false; const body = new ReadableStream({ start(controller) { const end = () => { @@ -2863,6 +2853,7 @@ export function serverComponentResponse(component, options = {}, init = {}) { }); stream.pipe({ write(chunk) { + if (chunk.type === "error" && !chunk.key) failed = true; if (closed) return; try { controller.enqueue(frame(JSON.stringify(chunk))); @@ -2891,17 +2882,20 @@ export function serverComponentResponse(component, options = {}, init = {}) { teardown.abort({ [DISCONNECTED]: true }); } }); - // The author's Cache-Control arrived with the head (`respond()`) and - // describes the success. A sync render failure is a 200 whose body is - // the `:error` record; the render recorded it (`stream.failed`) before - // this head exists. Mark the response so the transport finalizer - // declines to store it — the framing pipe does not decide cache policy, - // and the mark itself never leaves (the finalizer strips it). Status - // stays 200: the client reads the frame stream, and a 4xx/5xx without - // the body-format header is a transport failure instead of the - // component's error. A failure after this, once a suspended shell has - // to leave, can no longer change the head. - if (stream.failed) headers.set(FAILED_VALUE_HEADER, "1"); + // The author's cache headers arrived with the head (`respond()`) and + // describe a success. A sync render failure is a 200 whose body is the + // `:error` record, seen above before this head exists: declare it + // uncacheable, for every cache. A CDN-targeted field (`CDN-Cache-Control`, + // `-CDN-Cache-Control`, RFC 9213) or `Surrogate-Control` overrides + // `Cache-Control` at the CDN, so those go too. Status stays 200: the client + // reads the frame stream, and a 4xx/5xx without the body-format header is + // a transport failure instead of the component's error. A failure after + // this, once a suspended shell has to leave, can no longer change the head. + if (failed) { + headers.set("Cache-Control", "no-store"); + for (const name of [...headers.keys()]) + if (name === "surrogate-control" || name.endsWith("cdn-cache-control")) headers.delete(name); + } return new Response(body, { status: init.status || 200, headers }); } diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 006552573..47863c145 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -30,7 +30,6 @@ import { BODY_FORMAT_HEADER, BodyFormat, ERROR_HEADER, - FAILED_VALUE_HEADER, LIVE_SOURCE, REDIRECT_HEADER, SERVER_FUNCTION_INVOKE, @@ -71,7 +70,6 @@ export { // building blocks, not for hand-written code. ChunkReader, ERROR_HEADER, - FAILED_VALUE_HEADER, FLASH_COOKIE, REDIRECT_HEADER, SERVER_FUNCTION_INVOKE, @@ -4652,59 +4650,47 @@ function ownResponse(response) { * WIRE the way the docs describe it in prose. Without the default, CDN * zones with override-TTL or "cache everything" rules store per-user RPC * responses (#3071). - * - A value that failed while it was produced (`FAILED_VALUE_HEADER`, a - * server component that threw during its synchronous render) is not - * stored, even when the author set `Cache-Control`. Those headers - * described the success; the throw happened after `respond()` returned. - * The mark is stripped here, before CORS, so it never leaves. A returned - * `{ error }` value is not this: the author chose that value and the - * headers that travel with it. * - HEAD responses drop their body, as HTTP requires — the function still * ran (HEAD is gated identically to GET), so status and headers are those * of the equivalent GET (#3069). */ function finalizeTransportResponse(response, method) { const stripBody = method === "HEAD" && response.body !== null; - const failedValue = response.headers.has(FAILED_VALUE_HEADER); - // Never `no-store` onto a 304: a 304 is not a stored response, it is an - // UPDATE to one — RFC 9111 §4.3.4 has the cache freshen its stored entry - // with the header fields the 304 carries. `no-store` here would not - // decline to store this answer; it would instruct the cache to DROP the - // entry the conditional request was sent to keep alive, leaving the read - // worse off than uncached — a conditional round trip and then a full - // refetch, every other read, forever (#3134). An author who echoes - // Cache-Control on the 304 (RFC 9110 §15.4.5) was always untouched; this - // covers the minimal correct 304 the dev warning's own advice leads to. - // 204/205 are ordinary answers, not cache updates, and keep the default. - const settle = headers => { - if (failedValue) { - headers.delete(FAILED_VALUE_HEADER); - if (response.status !== 304) headers.set("Cache-Control", "no-store"); - return; - } - if (!headers.has("Cache-Control") && response.status !== 304) { - headers.set("Cache-Control", "no-store"); + // Never onto a 304: a 304 is not a stored response, it is an UPDATE to + // one — RFC 9111 §4.3.4 has the cache freshen its stored entry with the + // header fields the 304 carries. `no-store` here would not decline to + // store this answer; it would instruct the cache to DROP the entry the + // conditional request was sent to keep alive, leaving the read worse off + // than uncached — a conditional round trip and then a full refetch, + // every other read, forever (#3134). An author who echoes Cache-Control + // on the 304 (RFC 9110 §15.4.5) was always untouched; this covers the + // minimal correct 304 the dev warning's own advice leads to. 204/205 are + // ordinary answers, not cache updates, and keep the default. + const defaultsCache = !response.headers.has("Cache-Control") && response.status !== 304; + if (stripBody || defaultsCache) { + try { + if (defaultsCache) { + response.headers.set("Cache-Control", "no-store"); + } + if (!stripBody) return response; + // discard, don't leak: the encoded body may be a live codec stream + response.body.cancel().catch(() => {}); + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } catch { + // immutable headers (e.g. a raw fetch() Response passed through) + const headers = new Headers(response.headers); + if (defaultsCache && !headers.has("Cache-Control")) headers.set("Cache-Control", "no-store"); + if (stripBody) response.body.cancel().catch(() => {}); + return new Response(stripBody ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers + }); } - }; - try { - settle(response.headers); - if (!stripBody) return response; - // discard, don't leak: the encoded body may be a live codec stream - response.body.cancel().catch(() => {}); - return new Response(null, { - status: response.status, - statusText: response.statusText, - headers: response.headers - }); - } catch { - // immutable headers (e.g. a raw fetch() Response passed through) - const headers = new Headers(response.headers); - settle(headers); - if (stripBody) response.body.cancel().catch(() => {}); - return new Response(stripBody ? null : response.body, { - status: response.status, - statusText: response.statusText, - headers - }); } + return response; } diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index 11871ffaa..4379a31b1 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -735,18 +735,6 @@ export function serverFunctionUrlFor(endpoint, fn, args) { */ export const ERROR_HEADER = "X-Server-Function-Error"; -/** - * Internal mark that a response's value failed while it was being produced - * — a server component that threw during its synchronous render — as - * distinct from a thrown call (`ERROR_HEADER`). The call itself succeeded: - * the status stays 200 and the error rides in the body. The handler's - * transport finalizer consumes the mark on the way out (the author's - * `Cache-Control` described the success and does not apply) and the header - * never leaves. - * @internal - */ -export const FAILED_VALUE_HEADER = "X-Solid-Failed-Value"; - // HTTP header values are ByteStrings (latin1): Headers.set throws on code // points above U+00FF (or corrupts them, per platform), which used to turn a // thrown error with a CJK/emoji message into a bare 500 with nothing for the diff --git a/packages/web/test/server/frame-error-not-cached.spec.tsx b/packages/web/test/server/frame-error-not-cached.spec.tsx index 8da0a670e..98ab577f4 100644 --- a/packages/web/test/server/frame-error-not-cached.spec.tsx +++ b/packages/web/test/server/frame-error-not-cached.spec.tsx @@ -4,24 +4,24 @@ * A server component wrapped in `respond(View, { headers })` contributes * those headers to the frame response. The component renders while the * response is being built, so a throw in that pass is known before the head - * leaves: the render marks the response, and the transport finalizer - * declines to store it. The status stays 200 and the body still carries the - * `:error` record — that is how the client surfaces it. The mark itself - * never leaves. A render that succeeds keeps the author's headers, and so - * does a returned `{ error }` value: the author chose that value and the - * headers that travel with it. + * leaves, and `serverComponentResponse` declares the response uncacheable: + * `no-store`, with the CDN-targeted fields that would override it at a CDN + * removed. The status stays 200 and the body still carries the `:error` + * record — that is how the client surfaces it. A render that succeeds keeps + * the author's headers, and so does a returned `{ error }` value: the author + * chose that value and the headers that travel with it. */ import { AsyncLocalStorage } from "node:async_hooks"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { respond } from "@solidjs/web"; -import { frameTransformResult } from "../../frames/src/frame-sink.js"; +import { frameTransformResult, serverComponentResponse } from "../../frames/src/frame-sink.js"; import { GET, createServerReference, handleServerFunctionRequest, registerServerReference } from "../../server-functions/src/server.js"; -import { ChunkReader, FAILED_VALUE_HEADER } from "../../server-functions/src/shared.js"; +import { ChunkReader } from "../../server-functions/src/shared.js"; const RequestContext = Symbol.for("solid.RequestContext"); beforeAll(() => { @@ -35,6 +35,16 @@ const handle = (request: Request) => handleServerFunctionRequest(request, { transformResult: frameTransformResult }); const cacheable = { headers: { "cache-control": "public, max-age=60" } }; +const cdnCacheable = { + headers: { + "cache-control": "public, max-age=60", + "cdn-cache-control": "max-age=600", + "cloudflare-cdn-cache-control": "max-age=600", + "surrogate-control": "max-age=600", + vary: "Accept-Language" + } +}; +const cdnFields = ["cdn-cache-control", "cloudflare-cdn-cache-control", "surrogate-control"]; function declare(id: string, fn: () => unknown) { return GET(createServerReference(registerServerReference(id, fn))); @@ -58,7 +68,6 @@ describe("a thrown server component is not cached behind respond() headers", () const response = await handle(get("frame-error-cache-throw")); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); expect(response.headers.get("X-Frame-Stream")).toBe("frame-error-cache-throw"); const chunks = await chunksOf(response); const error = chunks.find(c => c.type === "error" && !c.key); @@ -71,7 +80,6 @@ describe("a thrown server component is not cached behind respond() headers", () const response = await handle(get("frame-error-cache-ok")); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("public, max-age=60"); - expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); const chunks = await chunksOf(response); expect(chunks.map(c => c.type)).toEqual(["start", "html", "complete"]); }); @@ -81,8 +89,41 @@ describe("a thrown server component is not cached behind respond() headers", () const response = await handle(get("frame-error-cache-value")); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("public, max-age=60"); - expect(response.headers.get(FAILED_VALUE_HEADER)).toBeNull(); expect(response.headers.get("X-Frame-Stream")).toBeNull(); expect(await response.json()).toEqual({ error: "nope" }); }); + + it("a sync throw also drops the CDN-targeted fields that would override no-store", async () => { + declare("frame-error-cache-cdn-throw", async () => { + const user: { id: string } | null = null; + return respond(() =>

User : {user!.id}

, cdnCacheable); + }); + const response = await handle(get("frame-error-cache-cdn-throw")); + expect(response.headers.get("cache-control")).toBe("no-store"); + for (const name of cdnFields) expect(response.headers.get(name), name).toBeNull(); + expect(response.headers.get("vary")).toBe("Accept-Language"); + }); + + it("a render that succeeds keeps the CDN-targeted fields", async () => { + declare("frame-error-cache-cdn-ok", async () => + respond(() =>

User : ada

, cdnCacheable) + ); + const response = await handle(get("frame-error-cache-cdn-ok")); + expect(response.headers.get("cache-control")).toBe("public, max-age=60"); + for (const name of cdnFields) expect(response.headers.get(name), name).toBe("max-age=600"); + }); + + it("serverComponentResponse called directly declines to store a sync throw", async () => { + const user: { id: string } | null = null; + const response = serverComponentResponse( + () =>

User : {user!.id}

, + { frame: { id: "direct" } }, + cdnCacheable + ); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + for (const name of cdnFields) expect(response.headers.get(name), name).toBeNull(); + const chunks = await chunksOf(response); + expect(chunks.some(c => c.type === "error" && !c.key)).toBe(true); + }); }); From 0e80780da5763d529b9d2ddf379684b1f05b6364 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:49:01 +0000 Subject: [PATCH 5/5] refactor(web): drop the frame building blocks from frames/server renderServerComponent, renderToFrameStream, serverComponentResponse and createFrameSink were exported from @solidjs/web/frames/server, but apps never call them: a server component is returned from a server function (directly or through respond()) and the transform hooks frame it. Keep the hooks and document-shell pieces as the entry's surface. The direct serverComponentResponse case leaves the not-cached spec, and the two built-package probes now reach frames through the server-function handler, the way an app does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1 --- .changeset/frame-error-not-cached.md | 4 +- packages/diagnostics/README.md | 2 +- packages/web/frames/src/server.ts | 4 - .../test/dist-frames-server-instance.spec.ts | 55 +++++++------- .../server/frame-error-not-cached.spec.tsx | 18 +---- .../server/ssr-error-sanitization.fixture.mjs | 75 +++++++++++++------ 6 files changed, 89 insertions(+), 69 deletions(-) diff --git a/.changeset/frame-error-not-cached.md b/.changeset/frame-error-not-cached.md index d671a04cd..294204693 100644 --- a/.changeset/frame-error-not-cached.md +++ b/.changeset/frame-error-not-cached.md @@ -2,4 +2,6 @@ "@solidjs/web": patch --- -A server component that throws during its synchronous render is no longer cacheable. `respond(View, { headers })` puts those headers on the frame response, and the response is a 200 whose body carries the error, so an author's `cache-control: public, max-age` stored the failure. When the render fails before the head is built, `serverComponentResponse` sends `Cache-Control: no-store` and drops the CDN-targeted fields that would override it at a CDN (`CDN-Cache-Control`, `-CDN-Cache-Control`, `Surrogate-Control`). The status stays 200 and the body still carries the error record. A render that succeeds keeps the author's headers, and so does a returned `{ error }` value: the author chose that value and the headers that travel with it. A failure that lands after a suspended shell has already left can no longer change the head. +A server component that throws during its synchronous render is no longer cacheable. `respond(View, { headers })` puts those headers on the frame response, and the response is a 200 whose body carries the error, so an author's `cache-control: public, max-age` stored the failure. When the render fails before the head is built, the frame response sends `Cache-Control: no-store` and drops the CDN-targeted fields that would override it at a CDN (`CDN-Cache-Control`, `-CDN-Cache-Control`, `Surrogate-Control`). The status stays 200 and the body still carries the error record. A render that succeeds keeps the author's headers, and so does a returned `{ error }` value: the author chose that value and the headers that travel with it. A failure that lands after a suspended shell has already left can no longer change the head. + +`@solidjs/web/frames/server` no longer exports `renderServerComponent`, `renderToFrameStream`, `serverComponentResponse` or `createFrameSink`. They were internal building blocks: a server component reaches the client by being returned from a server function (directly or wrapped in `respond()`), and the transform hooks (`frameTransformResult`, `frameTransformFlightResult`, `frameTransformDirectResult`) render and frame it. The entry is experimental, and the hooks and document-shell exports are unchanged. diff --git a/packages/diagnostics/README.md b/packages/diagnostics/README.md index a94c6d7ad..5695c2acc 100644 --- a/packages/diagnostics/README.md +++ b/packages/diagnostics/README.md @@ -57,7 +57,7 @@ artifact.records.invocation; // every server-function execution: id, durationMs, artifact.records.frame; // every frame stream produced (side: "server"): id, shellMs, durationMs, outcome, the chunk census ``` -`boundary` is one row per `` boundary that **waited** (a boundary that rendered on its first pass has nothing to attribute): how long it held its content up (`durationMs`), how long finished content then sat behind `` siblings (`heldMs`), how many render passes it took (`passes` — `2` is one round of async, more is a sequential chain) and how it ended (`outcome`: settled, the `renderToString` fallback, a client-only handoff, an error). `invocation` is one row per server-function execution; a direct call made during a boundary's pass carries that boundary's `id` in `boundary`, so a boundary's wait reads as the calls it consisted of. `frame` is one row per frame stream a server component rendered to (`renderServerComponent`, or a server-function response through `frameTransformResult`): time to the shell (`shellMs`) and to `complete` (`durationMs`), how it ended, and a census of what the stream carried (`fragments`, `slots`, `regions`, `errors`); a server-function response that is a frame stream has an invocation row and a frame row with the same `id`. The runtime derives two dev checks from the same facts — `SSR_BOUNDARY_WATERFALL` for a sequential chain of waits and `SSR_CLIENT_CONTENT_MASKED` for client-only content that surfaced only after a wait — so `expectNoDiagnostics` catches them without reading the tables. +`boundary` is one row per `` boundary that **waited** (a boundary that rendered on its first pass has nothing to attribute): how long it held its content up (`durationMs`), how long finished content then sat behind `` siblings (`heldMs`), how many render passes it took (`passes` — `2` is one round of async, more is a sequential chain) and how it ended (`outcome`: settled, the `renderToString` fallback, a client-only handoff, an error). `invocation` is one row per server-function execution; a direct call made during a boundary's pass carries that boundary's `id` in `boundary`, so a boundary's wait reads as the calls it consisted of. `frame` is one row per frame stream a server component rendered to (a server-function response through `frameTransformResult`): time to the shell (`shellMs`) and to `complete` (`durationMs`), how it ended, and a census of what the stream carried (`fragments`, `slots`, `regions`, `errors`); a server-function response that is a frame stream has an invocation row and a frame row with the same `id`. The runtime derives two dev checks from the same facts — `SSR_BOUNDARY_WATERFALL` for a sequential chain of waits and `SSR_CLIENT_CONTENT_MASKED` for client-only content that surfaced only after a wait — so `expectNoDiagnostics` catches them without reading the tables. In the browser (the in-process capture in a jsdom test, or the bridge under Playwright) the same tables hold the page's **requests**: `artifact.records.call` is one row per server-function call the client made — `id`, `method`, `durationMs` (the whole wait the caller saw), `outcome`, `status` — and `artifact.records.frame` rows with `side: "client"` are the frame streams it applied (`address` is the local boundary the stream was remapped onto; `outcome` adds `truncated` for a body that ended early). A `call` and the server's `invocation` of the same `id` differ by the wire; a `frame` seen from both sides joins by `id` and `version`. `artifact.records.recovery` is the client's side of a server hand-off: one row per `` boundary that rendered its children as fresh client DOM because the server could not produce the fragment (`outcome: "client"` on the server's `boundary` row of the same `id`) — `waitedMs` is the fallback the person looked at while the server was still trying, `renderMs` the fresh render. The tables are always present; one is empty when nothing of its kind happened. Their row types are the runtimes' own — `BoundaryEvent`/`RecoveryEvent` from `solid-js`, `InvocationEvent`/`FrameEvent`/`CallEvent` from `@solidjs/web` — which is why those two packages are (type-only) peers of this one. JSONL egress adds one line per record, `type` naming its table. diff --git a/packages/web/frames/src/server.ts b/packages/web/frames/src/server.ts index 57fcf08ce..56638815e 100644 --- a/packages/web/frames/src/server.ts +++ b/packages/web/frames/src/server.ts @@ -138,14 +138,10 @@ export function asyncArg(value: PromiseLike | AsyncIterable): T { } export { - renderToFrameStream, - renderServerComponent, - serverComponentResponse, frameTransformResult, // Single-flight: mutations whose invalidated payload includes markup // stream it as regions in one response (install as transformFlightResult) frameTransformFlightResult, - createFrameSink, // Document SSR (t=0): inline rendering + the hydration reference frameTransformDirectResult, ServerComponentPlugin, diff --git a/packages/web/test/dist-frames-server-instance.spec.ts b/packages/web/test/dist-frames-server-instance.spec.ts index f447b6379..53bd8dc65 100644 --- a/packages/web/test/dist-frames-server-instance.spec.ts +++ b/packages/web/test/dist-frames-server-instance.spec.ts @@ -172,12 +172,16 @@ describe("a single-flight server-component call carries its invocation id (#3641 FRAME_STREAM_HEADER, frameTransformDirectResult, frameTransformFlightResult, - frameTransformResult, - renderServerComponent + frameTransformResult } from "@solidjs/web/frames/server"; import { provideRequestEvent } from "@solidjs/web/storage"; createServerReference(registerServerReference("view-1", async () => () => "view markup")); + createServerReference( + registerServerReference("view-select", async () => () => + ssr('') + ) + ); let handlerSawInvocation; configureServerFunctionsServer({ @@ -193,32 +197,33 @@ describe("a single-flight server-component call carries its invocation id (#3641 collectFlightData: async () => ({ "route-data[]": { title: "x" } }) }); - const res = await handleServerFunctionRequest( - new Request("http://localhost/_server/data/view-1", { - method: "POST", - headers: { - "content-type": "application/json", - origin: "http://localhost", - "sec-fetch-site": "same-origin", - "x-server-function-format": "3", - "x-single-flight": "true" - }, - body: "[]" - }) - ); - const text = await res.text(); + const call = (id, singleFlight) => + handleServerFunctionRequest( + new Request("http://localhost/_server/data/" + id, { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost", + "sec-fetch-site": "same-origin", + "x-server-function-format": "3", + ...(singleFlight ? { "x-single-flight": "true" } : {}) + }, + body: "[]" + }) + ); // Length-prefixed frames: \`;0x<8 hex>;\` + JSON. - const chunks = text - .split(/;0x[0-9a-f]{8};/) - .filter(Boolean) - .map(s => JSON.parse(s)) - .map(({ type, id, html }) => ({ type, id, html })); + const chunksOf = async res => + (await res.text()) + .split(/;0x[0-9a-f]{8};/) + .filter(Boolean) + .map(s => JSON.parse(s)) + .map(({ type, id, html }) => ({ type, id, html })); + + const res = await call("view-1", true); + const chunks = await chunksOf(res); ssrSelectValues(); - const frame = await renderServerComponent( - () => ssr(''), - { frame: { id: "f", version: 1 } } - ); + const frame = await chunksOf(await call("view-select", false)); process.stdout.write( JSON.stringify({ diff --git a/packages/web/test/server/frame-error-not-cached.spec.tsx b/packages/web/test/server/frame-error-not-cached.spec.tsx index 98ab577f4..4b019d1a3 100644 --- a/packages/web/test/server/frame-error-not-cached.spec.tsx +++ b/packages/web/test/server/frame-error-not-cached.spec.tsx @@ -4,7 +4,7 @@ * A server component wrapped in `respond(View, { headers })` contributes * those headers to the frame response. The component renders while the * response is being built, so a throw in that pass is known before the head - * leaves, and `serverComponentResponse` declares the response uncacheable: + * leaves, and the frame response is declared uncacheable: * `no-store`, with the CDN-targeted fields that would override it at a CDN * removed. The status stays 200 and the body still carries the `:error` * record — that is how the client surfaces it. A render that succeeds keeps @@ -14,7 +14,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { respond } from "@solidjs/web"; -import { frameTransformResult, serverComponentResponse } from "../../frames/src/frame-sink.js"; +import { frameTransformResult } from "../../frames/src/frame-sink.js"; import { GET, createServerReference, @@ -112,18 +112,4 @@ describe("a thrown server component is not cached behind respond() headers", () expect(response.headers.get("cache-control")).toBe("public, max-age=60"); for (const name of cdnFields) expect(response.headers.get(name), name).toBe("max-age=600"); }); - - it("serverComponentResponse called directly declines to store a sync throw", async () => { - const user: { id: string } | null = null; - const response = serverComponentResponse( - () =>

User : {user!.id}

, - { frame: { id: "direct" } }, - cdnCacheable - ); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - for (const name of cdnFields) expect(response.headers.get(name), name).toBeNull(); - const chunks = await chunksOf(response); - expect(chunks.some(c => c.type === "error" && !c.key)).toBe(true); - }); }); diff --git a/packages/web/test/server/ssr-error-sanitization.fixture.mjs b/packages/web/test/server/ssr-error-sanitization.fixture.mjs index 5fd39ccde..b9341edba 100644 --- a/packages/web/test/server/ssr-error-sanitization.fixture.mjs +++ b/packages/web/test/server/ssr-error-sanitization.fixture.mjs @@ -16,7 +16,14 @@ import { renderToString, ssr } from "@solidjs/web"; -import { renderServerComponent } from "@solidjs/web/frames/server"; +import { frameTransformResult } from "@solidjs/web/frames/server"; +import { + configureServerFunctionsServer, + createServerReference, + handleServerFunctionRequest, + registerServerReference +} from "@solidjs/web/server-functions/server"; +import { provideRequestEvent } from "@solidjs/web/storage"; import { OBSERVE, createMemo } from "solid-js"; const delay = ms => new Promise(r => setTimeout(r, ms)); @@ -169,25 +176,50 @@ results.value = await withFindings(() => }) ); -// 6. Frame streams: a synchronous root failure's error chunk, and a failing -// fragment's keyed chunks — the fragment's, and the live hole's: a thunk -// hole (`() => escape(...)`, the compiled shape of a dynamic expression in -// a server component) whose re-evaluation throws. +// 6. Frame streams, served as an app serves them (a server function returning +// a view, through the handler's frame transform): a synchronous root +// failure's error chunk, and a failing fragment's keyed chunks — the +// fragment's, and the live hole's: a thunk hole (`() => escape(...)`, the +// compiled shape of a dynamic expression in a server component) whose +// re-evaluation throws. { - const boom = databaseError(); - results.frameRoot = await withFindings(async () => { - const chunks = await renderServerComponent( - () => { - throw boom; - }, - { frame: { id: "f-err" } } - ); - return chunks; + configureServerFunctionsServer({ + provideEvent: provideRequestEvent, + transformResult: frameTransformResult }); + const frameChunks = async id => { + const res = await handleServerFunctionRequest( + new Request(`http://localhost/_server/data/${id}`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost", + "sec-fetch-site": "same-origin", + "x-server-function-format": "3" + }, + body: "[]" + }) + ); + // Length-prefixed frames: `;0x<8 hex>;` + JSON. + return (await res.text()) + .split(/;0x[0-9a-f]{8};/) + .filter(Boolean) + .map(s => JSON.parse(s)); + }; + + const boom = databaseError(); + createServerReference( + registerServerReference("f-err", async () => () => { + throw boom; + }) + ); + results.frameRoot = await withFindings(() => frameChunks("f-err")); + const boom2 = databaseError(); - results.frameFragment = await withFindings(async () => { - const chunks = await renderServerComponent( - () => + createServerReference( + registerServerReference( + "f-frag", + async () => () => ssr( ["
", "
"], loading(() => { @@ -197,11 +229,10 @@ results.value = await withFindings(() => }); return ssr(["

", "

"], () => escape(data())); }) - ), - { frame: { id: "f-frag" } } - ); - return chunks; - }); + ) + ) + ); + results.frameFragment = await withFindings(() => frameChunks("f-frag")); } // 7. The server error hook (C6): the ambient hook hears the 's