Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/server-function-build-id.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<name>-<hash of file path>`), 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 `<Loading>` 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.
Expand Down
54 changes: 49 additions & 5 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { UPLOAD_ARGS_PART, createUploadDeserializer } from "./uploads.js";
import {
BODY_FORMAT_HEADER,
BUILD_HEADER,
BodyFormat,
ERROR_HEADER,
LIVE_SOURCE,
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/web/server-functions/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
165 changes: 165 additions & 0 deletions packages/web/test/server/server-functions-build-id.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<unknown>;

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<unknown>;

await expect(matched()).resolves.toBe("ran");
});
});
Loading