Skip to content
Open
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-skew.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions packages/start/src/config/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,42 @@ describe("SERVER_BASE_URL", () => {
await expect(serverBaseUrl({ base: "/app" }, command)).resolves.toBe("/app/");
});
});

describe("START_BUILD_ID", () => {
async function buildId(
options: Parameters<typeof solidStart>[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<UserConfig>;
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");
});
});
46 changes: 46 additions & 0 deletions packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
};
}

Expand Down Expand Up @@ -251,6 +279,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<SolidStartOptions["serverFunctions"]>["skewProtection"],
command: "build" | "serve",
fallback: string,
) {
if (!skewProtection || command !== "build") return "";
return (typeof skewProtection === "object" && skewProtection.buildId) || fallback;
}

export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
const start = defu(options ?? {}, {
appRoot: "./src",
Expand All @@ -273,6 +311,8 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
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}`,
Expand Down Expand Up @@ -308,6 +348,11 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
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()) {
Expand Down Expand Up @@ -408,6 +453,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
),
"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,
Expand Down
1 change: 1 addition & 0 deletions packages/start/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
61 changes: 61 additions & 0 deletions packages/start/src/fns/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,64 @@ describe("server function URL", () => {
expect(fn.url).toBe("/app/_server?id=test-fn");
});
});

describe("skew protection", () => {
let location: { reload: ReturnType<typeof vi.fn> };

const sentBuildId = () => {
const call = (fetch as unknown as ReturnType<typeof vi.fn>).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<string, string>();
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);
});
});
32 changes: 31 additions & 1 deletion packages/start/src/fns/client.ts
Original file line number Diff line number Diff line change
@@ -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";

let INSTANCE = 0;

Expand All @@ -16,6 +24,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) {
Expand Down Expand Up @@ -79,6 +88,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") ||
Expand All @@ -100,6 +113,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<never> {
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 += "/";
Expand Down
64 changes: 64 additions & 0 deletions packages/start/src/fns/handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => {
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();
});
});
26 changes: 25 additions & 1 deletion packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions packages/start/src/fns/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,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",
Expand Down
Loading
Loading