diff --git a/.changeset/server-function-build-id.md b/.changeset/server-function-build-id.md new file mode 100644 index 000000000..b9ca917a6 --- /dev/null +++ b/.changeset/server-function-build-id.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +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 2852249ff..96887553b 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 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`. **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/server.ts b/packages/web/server-functions/src/server.ts index 91de041bb..84100518c 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, @@ -495,6 +496,18 @@ export interface ServerFunctionsServerConfig { * outside the dev build; `0`/`undefined` is off. */ chaosReconnectEvery?: number; + /** + * 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; } /** @@ -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,31 @@ 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. 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( + new Response( + DEV + ? `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" + } + } + ) + ); + } // 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..1e6670b7c 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. 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"; + /** * 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..a4be17bdd --- /dev/null +++ b/packages/web/test/server/server-functions-build-id.spec.tsx @@ -0,0 +1,165 @@ +/** + * 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 integration sends the client's id through `prepareRequest`, and a + * server with another id answers `409` before the function runs. The + * 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). + */ +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 { + configureServerFunctionsClient, + 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(() => { + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); +}); + +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +afterEach(() => { + // an empty id turns the check off + configureServerFunctionsServer({ buildId: "" }); + configureServerFunctionsClient({ prepareRequest: null as any }); + 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(UNKNOWN_HEADER)).toBe("true"); + 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("reports the refusal as an unknown function on the client", async () => { + registerServerFunction("skewed-a1b2c3d4", async () => "ran"); + configureServerFunctionsServer({ buildId: "build-b" }); + sendBuildId("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.unknownFunction).toBe(true); + 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" }); + sendBuildId("build-b"); + connectTransport(); + const matched = createServerReference("matched-a1b2c3d4") as () => Promise; + + await expect(matched()).resolves.toBe("ran"); + }); +});