diff --git a/.changeset/server-function-skew.md b/.changeset/server-function-skew.md new file mode 100644 index 000000000..d54e9b30e --- /dev/null +++ b/.changeset/server-function-skew.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +Add `serverFunctions.skewProtection`. When it is on, a page loaded before a deploy can no longer call the new server's functions with an outdated client. The server answers such a call with `409` instead of running the function, and the page reloads once to load the new version. Each build gets a random ID, or pass `{ buildId }` (such as a commit hash) when several machines build the same deploy. Only applies to production builds, and is off by default. diff --git a/packages/start/src/config/index.spec.ts b/packages/start/src/config/index.spec.ts index 7f6650e43..933ed4da8 100644 --- a/packages/start/src/config/index.spec.ts +++ b/packages/start/src/config/index.spec.ts @@ -117,3 +117,42 @@ describe("resolve conditions", () => { ); }); }); + +describe("START_BUILD_ID", () => { + async function buildId( + options: Parameters[0], + command: ConfigEnv["command"] = "build", + ) { + const plugin = solidStart(options) + .flat() + .find( + (p): p is Plugin => + !!p && typeof p === "object" && "name" in p && p.name === "solid-start:config", + ); + const hook = plugin!.config as (config: UserConfig, env: ConfigEnv) => Promise; + const mode = command === "build" ? "production" : "development"; + const resolved = await hook({}, { command, mode }); + return JSON.parse(resolved.define!["import.meta.env.START_BUILD_ID"] as string) as string; + } + + it("is empty when skew protection is off", async () => { + await expect(buildId(undefined)).resolves.toBe(""); + }); + + it("is empty in dev", async () => { + await expect(buildId({ serverFunctions: { skewProtection: true } }, "serve")).resolves.toBe(""); + }); + + it("is random per build", async () => { + const a = await buildId({ serverFunctions: { skewProtection: true } }); + const b = await buildId({ serverFunctions: { skewProtection: true } }); + expect(a).not.toBe(""); + expect(a).not.toBe(b); + }); + + it("uses the configured build ID", async () => { + await expect( + buildId({ serverFunctions: { skewProtection: { buildId: "abc123" } } }), + ).resolves.toBe("abc123"); + }); +}); diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index e8d09dde7..0883b7ff8 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -1,4 +1,5 @@ import { defu } from "defu"; +import { randomUUID } from "node:crypto"; import { globSync } from "node:fs"; import { basename, extname, isAbsolute, join } from "node:path"; import type { PluginOption, FilterPattern, UserConfig } from "vite"; @@ -221,6 +222,33 @@ export interface SolidStartOptions { * @example "src/server-fn-error.ts" */ onError?: string; + + /** + * Reject server function calls from a page loaded before the latest + * deploy, and reload that page so it runs the new version. + * + * Each production build gets an ID that is baked into both the client and + * the server. The client sends its ID with every server function call. A + * server with a different ID answers with `409` instead of running the + * function, and the client reloads the page once. A call that still fails + * right after the reload rejects instead of reloading again. + * + * Every deploy invalidates every open page this way, even when the + * function did not change, because a changed argument or return type + * cannot be told apart from an unchanged one. + * + * Requests without the ID, such as no-JS form submissions or + * pages built before this option was turned on, are not checked. + * + * Pass `{ buildId }` when several machines build the same code for one + * deploy, such as a commit hash, so their IDs match. Otherwise each build + * gets a random ID. + * + * Only applies to production builds. + * + * @default false + */ + skewProtection?: boolean | { buildId?: string }; }; } @@ -256,6 +284,16 @@ function resolveServerBaseUrl(config: UserConfig) { return withSlashes(new URL(base, "http://vite.dev").pathname); } +// An empty ID turns the check off on both sides. +function resolveBuildId( + skewProtection: NonNullable["skewProtection"], + command: "build" | "serve", + fallback: string, +) { + if (!skewProtection || command !== "build") return ""; + return (typeof skewProtection === "object" && skewProtection.buildId) || fallback; +} + export function solidStart(options?: SolidStartOptions): Array { const start = defu(options ?? {}, { appRoot: "./src", @@ -278,6 +316,8 @@ export function solidStart(options?: SolidStartOptions): Array { throw new Error(`Could not find an app jsx/tsx entry in ${start.appRoot}.`); } const entryExtension = extname(appEntryPath); + // created once, so every environment of the build gets the same ID + const randomBuildId = randomUUID(); const handlers = { client: `${start.appRoot}/entry-client${entryExtension}`, server: `${start.appRoot}/entry-server${entryExtension}`, @@ -319,6 +359,11 @@ export function solidStart(options?: SolidStartOptions): Array { env.command === "serve" && config.experimental?.bundledDev ? `assets/${basename(handlers.client, entryExtension)}.js` : handlers.client; + const buildId = resolveBuildId( + options?.serverFunctions?.skewProtection, + env.command, + randomBuildId, + ); if (env.command === "build") { const clientRouter: BaseFileSystemRouter = (globalThis as any).ROUTERS.client; for (const route of await clientRouter.getRoutes()) { @@ -422,6 +467,7 @@ export function solidStart(options?: SolidStartOptions): Array { ), "import.meta.env.SERVER_BASE_URL": JSON.stringify(resolveServerBaseUrl(config)), "import.meta.env.SEROVAL_MODE": JSON.stringify(start.serialization?.mode || "json"), + "import.meta.env.START_BUILD_ID": JSON.stringify(buildId), }, builder: { sharedPlugins: true, diff --git a/packages/start/src/env.d.ts b/packages/start/src/env.d.ts index eb38b2dce..43caf3841 100644 --- a/packages/start/src/env.d.ts +++ b/packages/start/src/env.d.ts @@ -16,5 +16,6 @@ interface SolidStartMetaEnv { START_ISLANDS: boolean; START_EARLY_HINTS: boolean; START_RELOAD_ON_ROUTE_CHUNK_ERROR: boolean; + START_BUILD_ID: string; // START_DEV_OVERLAY: boolean; } diff --git a/packages/start/src/fns/client.spec.ts b/packages/start/src/fns/client.spec.ts index 4ff304d91..0eda23c7f 100644 --- a/packages/start/src/fns/client.spec.ts +++ b/packages/start/src/fns/client.spec.ts @@ -98,3 +98,64 @@ describe("server function URL", () => { expect(fn.url).toBe("/app/_server?id=test-fn"); }); }); + +describe("skew protection", () => { + let location: { reload: ReturnType }; + + const sentBuildId = () => { + const call = (fetch as unknown as ReturnType).mock.calls[0]; + return (call![0] as Request).headers.get("X-Start-Build"); + }; + + beforeEach(() => { + vi.stubEnv("SERVER_BASE_URL", "http://localhost/"); + vi.stubEnv("START_BUILD_ID", "build-a"); + location = { reload: vi.fn() }; + vi.stubGlobal("location", location); + const values = new Map(); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("sends the build ID", async () => { + respondWith(200); + await callServerFunction(); + expect(sentBuildId()).toBe("build-a"); + }); + + it("does not send a build ID when skew protection is off", async () => { + vi.stubEnv("START_BUILD_ID", ""); + respondWith(200); + await callServerFunction(); + expect(sentBuildId()).toBeNull(); + }); + + it("reloads the page and leaves the call pending on a skew response", async () => { + respondWith(409, { "X-Start-Skew": "true" }); + const settled = vi.fn(); + callServerFunction().then(settled, settled); + + await vi.waitFor(() => expect(location.reload).toHaveBeenCalledTimes(1)); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(settled).not.toHaveBeenCalled(); + }); + + it("rejects instead of reloading again right after a reload", async () => { + respondWith(409, { "X-Start-Skew": "true" }); + void callServerFunction(); + await vi.waitFor(() => expect(location.reload).toHaveBeenCalledTimes(1)); + + const rejection = await rejectionOf(callServerFunction()); + expect(rejection).toBeInstanceOf(Error); + expect(location.reload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/start/src/fns/client.ts b/packages/start/src/fns/client.ts index 9f74c50cb..0edd97650 100644 --- a/packages/start/src/fns/client.ts +++ b/packages/start/src/fns/client.ts @@ -1,10 +1,18 @@ import { type Component } from "solid-js"; import { pushRequest, pushResponse } from "../shared/dev-toolbar/functions/tracker.ts"; +import { recordReload } from "../shared/route-chunk-reload.ts"; import { // serializeToJSONStream, serializeToJSONString, } from "./serialization.ts"; -import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts"; +import { + BODY_FORMAT_KEY, + BodyFormat, + BUILD_ID_KEY, + extractBody, + getHeadersAndBody, + SKEW_KEY, +} from "./shared.ts"; import { appendUploads, ARGS_PART, createUploadSerializer } from "./uploads.ts"; let INSTANCE = 0; @@ -17,6 +25,7 @@ async function createRequest(base: string, id: string, instance: string, options ...options.headers, "X-Server-Id": id, "X-Server-Instance": instance, + ...(import.meta.env.START_BUILD_ID ? { [BUILD_ID_KEY]: import.meta.env.START_BUILD_ID } : {}), }, }); if (import.meta.env.DEV) { @@ -96,6 +105,10 @@ async function fetchServerFunction( const response = await initializeResponse(base, id, instance, options, args); + if (import.meta.env.START_BUILD_ID && response.headers.has(SKEW_KEY)) { + return handleSkew(); + } + if ( response.headers.has("Location") || response.headers.has("X-Revalidate") || @@ -117,6 +130,23 @@ async function fetchServerFunction( return result; } +/** + * The server was deployed with a different build since this page loaded, so + * the page reloads to run the new version. The call never settles, so the + * page does not show an error in the moment before the reload. A second skew + * within the reload window rejects instead, so a server that keeps answering + * from another build (a load balancer mixing old and new instances) does not + * reload the page in a loop. + */ +function handleSkew(): Promise { + if (!recordReload()) { + throw new Error("Server function call rejected: the server runs a different build"); + } + console.warn("[solid-start] The server was updated, reloading the page."); + location.reload(); + return new Promise(() => {}); +} + export function cloneServerReference(id: string) { let baseURL = import.meta.env.SERVER_BASE_URL || "/"; if (!baseURL.endsWith("/")) baseURL += "/"; diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index b5849230d..2ef873fbe 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -909,3 +909,67 @@ describe("redirects for submissions without JavaScript", () => { expect(response.headers.get("Location")).toBe("http://localhost/app/"); }); }); + +describe("skew protection", () => { + const call = async (headers: Record) => { + const request = new Request("http://localhost/_server", { + method: "POST", + headers: { "x-server-id": "fn", ...headers }, + }); + const h3Event = { res: { headers: new Headers(), errHeaders: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + const fn = vi.fn(() => "ok"); + vi.mocked(getServerFunction).mockReturnValue(fn as never); + const { handleServerFunction } = await import("./handler.ts"); + const response = (await handleServerFunction(h3Event as never)) as Response; + return { response, fn }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("START_BUILD_ID", "build-b"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("rejects a call from another build without running the function", async () => { + const { response, fn } = await call({ + "x-server-instance": "server-fn:1", + "x-start-build": "build-a", + }); + expect(response.status).toBe(409); + expect(response.headers.get("X-Start-Skew")).toBe("true"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(fn).not.toHaveBeenCalled(); + }); + + it("runs a call from the same build", async () => { + const { response, fn } = await call({ + "x-server-instance": "server-fn:1", + "x-start-build": "build-b", + }); + expect(response.status).toBe(200); + expect(fn).toHaveBeenCalled(); + }); + + it("runs a call without a build ID", async () => { + const { fn } = await call({ "x-server-instance": "server-fn:1" }); + expect(fn).toHaveBeenCalled(); + }); + + it("runs every call when skew protection is off", async () => { + vi.stubEnv("START_BUILD_ID", ""); + const { fn } = await call({ + "x-server-instance": "server-fn:1", + "x-start-build": "build-a", + }); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 56b4c5748..19892db88 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -11,7 +11,14 @@ import { serializeToJSONStream, serializeToJSStream, } from "./serialization.ts"; -import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts"; +import { + BODY_FORMAT_KEY, + BodyFormat, + BUILD_ID_KEY, + extractBody, + getHeadersAndBody, + SKEW_KEY, +} from "./shared.ts"; import "solid-start:server-fn-manifest"; import { getServerFunction, hasServerFunction } from "./registration.ts"; @@ -55,6 +62,19 @@ function isCrossSiteRequest(request: Request, url: URL): boolean { return false; } +/** + * With skew protection on, a client from another build may call a function + * that changed or no longer exists, so its call is refused before it runs. A + * request without a build ID (a no-JS form submission, a page from a + * build without skew protection) is let through. + */ +function isSkewedRequest(request: Request): boolean { + const buildId = import.meta.env.START_BUILD_ID; + if (!buildId) return false; + const clientBuildId = request.headers.get(BUILD_ID_KEY); + return !!clientBuildId && clientBuildId !== buildId; +} + const CACHE_CONTROL = "Cache-Control"; const DEFAULT_CACHE_CONTROL = "no-store"; @@ -132,6 +152,10 @@ async function runServerFunction(h3Event: H3Event) { ); } + if (isSkewedRequest(request)) { + return new Response(null, { status: 409, headers: { [SKEW_KEY]: "true" } }); + } + const serverReference = request.headers.get("X-Server-Id"); const instance = request.headers.get("X-Server-Instance"); const singleFlight = request.headers.has("X-Single-Flight"); diff --git a/packages/start/src/fns/shared.ts b/packages/start/src/fns/shared.ts index 7f1045bae..900da7f38 100644 --- a/packages/start/src/fns/shared.ts +++ b/packages/start/src/fns/shared.ts @@ -10,6 +10,12 @@ export const BODY_FORMAT_KEY = "X-Start-Type"; export const BODY_FORMAL_FILE = "__START__"; +/** Carries the client's build ID on a server function call. */ +export const BUILD_ID_KEY = "X-Start-Build"; + +/** Marks a call rejected because the client and server builds differ. */ +export const SKEW_KEY = "X-Start-Skew"; + export const enum BodyFormat { Seroval = "0", String = "1", diff --git a/packages/start/src/shared/route-chunk-reload.ts b/packages/start/src/shared/route-chunk-reload.ts index 98bb37ec6..ad6d728f3 100644 --- a/packages/start/src/shared/route-chunk-reload.ts +++ b/packages/start/src/shared/route-chunk-reload.ts @@ -48,7 +48,7 @@ function reloadAfterNavigation(from: string): void { * Records a reload. Returns false when one already happened within * `RELOAD_WINDOW_MS`, or when it cannot be recorded. */ -function recordReload(): boolean { +export function recordReload(): boolean { try { const now = Date.now(); const last = Number(sessionStorage.getItem(STORAGE_KEY));