From 9b331dc7ea9bd9552315e69ae1e37d04dccc1b70 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 22 Oct 2026 13:41:23 +0800 Subject: [PATCH 1/3] feat(web): refuse server function calls from another build - Add `buildId` to `configureServerFunctionsClient` and `configureServerFunctionsServer`. - The client sends `X-Server-Function-Build` on every call that is not GET-encoded. - A server with a different id answers 409 with its own id and does not run the function. - The client rejects with `versionSkew: true`, so an integration can reload the page. - GET-encoded reads stay header-free, so caches and preloads still match them by url. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-function-build-id.md | 5 + .../solid-2.0/10-server-functions.md | 2 + packages/web/server-functions/src/client.ts | 34 +++- packages/web/server-functions/src/server.ts | 46 ++++- packages/web/server-functions/src/shared.ts | 10 + .../server/server-functions-build-id.spec.tsx | 177 ++++++++++++++++++ 6 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 .changeset/server-function-build-id.md create mode 100644 packages/web/test/server/server-functions-build-id.spec.tsx diff --git a/.changeset/server-function-build-id.md b/.changeset/server-function-build-id.md new file mode 100644 index 000000000..fff31a9bc --- /dev/null +++ b/.changeset/server-function-build-id.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Add a `buildId` option to `configureServerFunctionsClient` and `configureServerFunctionsServer`. When both are set, the client sends its build's id on every call that is not GET-encoded. A server with a different id answers `409` without running the function, and the client rejects with `error.versionSkew: true`, so an integration can reload the page onto the current build. This catches skew the unknown-id 404 cannot: a function whose id survived a deploy but whose arguments or result changed shape. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 2852249ff..07909787d 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -69,6 +69,8 @@ The handler resolves the function id, decodes arguments, runs the function under **Version skew is recognisable on the wire.** Ids are identity-keyed (`-`), so ordinary edits — appending, deleting, reordering functions, or editing a body — never move an address another build already handed out; only renaming or removing a function retires its id. A call whose well-formed address is not registered in the deployment that answers it — the ordinary consequence of deploying under open tabs — gets a 404 labelled `X-Server-Function-Unknown`, and the client names it on the rejection (`error.unknownFunction: true`), so an integration can recover (typically: reload the document onto the current build) instead of surfacing a generic failed call. A 404 for a path the address scheme gives no meaning to stays bare — a mistyped route is not skew. +**A build id catches skew the id cannot.** A function whose id survived a deploy can still take other arguments or return another shape. Pass the same `buildId` to `configureServerFunctionsClient` and `configureServerFunctionsServer` for each deploy, such as a commit hash. The client sends it as `X-Server-Function-Build` on every call that is not GET-encoded. A server with a different id answers `409`, with its own id under the same header, and does not run the function. The client rejects with `error.versionSkew: true`, and the recovery is the same as for an unknown id: reload the document. GET-encoded reads carry no transport header, because their url is their identity, so they are not checked. Calls without the header, such as form posts made without the client runtime, are not checked either. + Inside a function body, `getRequestEvent()` (from `@solidjs/web`) reads the current event and `getServerFunctionInvocation()` reads the in-flight call’s id — usable for keying caches or logs. (Renamed from `getServerFunctionMeta` to keep clear of `getServerFunctionMetadata(fn)`, which reads a reference’s _static declaration_ metadata; the invocation accessor describes the call currently executing.) In-process SSR calls run the original function directly (no HTTP loopback) under a derived event marked `serverOnly`. **Observing invocations (dev and observe builds).** Both legs — HTTP dispatch and direct SSR calls — report each execution as the `"invocation"` record on `OBSERVE.records`, the records channel described in [RFC 08](08-dev-diagnostics.md#observerecords--the-runtimes-records-channel): `subscribe("invocation", (event, live) => …)` delivers one `{ id, direct, at, durationMs, outcome, deferred?, boundary? }` record per call when it settles, with the request event, `request` (HTTP only), `args`, and the result or the error **as thrown** beside it. A direct call made during a `` boundary's render pass carries that boundary's hydration id in `boundary` — the `"boundary"` record's `id` — so a boundary's wait reads as the calls it consisted of. The measured execution is the `wrapInvocation`-wrapped run, so what the observer times is what the request spent on the call, policy included; a stream or generator result is flagged `deferred` and timed to handoff. Observers, any number, none able to change the call. Prod server artifacts carry neither the channel nor the emit sites. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index dd12db9dc..fbc82b9c5 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -24,6 +24,7 @@ const CALL_OBSERVATION = Symbol("solid.CallObservation"); import type { ServerFunction, ServerFunctionMetadata } from "./shared.js"; import { BODY_FORMAT_HEADER, + BUILD_HEADER, BodyFormat, ERROR_HEADER, EventStreamReader, @@ -70,6 +71,7 @@ export { // import HERE instead — one copy of the framing/addressing code in the // app, and the codec/flight-consumer config the transport reads is the // shared built instance by construction. + BUILD_HEADER, ChunkReader, ERROR_HEADER, EVENT_STREAM_HEARTBEAT, @@ -312,6 +314,16 @@ export interface ServerFunctionsClientConfig { * field and each file in a `file` part. */ serializeArgs?(args: unknown[]): string | FormData | Promise; + /** + * Id of the build this client was bundled with. Must match the server's + * `buildId` for the same deploy. Sent on every call that is not + * GET-encoded, so a server from another build refuses the call with a + * `409` before running it, and the rejection carries + * `versionSkew: true`. Recover by reloading the document onto the current + * build. GET-encoded reads carry no transport header (their url is their + * identity), so they are not checked. Leave unset to send nothing. + */ + buildId?: string; } /** Wire-state transitions a live call's iterable can report. */ @@ -343,7 +355,8 @@ const config = { fetch: undefined, prepareRequest: undefined, responseHandler: undefined, - serializeArgs: undefined + serializeArgs: undefined, + buildId: undefined }; /** @@ -473,7 +486,8 @@ export function configureServerFunctionsClient({ fetch, prepareRequest, responseHandler, - serializeArgs + serializeArgs, + buildId } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); @@ -481,6 +495,7 @@ export function configureServerFunctionsClient({ if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; + if (buildId !== undefined) config.buildId = buildId; } // Longest url the GET transport will build before falling back to POST. @@ -530,12 +545,17 @@ function serverFunctionFailure(response, value) { // not know this call's id — version skew (a tab holding the previous // build's ids across a deploy) or a genuinely removed function. const unknown = response.headers.get(UNKNOWN_HEADER) !== null; + // The build-id refusal: the server runs another build than the one this + // page was bundled with, so the call was not run. + const skewed = response.status === 409 && response.headers.get(BUILD_HEADER) !== null; const error = value ?? new Error( unknown ? "Server function is not part of the deployment that answered (version skew or removed function)" - : `Server function call failed with status ${response.status}` + : skewed + ? "Server function call refused: the server runs a different build than this page (version skew)" + : `Server function call failed with status ${response.status}` ); // Stamp the HTTP status so policy layers (live retry loops, router // channels) can classify the failure: 4xx is a definite rejection that @@ -555,6 +575,8 @@ function serverFunctionFailure(response, value) { // failed call. Retrying cannot help: the id will stay unknown until the // page runs the new bundle. if (unknown && error instanceof Error) error.unknownFunction = true; + // Same recovery, named apart: the id is still known, the build is not. + if (skewed && error instanceof Error) error.versionSkew = true; return error; } @@ -618,6 +640,12 @@ async function createRequest(base, id, options, meta) { if (flightSources.length > 0 && !isReadCall(options)) { headers[SINGLE_FLIGHT_HEADER] = flightSources.join(","); } + // The build id rides every call the read rule above leaves room for: a + // POST-shaped read (`read: true`) is never cached or preloaded, so only + // GET-encoded calls go without it. + if (config.buildId && !(options.method && options.method.toUpperCase() === "GET")) { + headers[BUILD_HEADER] = config.buildId; + } let init = { method: "POST", ...options, diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 91de041bb..9f65cb1c0 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -37,6 +37,7 @@ import { import { UPLOAD_ARGS_PART, createUploadDeserializer } from "./uploads.js"; import { BODY_FORMAT_HEADER, + BUILD_HEADER, BodyFormat, ERROR_HEADER, LIVE_SOURCE, @@ -77,6 +78,7 @@ export { // so the framing/addressing code and the streaming codec entry it uses // are the handler's own instance — never a private copy. Transport // building blocks, not for hand-written code. + BUILD_HEADER, ChunkReader, ERROR_HEADER, FLASH_COOKIE, @@ -495,6 +497,17 @@ export interface ServerFunctionsServerConfig { * outside the dev build; `0`/`undefined` is off. */ chaosReconnectEvery?: number; + /** + * Id of the build this server was bundled with. Must match the client's + * `buildId` for the same deploy. A call whose `X-Server-Function-Build` + * names another build is answered `409`, labelled with this id, and the + * function does not run: the page was loaded from a build whose functions + * may take other arguments or return another shape. Calls without the + * header (GET-encoded reads, form posts without the client runtime, a + * client with no `buildId`) are not checked. Leave unset to check + * nothing. + */ + buildId?: string; } /** @@ -687,7 +700,8 @@ const config = { csrf: true, bodySizeLimit: 1_048_576, maxArguments: 1000, - chaosReconnectEvery: 0 + chaosReconnectEvery: 0, + buildId: undefined }; /** * Configures the server runtime. Call once at server startup, before * handling requests. Only needed when deviating from the defaults (custom @@ -728,7 +742,8 @@ export function configureServerFunctionsServer({ bodySizeLimit, maxArguments, secret, - chaosReconnectEvery + chaosReconnectEvery, + buildId } = {}) { if (provideEvent !== undefined) config.provideEvent = provideEvent; if (wrapInvocation !== undefined) config.wrapInvocation = wrapInvocation; @@ -745,6 +760,7 @@ export function configureServerFunctionsServer({ // the flash codec owns the key (flash.js) — the option just names it if (secret !== undefined) setFlashSecret(secret); if (chaosReconnectEvery !== undefined) config.chaosReconnectEvery = chaosReconnectEvery; + if (buildId !== undefined) config.buildId = buildId; } // Named flight-data collectors, keyed by source id. The unnamed @@ -3829,9 +3845,12 @@ function withCSRFVary(response) { // The request headers the client transport sets on its own calls — what a // preflight that names none (a bare `Access-Control-Request-Method`) is // told it may send. -const TRANSPORT_REQUEST_HEADERS = ["Content-Type", BODY_FORMAT_HEADER, SINGLE_FLIGHT_HEADER].join( - ", " -); +const TRANSPORT_REQUEST_HEADERS = [ + "Content-Type", + BODY_FORMAT_HEADER, + SINGLE_FLIGHT_HEADER, + BUILD_HEADER +].join(", "); // How long a browser may reuse a preflight answer for one address. Each // function has an address of its own, so this is per function, and a // change to the allowlist is honoured within it (browsers cap the value @@ -4217,6 +4236,23 @@ export async function handleServerFunctionRequest(request, options = {}) { const response = new Response(DEV ? "Server function not found" : null, { status: 404 }); return finish(protectsRequest ? withCSRFVary(response) : response); } + // The build-id refusal (see BUILD_HEADER): the caller was bundled with + // another build, so its arguments may not be what this build's function + // takes, nor its decoder what this build's result is. Refused before the + // arguments are parsed or the function runs. After the origin gate, unlike + // the unknown-id 404: only the client runtime sends the header, and it + // sends the fetch metadata the gate reads. + const callerBuild = request.headers.get(BUILD_HEADER); + if (config.buildId && callerBuild !== null && callerBuild !== config.buildId) { + return finish( + new Response( + DEV + ? `Server function call from build "${callerBuild}" refused by build "${config.buildId}"` + : null, + { status: 409, headers: { [BUILD_HEADER]: config.buildId, "Cache-Control": "no-store" } } + ) + ); + } // Which of the three answer shapes this call gets — codec encodings for // the client transport, the same encodings framed as an event stream for diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index 7234c5835..c74920690 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -884,6 +884,16 @@ export const LIVE_WIRE = Symbol("solid.LiveWire"); */ export const UNKNOWN_HEADER = "X-Server-Function-Unknown"; +/** + * Header carrying a build id, when both halves are configured with one + * (`buildId`). The client sends its build's id on every call that is not + * GET-encoded. A server whose id differs answers `409` with its own id under + * the same header, and does not run the function. This catches the skew the + * unknown-id 404 cannot: a function whose id survived the deploy but whose + * arguments or result changed shape. + */ +export const BUILD_HEADER = "X-Server-Function-Build"; + /** * Header carrying a redirect to scripted callers. fetch FOLLOWS redirect * statuses before the transport can read them, so a scripted answer masks diff --git a/packages/web/test/server/server-functions-build-id.spec.tsx b/packages/web/test/server/server-functions-build-id.spec.tsx new file mode 100644 index 000000000..3425288af --- /dev/null +++ b/packages/web/test/server/server-functions-build-id.spec.tsx @@ -0,0 +1,177 @@ +/** + * Build-id skew check. + * + * The unknown-id 404 (#3110) catches a function that a deploy removed or + * renamed. It cannot catch one whose id survived but whose arguments or + * result changed shape. When both halves are configured with a `buildId`, + * the client sends its id on every call that is not GET-encoded, and a + * server with another id answers `409` before the function runs. The + * client names it on the error (`versionSkew: true`). + * + * Like the other server-function specs, these run against the built + * bundles (server-functions/dist/*, wired up in vite.config.server.mjs). + */ +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + configureServerFunctionsServer, + handleServerFunctionRequest, + registerServerFunction +} from "@solidjs/web/server-functions/server"; +import { + BUILD_HEADER, + configureServerFunctionsClient, + createServerReference, + GET +} from "@solidjs/web/server-functions/client"; + +const RequestContext = Symbol.for("solid.RequestContext"); + +beforeAll(() => { + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); +}); + +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +afterEach(() => { + // an empty id turns the check off on both halves + configureServerFunctionsServer({ buildId: "" }); + configureServerFunctionsClient({ buildId: "" }); + while (disconnects.length) disconnects.pop()!(); +}); + +function post(path: string, headers: Record = {}) { + return new Request(`https://app.example${path}`, { + method: "POST", + headers: { "Sec-Fetch-Site": "same-origin", ...headers } + }); +} + +/** Routes the client stub's fetch straight into the handler, recording each request. */ +function connectTransport() { + const original = globalThis.fetch; + const sent: Request[] = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const address = input instanceof Request ? input.url : input.toString(); + const request = new Request( + new URL(address, "http://localhost"), + input instanceof Request ? input : init + ); + request.headers.set("Sec-Fetch-Site", "same-origin"); + sent.push(request.clone()); + return handleServerFunctionRequest(request); + }) as typeof fetch; + disconnects.push(() => { + globalThis.fetch = original; + }); + return sent; +} + +const disconnects: (() => void)[] = []; + +describe("build-id skew check", () => { + it("refuses a call from another build without running the function", async () => { + const fn = vi.fn(async () => "ran"); + registerServerFunction("refused-a1b2c3d4", fn); + configureServerFunctionsServer({ buildId: "build-b" }); + + const wire = await handleServerFunctionRequest( + post("/_server/data/refused-a1b2c3d4", { [BUILD_HEADER]: "build-a" }) + ); + + expect(wire.status).toBe(409); + expect(wire.headers.get(BUILD_HEADER)).toBe("build-b"); + expect(wire.headers.get("Cache-Control")).toBe("no-store"); + expect(fn).not.toHaveBeenCalled(); + }); + + it("runs a call from the same build", async () => { + registerServerFunction("same-a1b2c3d4", async () => "ran"); + configureServerFunctionsServer({ buildId: "build-b" }); + + const wire = await handleServerFunctionRequest( + post("/_server/data/same-a1b2c3d4", { [BUILD_HEADER]: "build-b" }) + ); + + expect(wire.status).toBe(200); + }); + + it("runs a call without a build id", async () => { + const fn = vi.fn(async () => "ran"); + registerServerFunction("unlabelled-a1b2c3d4", fn); + configureServerFunctionsServer({ buildId: "build-b" }); + + const wire = await handleServerFunctionRequest(post("/_server/data/unlabelled-a1b2c3d4")); + + expect(wire.status).toBe(200); + expect(fn).toHaveBeenCalled(); + }); + + it("checks nothing when the server has no build id", async () => { + registerServerFunction("unchecked-a1b2c3d4", async () => "ran"); + + const wire = await handleServerFunctionRequest( + post("/_server/data/unchecked-a1b2c3d4", { [BUILD_HEADER]: "build-a" }) + ); + + expect(wire.status).toBe(200); + }); + + it("names the refusal on the client error", async () => { + registerServerFunction("skewed-a1b2c3d4", async () => "ran"); + configureServerFunctionsServer({ buildId: "build-b" }); + configureServerFunctionsClient({ buildId: "build-a" }); + const sent = connectTransport(); + const skewed = createServerReference("skewed-a1b2c3d4") as () => Promise; + + const outcome = await skewed().then( + () => { + throw new Error("resolved: the call from another build ran"); + }, + (error: any) => error + ); + + expect(sent[0]!.headers.get(BUILD_HEADER)).toBe("build-a"); + expect(outcome).toBeInstanceOf(Error); + expect(outcome.versionSkew).toBe(true); + expect(outcome.unknownFunction).toBeUndefined(); + expect(outcome.status).toBe(409); + expect(outcome.message).toMatch(/version skew/); + }); + + it("resolves a call when the builds match", async () => { + registerServerFunction("matched-a1b2c3d4", async () => "ran"); + configureServerFunctionsServer({ buildId: "build-b" }); + configureServerFunctionsClient({ buildId: "build-b" }); + connectTransport(); + const matched = createServerReference("matched-a1b2c3d4") as () => Promise; + + await expect(matched()).resolves.toBe("ran"); + }); + + it("sends no build id on a GET-encoded read", async () => { + // A read's url is its identity (#3406): caches and preloads match it + // without the transport's headers, so the build id stays off it. + registerServerFunction("read-a1b2c3d4", async () => "read"); + configureServerFunctionsClient({ buildId: "build-a" }); + const sent = connectTransport(); + const read = GET(createServerReference("read-a1b2c3d4")) as () => Promise; + + await read().catch(() => {}); + + expect(sent[0]!.method).toBe("GET"); + expect(sent[0]!.headers.get(BUILD_HEADER)).toBeNull(); + }); + + it("sends no build id when the client has none", async () => { + registerServerFunction("plain-a1b2c3d4", async () => "ran"); + const sent = connectTransport(); + const plain = createServerReference("plain-a1b2c3d4") as () => Promise; + + await plain(); + + expect(sent[0]!.headers.get(BUILD_HEADER)).toBeNull(); + }); +}); From 725fe5ca03b4d005fb1bdbc0aa6f5fd595a9db48 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 23 Oct 2026 00:31:17 +0800 Subject: [PATCH 2/3] refactor(web): move the build id header to the integration - Remove `buildId` from the client. The client runtime no longer sends the header, so it adds no bytes. - The 409 also carries the unknown-function label, so the client rejects with `unknownFunction: true`, the same as the 404. - Remove `BUILD_HEADER` from the public exports. The integration adds the header through `prepareRequest`. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-function-build-id.md | 2 +- .../solid-2.0/10-server-functions.md | 2 +- packages/web/server-functions/src/client.ts | 34 +---------- packages/web/server-functions/src/server.ts | 30 ++++++---- packages/web/server-functions/src/shared.ts | 12 ++-- .../server/server-functions-build-id.spec.tsx | 60 ++++++++----------- 6 files changed, 54 insertions(+), 86 deletions(-) diff --git a/.changeset/server-function-build-id.md b/.changeset/server-function-build-id.md index fff31a9bc..b9ca917a6 100644 --- a/.changeset/server-function-build-id.md +++ b/.changeset/server-function-build-id.md @@ -2,4 +2,4 @@ "@solidjs/web": patch --- -Add a `buildId` option to `configureServerFunctionsClient` and `configureServerFunctionsServer`. When both are set, the client sends its build's id on every call that is not GET-encoded. A server with a different id answers `409` without running the function, and the client rejects with `error.versionSkew: true`, so an integration can reload the page onto the current build. This catches skew the unknown-id 404 cannot: a function whose id survived a deploy but whose arguments or result changed shape. +Add a `buildId` option to `configureServerFunctionsServer`. A call whose `X-Server-Function-Build` header names another build is answered `409` without running the function, and the client rejects with `error.unknownFunction: true`, the same as for a function the deploy removed. The build integration adds the header through `prepareRequest`, so the client runtime does not grow. This catches skew the unknown-id 404 cannot: a function whose id survived a deploy but whose arguments or result changed shape. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 07909787d..96887553b 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -69,7 +69,7 @@ The handler resolves the function id, decodes arguments, runs the function under **Version skew is recognisable on the wire.** Ids are identity-keyed (`-`), so ordinary edits — appending, deleting, reordering functions, or editing a body — never move an address another build already handed out; only renaming or removing a function retires its id. A call whose well-formed address is not registered in the deployment that answers it — the ordinary consequence of deploying under open tabs — gets a 404 labelled `X-Server-Function-Unknown`, and the client names it on the rejection (`error.unknownFunction: true`), so an integration can recover (typically: reload the document onto the current build) instead of surfacing a generic failed call. A 404 for a path the address scheme gives no meaning to stays bare — a mistyped route is not skew. -**A build id catches skew the id cannot.** A function whose id survived a deploy can still take other arguments or return another shape. Pass the same `buildId` to `configureServerFunctionsClient` and `configureServerFunctionsServer` for each deploy, such as a commit hash. The client sends it as `X-Server-Function-Build` on every call that is not GET-encoded. A server with a different id answers `409`, with its own id under the same header, and does not run the function. The client rejects with `error.versionSkew: true`, and the recovery is the same as for an unknown id: reload the document. GET-encoded reads carry no transport header, because their url is their identity, so they are not checked. Calls without the header, such as form posts made without the client runtime, are not checked either. +**A build id catches skew the id cannot.** A function whose id survived a deploy can still take other arguments or return another shape. Pass a `buildId` for each deploy, such as a commit hash, to `configureServerFunctionsServer`. A call whose `X-Server-Function-Build` header names another build is answered `409`, with the server's id under the same header, and the function does not run. The answer carries the same `X-Server-Function-Unknown` label as the unknown-id 404, so the client rejects with `error.unknownFunction: true` and the recovery is the same: reload the document. The client runtime does not send the header. The build integration knows the id and adds it through `prepareRequest`, skipping GET-encoded reads, whose url is their identity. Calls without the header are not checked. Platform skew protection, such as pinning a deployment by header or cookie, fits the same hook. Inside a function body, `getRequestEvent()` (from `@solidjs/web`) reads the current event and `getServerFunctionInvocation()` reads the in-flight call’s id — usable for keying caches or logs. (Renamed from `getServerFunctionMeta` to keep clear of `getServerFunctionMetadata(fn)`, which reads a reference’s _static declaration_ metadata; the invocation accessor describes the call currently executing.) In-process SSR calls run the original function directly (no HTTP loopback) under a derived event marked `serverOnly`. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index fbc82b9c5..dd12db9dc 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -24,7 +24,6 @@ const CALL_OBSERVATION = Symbol("solid.CallObservation"); import type { ServerFunction, ServerFunctionMetadata } from "./shared.js"; import { BODY_FORMAT_HEADER, - BUILD_HEADER, BodyFormat, ERROR_HEADER, EventStreamReader, @@ -71,7 +70,6 @@ export { // import HERE instead — one copy of the framing/addressing code in the // app, and the codec/flight-consumer config the transport reads is the // shared built instance by construction. - BUILD_HEADER, ChunkReader, ERROR_HEADER, EVENT_STREAM_HEARTBEAT, @@ -314,16 +312,6 @@ export interface ServerFunctionsClientConfig { * field and each file in a `file` part. */ serializeArgs?(args: unknown[]): string | FormData | Promise; - /** - * Id of the build this client was bundled with. Must match the server's - * `buildId` for the same deploy. Sent on every call that is not - * GET-encoded, so a server from another build refuses the call with a - * `409` before running it, and the rejection carries - * `versionSkew: true`. Recover by reloading the document onto the current - * build. GET-encoded reads carry no transport header (their url is their - * identity), so they are not checked. Leave unset to send nothing. - */ - buildId?: string; } /** Wire-state transitions a live call's iterable can report. */ @@ -355,8 +343,7 @@ const config = { fetch: undefined, prepareRequest: undefined, responseHandler: undefined, - serializeArgs: undefined, - buildId: undefined + serializeArgs: undefined }; /** @@ -486,8 +473,7 @@ export function configureServerFunctionsClient({ fetch, prepareRequest, responseHandler, - serializeArgs, - buildId + serializeArgs } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); @@ -495,7 +481,6 @@ export function configureServerFunctionsClient({ if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; - if (buildId !== undefined) config.buildId = buildId; } // Longest url the GET transport will build before falling back to POST. @@ -545,17 +530,12 @@ function serverFunctionFailure(response, value) { // not know this call's id — version skew (a tab holding the previous // build's ids across a deploy) or a genuinely removed function. const unknown = response.headers.get(UNKNOWN_HEADER) !== null; - // The build-id refusal: the server runs another build than the one this - // page was bundled with, so the call was not run. - const skewed = response.status === 409 && response.headers.get(BUILD_HEADER) !== null; const error = value ?? new Error( unknown ? "Server function is not part of the deployment that answered (version skew or removed function)" - : skewed - ? "Server function call refused: the server runs a different build than this page (version skew)" - : `Server function call failed with status ${response.status}` + : `Server function call failed with status ${response.status}` ); // Stamp the HTTP status so policy layers (live retry loops, router // channels) can classify the failure: 4xx is a definite rejection that @@ -575,8 +555,6 @@ function serverFunctionFailure(response, value) { // failed call. Retrying cannot help: the id will stay unknown until the // page runs the new bundle. if (unknown && error instanceof Error) error.unknownFunction = true; - // Same recovery, named apart: the id is still known, the build is not. - if (skewed && error instanceof Error) error.versionSkew = true; return error; } @@ -640,12 +618,6 @@ async function createRequest(base, id, options, meta) { if (flightSources.length > 0 && !isReadCall(options)) { headers[SINGLE_FLIGHT_HEADER] = flightSources.join(","); } - // The build id rides every call the read rule above leaves room for: a - // POST-shaped read (`read: true`) is never cached or preloaded, so only - // GET-encoded calls go without it. - if (config.buildId && !(options.method && options.method.toUpperCase() === "GET")) { - headers[BUILD_HEADER] = config.buildId; - } let init = { method: "POST", ...options, diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 9f65cb1c0..257eea790 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -78,7 +78,6 @@ export { // so the framing/addressing code and the streaming codec entry it uses // are the handler's own instance — never a private copy. Transport // building blocks, not for hand-written code. - BUILD_HEADER, ChunkReader, ERROR_HEADER, FLASH_COOKIE, @@ -498,14 +497,15 @@ export interface ServerFunctionsServerConfig { */ chaosReconnectEvery?: number; /** - * Id of the build this server was bundled with. Must match the client's - * `buildId` for the same deploy. A call whose `X-Server-Function-Build` - * names another build is answered `409`, labelled with this id, and the - * function does not run: the page was loaded from a build whose functions - * may take other arguments or return another shape. Calls without the - * header (GET-encoded reads, form posts without the client runtime, a - * client with no `buildId`) are not checked. Leave unset to check - * nothing. + * Id of the build this server was bundled with. A call whose + * `X-Server-Function-Build` header names another build is answered `409` + * and the function does not run: the page was loaded from a build whose + * functions may take other arguments or return another shape. The answer + * carries the unknown-function label, so the client rejects with + * `unknownFunction: true`, the same as for an id this build does not + * have. The client runtime does not send the header itself: the build + * integration adds it through `prepareRequest`. Calls without the header + * are not checked. Leave unset to check nothing. */ buildId?: string; } @@ -4241,7 +4241,8 @@ export async function handleServerFunctionRequest(request, options = {}) { // takes, nor its decoder what this build's result is. Refused before the // arguments are parsed or the function runs. After the origin gate, unlike // the unknown-id 404: only the client runtime sends the header, and it - // sends the fetch metadata the gate reads. + // sends the fetch metadata the gate reads. Labelled as unknown so the + // client reports one skew signal: this page came from another build. const callerBuild = request.headers.get(BUILD_HEADER); if (config.buildId && callerBuild !== null && callerBuild !== config.buildId) { return finish( @@ -4249,7 +4250,14 @@ export async function handleServerFunctionRequest(request, options = {}) { DEV ? `Server function call from build "${callerBuild}" refused by build "${config.buildId}"` : null, - { status: 409, headers: { [BUILD_HEADER]: config.buildId, "Cache-Control": "no-store" } } + { + status: 409, + headers: { + [UNKNOWN_HEADER]: "true", + [BUILD_HEADER]: config.buildId, + "Cache-Control": "no-store" + } + } ) ); } diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index c74920690..1e6670b7c 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -885,12 +885,12 @@ export const LIVE_WIRE = Symbol("solid.LiveWire"); export const UNKNOWN_HEADER = "X-Server-Function-Unknown"; /** - * Header carrying a build id, when both halves are configured with one - * (`buildId`). The client sends its build's id on every call that is not - * GET-encoded. A server whose id differs answers `409` with its own id under - * the same header, and does not run the function. This catches the skew the - * unknown-id 404 cannot: a function whose id survived the deploy but whose - * arguments or result changed shape. + * Header carrying a build id. The build integration adds it to calls + * through `prepareRequest`; the client runtime never sends it. A server + * configured with another `buildId` answers `409` with its own id under the + * same header and the `UNKNOWN_HEADER` label, and does not run the function. + * This catches the skew the unknown-id 404 cannot: a function whose id + * survived the deploy but whose arguments or result changed shape. */ export const BUILD_HEADER = "X-Server-Function-Build"; diff --git a/packages/web/test/server/server-functions-build-id.spec.tsx b/packages/web/test/server/server-functions-build-id.spec.tsx index 3425288af..a4be17bdd 100644 --- a/packages/web/test/server/server-functions-build-id.spec.tsx +++ b/packages/web/test/server/server-functions-build-id.spec.tsx @@ -4,9 +4,10 @@ * The unknown-id 404 (#3110) catches a function that a deploy removed or * renamed. It cannot catch one whose id survived but whose arguments or * result changed shape. When both halves are configured with a `buildId`, - * the client sends its id on every call that is not GET-encoded, and a + * the integration sends the client's id through `prepareRequest`, and a * server with another id answers `409` before the function runs. The - * client names it on the error (`versionSkew: true`). + * answer carries the unknown-function label, so the client reports the + * same skew signal as the 404 (`unknownFunction: true`). * * Like the other server-function specs, these run against the built * bundles (server-functions/dist/*, wired up in vite.config.server.mjs). @@ -19,12 +20,23 @@ import { registerServerFunction } from "@solidjs/web/server-functions/server"; import { - BUILD_HEADER, configureServerFunctionsClient, - createServerReference, - GET + createServerReference } from "@solidjs/web/server-functions/client"; +const BUILD_HEADER = "X-Server-Function-Build"; +const UNKNOWN_HEADER = "X-Server-Function-Unknown"; + +/** What a build integration installs: its build id on every call that is not GET-encoded. */ +function sendBuildId(buildId: string) { + configureServerFunctionsClient({ + prepareRequest(init) { + if (init.method?.toUpperCase() === "GET") return init; + return { ...init, headers: { ...(init.headers as object), [BUILD_HEADER]: buildId } }; + } + }); +} + const RequestContext = Symbol.for("solid.RequestContext"); beforeAll(() => { @@ -36,9 +48,9 @@ afterAll(() => { }); afterEach(() => { - // an empty id turns the check off on both halves + // an empty id turns the check off configureServerFunctionsServer({ buildId: "" }); - configureServerFunctionsClient({ buildId: "" }); + configureServerFunctionsClient({ prepareRequest: null as any }); while (disconnects.length) disconnects.pop()!(); }); @@ -83,6 +95,7 @@ describe("build-id skew check", () => { expect(wire.status).toBe(409); expect(wire.headers.get(BUILD_HEADER)).toBe("build-b"); + expect(wire.headers.get(UNKNOWN_HEADER)).toBe("true"); expect(wire.headers.get("Cache-Control")).toBe("no-store"); expect(fn).not.toHaveBeenCalled(); }); @@ -119,10 +132,10 @@ describe("build-id skew check", () => { expect(wire.status).toBe(200); }); - it("names the refusal on the client error", async () => { + it("reports the refusal as an unknown function on the client", async () => { registerServerFunction("skewed-a1b2c3d4", async () => "ran"); configureServerFunctionsServer({ buildId: "build-b" }); - configureServerFunctionsClient({ buildId: "build-a" }); + sendBuildId("build-a"); const sent = connectTransport(); const skewed = createServerReference("skewed-a1b2c3d4") as () => Promise; @@ -135,8 +148,7 @@ describe("build-id skew check", () => { expect(sent[0]!.headers.get(BUILD_HEADER)).toBe("build-a"); expect(outcome).toBeInstanceOf(Error); - expect(outcome.versionSkew).toBe(true); - expect(outcome.unknownFunction).toBeUndefined(); + expect(outcome.unknownFunction).toBe(true); expect(outcome.status).toBe(409); expect(outcome.message).toMatch(/version skew/); }); @@ -144,34 +156,10 @@ describe("build-id skew check", () => { it("resolves a call when the builds match", async () => { registerServerFunction("matched-a1b2c3d4", async () => "ran"); configureServerFunctionsServer({ buildId: "build-b" }); - configureServerFunctionsClient({ buildId: "build-b" }); + sendBuildId("build-b"); connectTransport(); const matched = createServerReference("matched-a1b2c3d4") as () => Promise; await expect(matched()).resolves.toBe("ran"); }); - - it("sends no build id on a GET-encoded read", async () => { - // A read's url is its identity (#3406): caches and preloads match it - // without the transport's headers, so the build id stays off it. - registerServerFunction("read-a1b2c3d4", async () => "read"); - configureServerFunctionsClient({ buildId: "build-a" }); - const sent = connectTransport(); - const read = GET(createServerReference("read-a1b2c3d4")) as () => Promise; - - await read().catch(() => {}); - - expect(sent[0]!.method).toBe("GET"); - expect(sent[0]!.headers.get(BUILD_HEADER)).toBeNull(); - }); - - it("sends no build id when the client has none", async () => { - registerServerFunction("plain-a1b2c3d4", async () => "ran"); - const sent = connectTransport(); - const plain = createServerReference("plain-a1b2c3d4") as () => Promise; - - await plain(); - - expect(sent[0]!.headers.get(BUILD_HEADER)).toBeNull(); - }); }); From 89a61a7d86adb2a3492dcbf5ad74c7d406fdb6d0 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 23 Oct 2026 00:31:43 +0800 Subject: [PATCH 3/3] style(web): format the build id refusal Co-Authored-By: Claude Opus 5.5 --- packages/web/server-functions/src/server.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 257eea790..84100518c 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -4251,13 +4251,13 @@ export async function handleServerFunctionRequest(request, options = {}) { ? `Server function call from build "${callerBuild}" refused by build "${config.buildId}"` : null, { - status: 409, - headers: { - [UNKNOWN_HEADER]: "true", - [BUILD_HEADER]: config.buildId, - "Cache-Control": "no-store" - } + status: 409, + headers: { + [UNKNOWN_HEADER]: "true", + [BUILD_HEADER]: config.buildId, + "Cache-Control": "no-store" } + } ) ); }