From 8cf8ca933d5381598aca44d858b8766534d15054 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 22 Oct 2026 13:30:48 +0800 Subject: [PATCH 1/5] feat(start): add skew protection for server functions - Add the `serverFunctions.skewProtection` option. It is off by default. - Each production build gets an ID that both the client and server bundles share. A configured `buildId` replaces the random one. - The client sends its build ID with every server function call. - The server answers a call with a different build ID with 409 and does not run the function. - The client reloads the page once on that response. A second skew within 10 seconds rejects the call instead. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-function-skew.md | 5 ++ packages/start/src/config/index.spec.ts | 39 +++++++++++ packages/start/src/config/index.ts | 46 +++++++++++++ packages/start/src/env.d.ts | 1 + packages/start/src/fns/client.spec.ts | 61 ++++++++++++++++++ packages/start/src/fns/client.ts | 32 +++++++++- packages/start/src/fns/handler.spec.ts | 64 +++++++++++++++++++ packages/start/src/fns/handler.ts | 26 +++++++- packages/start/src/fns/shared.ts | 6 ++ .../start/src/shared/route-chunk-reload.ts | 2 +- 10 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 .changeset/server-function-skew.md 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 4eec36f04..e6dbb6d2d 100644 --- a/packages/start/src/config/index.spec.ts +++ b/packages/start/src/config/index.spec.ts @@ -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[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 3d5aa28e9..6b4e23fc3 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 }; }; } @@ -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["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", @@ -273,6 +311,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}`, @@ -308,6 +348,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()) { @@ -408,6 +453,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 1a8a68ce0..9a979b566 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"; let INSTANCE = 0; @@ -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) { @@ -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") || @@ -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 { + 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 cf0a7efd4..5da3e5b33 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 e0a1d7c2e..7d104a847 100644 --- a/packages/start/src/fns/shared.ts +++ b/packages/start/src/fns/shared.ts @@ -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", 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)); From 6280546fac5b9bf5a7387f7bf028db65bcb60382 Mon Sep 17 00:00:00 2001 From: dennev Date: Fri, 23 Oct 2026 03:40:11 +0900 Subject: [PATCH 2/5] fix(start): resolve the server function filter and lazy ids against Vite's root (#2357) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brenley Dueck --- .changeset/server-functions-real-root.md | 5 ++ packages/start/src/config/lazy.spec.ts | 27 +++++++- packages/start/src/config/lazy.ts | 14 ++-- packages/start/src/directives/index.spec.ts | 64 +++++++++++++++++++ packages/start/src/directives/index.ts | 14 ++-- .../start/src/server/collect-styles.spec.ts | 37 +++++++++++ packages/start/src/server/collect-styles.ts | 3 +- 7 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 .changeset/server-functions-real-root.md create mode 100644 packages/start/src/directives/index.spec.ts create mode 100644 packages/start/src/server/collect-styles.spec.ts diff --git a/.changeset/server-functions-real-root.md b/.changeset/server-functions-real-root.md new file mode 100644 index 000000000..cefb7cfbe --- /dev/null +++ b/.changeset/server-functions-real-root.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Resolve the server function filter and the ids of lazily loaded modules against Vite's root instead of the working directory. On Windows a working directory entered through a junction or symlink keeps that path, while Vite (8.2 and later) resolves the root and every module id to the real path. The filter then matched no module, so no `"use server"` function was compiled: its body shipped in the client bundle, and the build failed with `Attempt to import 'server-only' in a client module` once one of them imported server-only code such as `@solidjs/start/http`. The ids of lazily loaded route components matched no client manifest entry either, so their CSS was missing from the server-rendered HTML. Relative patterns in the `serverFunctions.filter` option now resolve against Vite's root too. diff --git a/packages/start/src/config/lazy.spec.ts b/packages/start/src/config/lazy.spec.ts index 39adb2fad..2e7e18ef8 100644 --- a/packages/start/src/config/lazy.spec.ts +++ b/packages/start/src/config/lazy.spec.ts @@ -1,13 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import lazy from "./lazy.ts"; -async function transform(code: string, id = `${process.cwd()}/src/routes/page.tsx`) { +async function transform( + code: string, + id = `${process.cwd()}/src/routes/page.tsx`, + root = process.cwd(), +) { const plugin = lazy() as any; - const context = { environment: { name: "ssr" } }; + const context = { environment: { name: "ssr", config: { root } } }; return plugin.transform.call(context, code, id); } +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("lazy", () => { it("appends the module id without moving existing code", async () => { const code = `import { A } from "./a.ts";\nexport default function Page() { return A; }`; @@ -29,6 +37,19 @@ describe("lazy", () => { expect(result.map).toBeTruthy(); }); + it("names the module relative to the root when the working directory is a link to it", async () => { + // Vite (8.2 and later) resolves its root and module ids to the real path. + // On Windows the working directory keeps the junction or symlink path. + vi.spyOn(process, "cwd").mockReturnValue("/link/app"); + const result = await transform( + `import { A } from "./a.ts";\nexport default A;`, + "/real/app/src/routes/page.tsx", + "/real/app", + ); + + expect(result.code).toContain(`export const id$$ = "src/routes/page.tsx";`); + }); + it("skips a module without a default export or lazy", async () => { expect(await transform(`import { A } from "./a.ts";\nexport const B = A;`)).toBeUndefined(); }); diff --git a/packages/start/src/config/lazy.ts b/packages/start/src/config/lazy.ts index 72fbfc3d2..7518817e1 100644 --- a/packages/start/src/config/lazy.ts +++ b/packages/start/src/config/lazy.ts @@ -1,9 +1,9 @@ import type { PluginItem } from "@babel/core"; import babel from "@babel/core"; import * as t from "@babel/types"; -import { sep as osSep } from "node:path"; -import { basename, relative, sep } from "node:path/posix"; -import type { PluginOption } from "vite"; +import { relative } from "node:path"; +import { basename } from "node:path/posix"; +import { normalizePath, type PluginOption } from "vite"; import { VITE_ENVIRONMENTS } from "./constants.ts"; const importTransform = (): PluginItem => { @@ -31,8 +31,6 @@ const importTransform = (): PluginItem => { const fileEndingRegex = /(ts|js)x(\?.*)?$/; const lazy = (): PluginOption => { - const cwd = process.cwd().replaceAll(osSep, sep); - /** * Maps module ids to their client-specific shared chunk names. * Modules in shared chunks need to find their assets via the chunk name, instead of their module id. @@ -84,7 +82,11 @@ const lazy = (): PluginOption => { // source map of the input still applies to the output. let idExport = ""; if (src.indexOf("export default") !== -1) { - const chunkName = sharedChunkNames[id] ?? relative(cwd, id); + // Derived the way Vite derives the client manifest keys it is looked + // up by. Relative to the working directory, it would match no key when + // that directory reaches the project through a junction or symlink. + const chunkName = + sharedChunkNames[id] ?? normalizePath(relative(this.environment.config.root, id)); idExport = `\nexport const id$$ = ${JSON.stringify(chunkName)};\n`; } diff --git a/packages/start/src/directives/index.spec.ts b/packages/start/src/directives/index.spec.ts new file mode 100644 index 000000000..a84158b78 --- /dev/null +++ b/packages/start/src/directives/index.spec.ts @@ -0,0 +1,64 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { normalizePath } from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { type ServerFunctionsFilter, serverFunctionsPlugin } from "./index.ts"; + +// Vite (8.2 and later) resolves its root and every module id to the real path. +// On Windows the working directory keeps the path it was entered through, so it +// can name the same project through a junction or symlink. +const root = normalizePath(join(tmpdir(), "solid-start-app")); +const linkedRoot = normalizePath(join(tmpdir(), "solid-start-link")); + +const code = ` +export async function getMessage() { + "use server"; + return "hello"; +} +`; + +function clientTransform(filter?: ServerFunctionsFilter) { + const plugins = serverFunctionsPlugin({ + manifest: "virtual:server-fn-manifest", + runtime: { server: "virtual:server-runtime", client: "virtual:client-runtime" }, + filter, + }) as any[]; + for (const plugin of plugins) plugin.configResolved?.({ root, mode: "production" }); + + const compiler = plugins.find(p => p.name === "solid-start:server-functions/compiler"); + const context = { environment: { config: { consumer: "client" } }, warn() {} }; + return (id: string) => compiler.transform.call(context, code, id); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("serverFunctionsPlugin", () => { + it("compiles server functions when the working directory is a link to the root", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform(); + + const result = await transform(`${root}/src/message.ts`); + + expect(result).not.toBeNull(); + expect(result.code).toContain("virtual:client-runtime"); + expect(result.code).not.toContain("hello"); + }); + + it("leaves modules outside the root's src folder alone", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform(); + + expect(await transform(`${root}/scripts/message.ts`)).toBeNull(); + }); + + it("resolves relative filter patterns against the root", async () => { + vi.spyOn(process, "cwd").mockReturnValue(linkedRoot); + const transform = clientTransform({ include: "app/**/*.ts" }); + + expect(await transform(`${root}/app/message.ts`)).not.toBeNull(); + expect(await transform(`${root}/src/message.ts`)).toBeNull(); + }); +}); diff --git a/packages/start/src/directives/index.ts b/packages/start/src/directives/index.ts index 62137e271..2926b938a 100644 --- a/packages/start/src/directives/index.ts +++ b/packages/start/src/directives/index.ts @@ -125,10 +125,7 @@ function invalidateModules( } export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] { - const filter = createFilter( - options.filter?.include || DEFAULT_INCLUDE, - options.filter?.exclude || DEFAULT_EXCLUDE, - ); + let filter: ReturnType; let env: CompileOptions["env"]; @@ -177,6 +174,15 @@ export function serverFunctionsPlugin(options: ServerFunctionsOptions): Plugin[] enforce: "pre", configResolved(config) { env = config.mode !== "production" ? "development" : "production"; + // Vite resolves its root like every module id: to the real path since + // Vite 8.2, unless `resolve.preserveSymlinks`. Patterns resolved against + // a working directory that reaches the project through a junction or + // symlink would match no module. + filter = createFilter( + options.filter?.include || DEFAULT_INCLUDE, + options.filter?.exclude || DEFAULT_EXCLUDE, + { resolve: config.root }, + ); }, configureServer(server) { currentServer = server; diff --git a/packages/start/src/server/collect-styles.spec.ts b/packages/start/src/server/collect-styles.spec.ts new file mode 100644 index 000000000..08f449c13 --- /dev/null +++ b/packages/start/src/server/collect-styles.spec.ts @@ -0,0 +1,37 @@ +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { normalizePath } from "vite"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { findStylesInModuleGraph } from "./collect-styles.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("findStylesInModuleGraph", () => { + it("resolves the id against the root when the working directory is a link to it", async () => { + const root = join(tmpdir(), "solid-start-app"); + vi.spyOn(process, "cwd").mockReturnValue(join(tmpdir(), "solid-start-link")); + const page = resolve(root, "src/routes/page.tsx"); + const style = resolve(root, "src/routes/page.css"); + const modules: Record = { + [page]: { + id: page, + url: "/src/routes/page.tsx", + file: page, + transformResult: { deps: [style] }, + }, + [style]: { id: style, url: "/src/routes/page.css", file: style }, + }; + const environment = { + config: { root: normalizePath(root) }, + fetchModule: async (file: string) => (file in modules ? { id: file } : {}), + moduleGraph: { getModuleById: (id: string) => modules[id] }, + } as any; + + const styles = await findStylesInModuleGraph(environment, "src/routes/page.tsx", () => true); + + expect(styles).toEqual({ [style]: "/src/routes/page.css" }); + }); +}); diff --git a/packages/start/src/server/collect-styles.ts b/packages/start/src/server/collect-styles.ts index bb9510bde..affc954f4 100644 --- a/packages/start/src/server/collect-styles.ts +++ b/packages/start/src/server/collect-styles.ts @@ -60,7 +60,8 @@ export async function findStylesInModuleGraph( id: string, filter: StyleFilter, ) { - const absolute = path.resolve(process.cwd(), id); + // Ids are relative to Vite's root, as `lazy` derives `id$$`. + const absolute = path.resolve(vite.config.root, id); const dependencies = new Set(); try { From d3293693e74056a0c325e83c48e0b5d7c42a92f1 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 23 Oct 2026 03:19:06 +0800 Subject: [PATCH 3/5] feat: send files anywhere in server function arguments (#2359) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brenley Dueck --- .changeset/server-function-uploads.md | 5 + apps/tests/src/e2e/server-function.test.ts | 12 ++ .../src/routes/server-function-upload.tsx | 36 ++++ packages/start/src/fns/client.ts | 21 ++- packages/start/src/fns/handler.ts | 2 +- packages/start/src/fns/serialization.ts | 34 +++- packages/start/src/fns/shared.ts | 16 ++ packages/start/src/fns/uploads.spec.ts | 165 ++++++++++++++++++ packages/start/src/fns/uploads.ts | 141 +++++++++++++++ 9 files changed, 421 insertions(+), 11 deletions(-) create mode 100644 .changeset/server-function-uploads.md create mode 100644 apps/tests/src/routes/server-function-upload.tsx create mode 100644 packages/start/src/fns/uploads.spec.ts create mode 100644 packages/start/src/fns/uploads.ts diff --git a/.changeset/server-function-uploads.md b/.changeset/server-function-uploads.md new file mode 100644 index 000000000..3a68bdd57 --- /dev/null +++ b/.changeset/server-function-uploads.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": minor +--- + +Let server functions take files and blobs anywhere in their arguments, not only as the only argument. A call like `upload(file, { tags })`, or one with files in arrays, objects, maps, sets or form data, sends them as parts of multipart form data next to the serialized arguments, and the server function gets them as `File` values. Calls without files are sent as before. diff --git a/apps/tests/src/e2e/server-function.test.ts b/apps/tests/src/e2e/server-function.test.ts index abb9861f7..d668deaa6 100644 --- a/apps/tests/src/e2e/server-function.test.ts +++ b/apps/tests/src/e2e/server-function.test.ts @@ -94,6 +94,18 @@ test.describe("server-function", () => { await page.goto("http://localhost:3000/server-function-blob"); await expect(page.locator("#server-fn-test")).toContainText('{"result":true}'); }); + test("should send files next to other arguments", async ({ page }) => { + await page.goto("http://localhost:3000/server-function-upload"); + await expect(page.locator("#server-fn-test")).toContainText( + JSON.stringify({ + name: "hello-world.txt", + text: "Hello, World!", + tags: ["a", "b"], + attachments: ["one", "two"], + avatar: "avatar.png:png", + }), + ); + }); test("should remove exports for non-function values when top-level use server is used", async ({ page, }) => { diff --git a/apps/tests/src/routes/server-function-upload.tsx b/apps/tests/src/routes/server-function-upload.tsx new file mode 100644 index 000000000..874d3047b --- /dev/null +++ b/apps/tests/src/routes/server-function-upload.tsx @@ -0,0 +1,36 @@ +import { createEffect, createSignal } from "solid-js"; + +async function upload(file: File, meta: { tags: string[]; attachments: Blob[] }, form: FormData) { + "use server"; + const attachments = await Promise.all(meta.attachments.map(blob => blob.text())); + const avatar = form.get("avatar") as File; + return { + name: file.name, + text: await file.text(), + tags: meta.tags, + attachments, + avatar: `${avatar.name}:${await avatar.text()}`, + }; +} + +export default function App() { + const [output, setOutput] = createSignal(); + + createEffect(async () => { + const form = new FormData(); + form.set("avatar", new File(["png"], "avatar.png", { type: "image/png" })); + setOutput( + await upload( + new File(["Hello, World!"], "hello-world.txt"), + { tags: ["a", "b"], attachments: [new Blob(["one"]), new Blob(["two"])] }, + form, + ), + ); + }); + + return ( +
+ {JSON.stringify(output())} +
+ ); +} diff --git a/packages/start/src/fns/client.ts b/packages/start/src/fns/client.ts index 1a8a68ce0..9f74c50cb 100644 --- a/packages/start/src/fns/client.ts +++ b/packages/start/src/fns/client.ts @@ -5,6 +5,7 @@ import { serializeToJSONString, } from "./serialization.ts"; import { BODY_FORMAT_KEY, BodyFormat, extractBody, getHeadersAndBody } from "./shared.ts"; +import { appendUploads, ARGS_PART, createUploadSerializer } from "./uploads.ts"; let INSTANCE = 0; @@ -55,10 +56,26 @@ async function initializeResponse( } } // Fallback to seroval + const files: Blob[] = []; + // TODO(Alexis): move to serializeToJSONStream + const serialized = await serializeToJSONString(args, [createUploadSerializer(files)]); + if (files.length > 0) { + // Files go as form parts, so they are sent as they are instead of encoded. + const formData = new FormData(); + formData.set(ARGS_PART, serialized); + await appendUploads(formData, files); + return createRequest(base, id, instance, { + ...options, + body: formData, + headers: { + ...options.headers, + [BODY_FORMAT_KEY]: BodyFormat.SerovalFormData, + }, + }); + } return createRequest(base, id, instance, { ...options, - // TODO(Alexis): move to serializeToJSONStream - body: await serializeToJSONString(args), + body: serialized, // duplex: 'half', // body: serializeToJSONStream(args), headers: { diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index cf0a7efd4..56b4c5748 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -184,7 +184,7 @@ async function runServerFunction(h3Event: H3Event) { if (request.method === "POST" && request.body !== null) { const bodyFormat = request.headers.get(BODY_FORMAT_KEY); const decoded = await extractBody("", false, request.clone()); - if (bodyFormat === BodyFormat.Seroval) { + if (bodyFormat === BodyFormat.Seroval || bodyFormat === BodyFormat.SerovalFormData) { parsed = decoded as any[]; } else { parsed.push(decoded); diff --git a/packages/start/src/fns/serialization.ts b/packages/start/src/fns/serialization.ts index a68a3b6d0..0ff67e4a5 100644 --- a/packages/start/src/fns/serialization.ts +++ b/packages/start/src/fns/serialization.ts @@ -5,6 +5,7 @@ import { fromCrossJSON, getCrossReferenceHeader, isStream, + type Plugin, type SerovalNode, toCrossJSONStream, } from "seroval"; @@ -47,6 +48,14 @@ const DEFAULT_PLUGINS = [ * rather than a runtime option. */ const PLUGINS = [...DEFAULT_PLUGINS, ...userPlugins]; + +/** + * Plugins for one call, such as the ones that carry files as form parts. They + * come first, so the values they handle never reach the other plugins. + */ +function withPlugins(extra: Plugin[] | undefined) { + return extra?.length ? [...extra, ...PLUGINS] : PLUGINS; +} const MAX_SERIALIZATION_DEPTH_LIMIT = 64; const DISABLED_FEATURES = Feature.RegExp; @@ -144,13 +153,13 @@ export function serializeToJSStream(id: string, value: any) { }); } -export function serializeToJSONStream(value: any) { +export function serializeToJSONStream(value: any, plugins?: Plugin[]) { return new ReadableStream({ start(controller) { toCrossJSONStream(value, { disabledFeatures: SERIALIZE_DISABLED_FEATURES, depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, - plugins: PLUGINS, + plugins: withPlugins(plugins), onParse(node) { enqueueChunk(controller, JSON.stringify(node)); }, @@ -197,6 +206,11 @@ export interface SerovalChunkReaderOptions { maxChunkSize?: number; } +export interface DeserializeJSONStreamOptions extends SerovalChunkReaderOptions { + /** Plugins for this body only, tried before the others. */ + plugins?: Plugin[]; +} + export class SerovalChunkReader { reader: ReadableStreamDefaultReader; done = false; @@ -368,19 +382,22 @@ function createEndOfStreamError() { return new Error("Server function stream ended unexpectedly."); } -export async function serializeToJSONString(value: any) { - const response = new Response(serializeToJSONStream(value)); +export async function serializeToJSONString(value: any, plugins?: Plugin[]) { + const response = new Response(serializeToJSONStream(value, plugins)); return await response.text(); } -export async function deserializeFromJSONString(json: string) { +export async function deserializeFromJSONString( + json: string, + options?: DeserializeJSONStreamOptions, +) { const blob = new Response(json); - return await deserializeJSONStream(blob); + return await deserializeJSONStream(blob, options); } export async function deserializeJSONStream( response: Response | Request, - options?: SerovalChunkReaderOptions, + options?: DeserializeJSONStreamOptions, ) { if (!response.body) { throw new Error("missing body"); @@ -392,13 +409,14 @@ export async function deserializeJSONStream( }); if (!result.done) { const refs = new DecodedRefs(); + const plugins = withPlugins(options?.plugins); function interpretChunk(chunk: string): unknown { const value = fromCrossJSON(JSON.parse(chunk) as SerovalNode, { refs, disabledFeatures: DISABLED_FEATURES, depthLimit: MAX_SERIALIZATION_DEPTH_LIMIT, - plugins: PLUGINS, + plugins, }); return value; } diff --git a/packages/start/src/fns/shared.ts b/packages/start/src/fns/shared.ts index e0a1d7c2e..7f1045bae 100644 --- a/packages/start/src/fns/shared.ts +++ b/packages/start/src/fns/shared.ts @@ -1,8 +1,10 @@ import { + deserializeFromJSONString, deserializeJSONStream, deserializeJSStream, MAX_REQUEST_CHUNK_SIZE, } from "./serialization.ts"; +import { ARGS_PART, createUploadDeserializer } from "./uploads.ts"; export const BODY_FORMAT_KEY = "X-Start-Type"; @@ -17,6 +19,8 @@ export const enum BodyFormat { File = "5", ArrayBuffer = "6", Uint8Array = "7", + /** Serialized arguments with their files as parts of multipart form data. */ + SerovalFormData = "8", } export function getHeadersAndBody(body: any): @@ -100,6 +104,18 @@ export async function extractBody(instance: string, client: boolean, source: Req clone, client ? undefined : { maxChunkSize: MAX_REQUEST_CHUNK_SIZE }, ); + case startType === BodyFormat.SerovalFormData: { + // Only a client sends files, so this is always read on the server. + const formData = await clone.formData(); + const args = formData.get(ARGS_PART); + if (typeof args !== "string") { + throw new Error("Malformed server function upload."); + } + return await deserializeFromJSONString(args, { + maxChunkSize: MAX_REQUEST_CHUNK_SIZE, + plugins: [createUploadDeserializer(formData)], + }); + } case startType === BodyFormat.String: return await clone.text(); case startType === BodyFormat.File: { diff --git a/packages/start/src/fns/uploads.spec.ts b/packages/start/src/fns/uploads.spec.ts new file mode 100644 index 000000000..61e46a514 --- /dev/null +++ b/packages/start/src/fns/uploads.spec.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { deserializeFromJSONString, serializeToJSONString } from "./serialization.ts"; +import { BODY_FORMAT_KEY, BodyFormat, extractBody } from "./shared.ts"; +import { ARGS_PART, createUploadSerializer, FILE_PART } from "./uploads.ts"; + +vi.mock("../shared/dev-toolbar/functions/tracker.ts", () => ({ + pushRequest: vi.fn(), + pushResponse: vi.fn(), +})); + +const { cloneServerReference } = await import("./client.ts"); + +let sent: Request | undefined; + +beforeEach(() => { + sent = undefined; + vi.stubEnv("SERVER_BASE_URL", "http://localhost/"); + vi.stubGlobal( + "fetch", + vi.fn(async (request: Request) => { + sent = request; + return new Response(null, { status: 204 }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +/** Calls a server function and returns the arguments the server reads from the request. */ +async function roundTrip(...args: unknown[]) { + await (cloneServerReference("upload-fn") as unknown as (...args: unknown[]) => Promise)( + ...args, + ); + return (await extractBody("", false, sent!)) as unknown[]; +} + +function uploadRequest(form: FormData) { + return new Request("http://localhost/_server", { + method: "POST", + body: form, + headers: { [BODY_FORMAT_KEY]: BodyFormat.SerovalFormData }, + }); +} + +describe("server function uploads", () => { + it("sends files next to other arguments as form parts", async () => { + const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + + const [received, meta] = await roundTrip(file, { tags: ["a"] }); + + expect(sent!.headers.get(BODY_FORMAT_KEY)).toBe(BodyFormat.SerovalFormData); + expect(sent!.headers.get("content-type")).toMatch(/^multipart\/form-data/); + expect(received).toBeInstanceOf(File); + expect((received as File).name).toBe("hello.txt"); + expect((received as File).type).toBe("text/plain"); + expect(await (received as File).text()).toBe("hello"); + expect(meta).toEqual({ tags: ["a"] }); + }); + + it("finds files anywhere in the arguments", async () => { + const a = new File(["a"], "a.txt"); + const b = new File(["b"], "b.txt"); + const c = new File(["c"], "c.txt"); + + const [value] = (await roundTrip({ + list: [a], + map: new Map([["b", b]]), + set: new Set([c]), + })) as [{ list: File[]; map: Map; set: Set }]; + + expect(await value.list[0]!.text()).toBe("a"); + expect(await value.map.get("b")!.text()).toBe("b"); + expect(await [...value.set][0]!.text()).toBe("c"); + }); + + it("sends the files of form data that is not the only argument", async () => { + const form = new FormData(); + form.set("name", "avatar"); + form.set("file", new File(["png"], "avatar.png", { type: "image/png" })); + + const [received, count] = (await roundTrip(form, 1)) as [FormData, number]; + + expect(received).toBeInstanceOf(FormData); + expect(received.get("name")).toBe("avatar"); + expect(await (received.get("file") as File).text()).toBe("png"); + expect(count).toBe(1); + }); + + it("sends a file used twice once, and keeps it the same value", async () => { + const file = new File(["once"], "once.txt"); + + const [first, second] = await roundTrip(file, { again: file }); + + const form = await sent!.clone().formData(); + expect(form.has(`${FILE_PART}0`)).toBe(true); + expect(form.has(`${FILE_PART}1`)).toBe(false); + expect((second as { again: File }).again).toBe(first); + }); + + it("keeps the type of a blob", async () => { + const [received] = await roundTrip(new Blob(["{}"], { type: "application/json" }), 2); + + expect(received).toBeInstanceOf(Blob); + expect((received as Blob).type).toBe("application/json"); + expect(await (received as Blob).text()).toBe("{}"); + }); + + it("copies a blob from another frame", async () => { + const source = new Blob(["other"], { type: "text/plain" }); + // Not an instance of this realm's Blob, as one from another frame isn't. + const foreign = { + size: source.size, + type: source.type, + arrayBuffer: () => source.arrayBuffer(), + slice: () => source.slice(), + }; + + const [received] = await roundTrip(foreign, 1); + + expect(await (received as Blob).text()).toBe("other"); + }); + + it("keeps calls without files as serialized text", async () => { + await roundTrip({ a: 1 }, 2); + + expect(sent!.headers.get(BODY_FORMAT_KEY)).toBe(BodyFormat.Seroval); + expect(sent!.headers.get("content-type")).toBe("text/plain"); + }); + + it("rejects an upload whose part is missing", async () => { + const form = new FormData(); + const files: Blob[] = []; + form.set( + ARGS_PART, + await serializeToJSONString([new File(["x"], "x.txt")], [createUploadSerializer(files)]), + ); + + await expect(extractBody("", false, uploadRequest(form))).rejects.toThrow( + "Malformed server function upload.", + ); + }); + + it("rejects an upload without its arguments", async () => { + const form = new FormData(); + form.set(`${FILE_PART}0`, new File(["x"], "x.txt")); + + await expect(extractBody("", false, uploadRequest(form))).rejects.toThrow( + "Malformed server function upload.", + ); + }); + + it("does not read upload indexes from a body that is not multipart", async () => { + const body = await serializeToJSONString( + [new File(["x"], "x.txt")], + [createUploadSerializer([])], + ); + + // A plain body is read without the upload plugin, so the index has no meaning there. + await expect(deserializeFromJSONString(body)).rejects.toThrow(); + }); +}); diff --git a/packages/start/src/fns/uploads.ts b/packages/start/src/fns/uploads.ts new file mode 100644 index 000000000..9bc42f32a --- /dev/null +++ b/packages/start/src/fns/uploads.ts @@ -0,0 +1,141 @@ +import { createPlugin, type Plugin, type SerovalNode } from "seroval"; + +/** + * Files and blobs in the arguments of a server function call go as parts of + * multipart form data instead of inside the serialized arguments. The + * arguments go in the `ARGS_PART` field, and each file in `FILE_PART`, + * where the arguments hold only its index. + */ +export const ARGS_PART = "args"; +export const FILE_PART = "file"; + +const UPLOAD_TAG = "solid-start/upload"; + +/* + * Migration note: seroval's binary mode (`binary.serialize` and + * `binary.deserialize`, in development on seroval's `binary-mode` branch) + * could replace this when server function bodies move to it. + * + * Binary mode would be better for the arguments in general: + * - one body and one format for every value, instead of JSON in a form field; + * - `ArrayBuffer` and typed array bytes sent as they are, with no base64 and + * no `maxBase64Length` limit; + * - decoding that validates untrusted input and runs no code, as `fromCrossJSON` + * does today. + * + * For files, the form parts here are still better: + * - `BlobPlugin` and `FilePlugin` read the whole file with `arrayBuffer()` + * before sending it, so a large upload is held in memory on the client. A + * `File` in `FormData` is read by the browser as it sends the request. + * - Sending binary mode output as it is produced needs a streamed request + * body (`duplex: "half"`), which not every browser supports. Otherwise the + * chunks are collected first, which holds the file in memory again. + * - Multipart bodies work in every runtime and with every proxy, and servers + * read them with `request.formData()`. + * + * Neither limits the total size: binary mode leaves that to the transport, + * and `request.formData()` reads the whole body. A limit belongs in the + * server either way. + * + * When migrating, the arguments can move to binary mode while files keep + * going as form parts, with binary mode's plugin section writing the part + * index the way `createUploadSerializer` does. A server should accept both + * bodies for a while, because an open tab can call a newer server. + */ + +type UploadNode = { i: SerovalNode }; + +/** + * True for a file or a blob. One from another frame is an instance of that + * frame's `Blob`, so its shape tells instead. + */ +function isBlob(value: unknown): value is Blob { + if (value instanceof Blob) return true; + const blob = value as Partial | null; + return ( + typeof blob === "object" && + blob !== null && + typeof blob.size === "number" && + typeof blob.type === "string" && + typeof blob.arrayBuffer === "function" && + typeof blob.slice === "function" + ); +} + +function malformed(): Error { + return new Error("Malformed server function upload."); +} + +/** + * Serializes each file or blob in the arguments as its index in `files`, so + * the call can send it as a form part. Seroval calls this wherever it finds + * one: in arrays, objects, maps, sets and form data. + */ +export function createUploadSerializer(files: Blob[]): Plugin { + const parse = (value: Blob) => files.push(value) - 1; + return createPlugin({ + tag: UPLOAD_TAG, + test: isBlob, + parse: { + sync: (value, ctx) => ({ i: ctx.parse(parse(value)) }), + async: async (value, ctx) => ({ i: await ctx.parse(parse(value)) }), + stream: (value, ctx) => ({ i: ctx.parse(parse(value)) }), + }, + serialize: (node, ctx) => ctx.serialize(node.i), + deserialize: () => { + throw malformed(); + }, + }); +} + +/** + * Turns each index written by `createUploadSerializer` back into its part of + * `form`. It is only used for a multipart call, so an index in any other body + * fails to deserialize. + */ +export function createUploadDeserializer(form: FormData): Plugin { + return createPlugin({ + tag: UPLOAD_TAG, + test: () => false, + parse: { + sync: () => { + throw malformed(); + }, + async: async () => { + throw malformed(); + }, + stream: () => { + throw malformed(); + }, + }, + serialize: () => { + throw malformed(); + }, + deserialize: (node, ctx) => { + const index = ctx.deserialize(node.i); + if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0) { + throw malformed(); + } + const part = form.get(`${FILE_PART}${index}`); + if (!(part instanceof Blob)) throw malformed(); + return part; + }, + }); +} + +/** + * A blob of another frame is copied, because form data only takes blobs of + * its own. + */ +export async function appendUploads(form: FormData, files: Blob[]): Promise { + const blobs = await Promise.all( + files.map(async file => + file instanceof Blob + ? file + : new Blob([await (file as Blob).arrayBuffer()], { type: (file as Blob).type }), + ), + ); + for (const [index, blob] of blobs.entries()) { + form.set(`${FILE_PART}${index}`, blob, (files[index] as Partial).name ?? "blob"); + } +} From e8bd8c0151cb083d7f5ddccbb5746c5e8c0dd5a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Chatard?= Date: Thu, 22 Oct 2026 21:29:53 +0200 Subject: [PATCH 4/5] fix(config): keep Vite's default client resolve conditions (#2365) Co-authored-by: Brenley Dueck --- .changeset/keep-client-conditions.md | 5 ++++ packages/start/src/config/index.spec.ts | 36 +++++++++++++++++++++++++ packages/start/src/config/index.ts | 18 +++++++++++-- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 .changeset/keep-client-conditions.md diff --git a/.changeset/keep-client-conditions.md b/.changeset/keep-client-conditions.md new file mode 100644 index 000000000..0d8808ace --- /dev/null +++ b/.changeset/keep-client-conditions.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Keep Vite's default resolve conditions ("module", "browser") in the client environment: since 2.1.0, packages exposing a "browser" export condition (e.g. `@sentry/solidstart`) resolved to their Node build in the browser. The dev overlay's `@jridgewell/trace-mapping` is now pre-bundled, as the "browser" condition makes it import a UMD build. diff --git a/packages/start/src/config/index.spec.ts b/packages/start/src/config/index.spec.ts index 4eec36f04..7f6650e43 100644 --- a/packages/start/src/config/index.spec.ts +++ b/packages/start/src/config/index.spec.ts @@ -81,3 +81,39 @@ describe("SERVER_BASE_URL", () => { await expect(serverBaseUrl({ base: "/app" }, command)).resolves.toBe("/app/"); }); }); + +describe("resolve conditions", () => { + async function resolveEnvironments(command: ConfigEnv["command"] = "serve") { + const { resolveConfig } = await import("vite"); + const config = await resolveConfig( + { root, logLevel: "silent", plugins: [solidStart()] }, + command, + command === "build" ? "production" : "development", + ); + return config.environments; + } + + it("keeps Vite's client conditions", async () => { + const { client } = await resolveEnvironments(); + + expect(client.resolve.conditions).toEqual(expect.arrayContaining(["module", "browser"])); + expect(client.resolve.conditions).not.toContain("generic"); + }); + + it("resolves the server to runtime-agnostic builds", async () => { + const { ssr } = await resolveEnvironments(); + + expect(ssr.resolve.conditions).toContain("generic"); + expect(ssr.resolve.conditions).not.toContain("node"); + }); + + it("pre-bundles the dev overlay's dependencies in dev only", async () => { + const dev = await resolveEnvironments("serve"); + const build = await resolveEnvironments("build"); + + expect(dev.client.optimizeDeps.include).toContain("@solidjs/start > @jridgewell/trace-mapping"); + expect(build.client.optimizeDeps.include ?? []).not.toContain( + "@solidjs/start > @jridgewell/trace-mapping", + ); + }); +}); diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index 3d5aa28e9..e8d09dde7 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -224,6 +224,11 @@ export interface SolidStartOptions { }; } +// The dev overlay is served unbundled (solid packages are excluded from +// pre-bundling), and with the "browser" condition @jridgewell/trace-mapping +// imports the UMD build of @jridgewell/resolve-uri, which has no ESM exports. +const DEV_OVERLAY_DEPENDENCIES = ["@solidjs/start > @jridgewell/trace-mapping"]; + const absolute = (path: string, root: string) => path ? (isAbsolute(path) ? path : join(root, path)) : path; @@ -293,10 +298,16 @@ export function solidStart(options?: SolidStartOptions): Array { { name: "solid-start:config", enforce: "pre", - configEnvironment(name) { + configEnvironment(name, config) { + // Setting resolve.conditions replaces Vite's defaults instead of extending + // them. Server environments drop "node" on purpose, so that h3 resolves to + // its runtime-agnostic build, but the client must keep "browser" and + // "module": without them, packages exposing a "browser" export condition + // resolve to their Node build in the browser. + const isClient = name === VITE_ENVIRONMENTS.client || config.consumer === "client"; return { resolve: { - conditions: ["generic"], + ...(isClient ? {} : { conditions: ["generic"] }), // remove when https://github.com/solidjs/vite-plugin-solid/pull/228 is released externalConditions: ["solid", "node"], }, @@ -343,6 +354,9 @@ export function solidStart(options?: SolidStartOptions): Array { environments: { [VITE_ENVIRONMENTS.client]: { consumer: "client", + ...(env.command === "serve" && start.devOverlay + ? { optimizeDeps: { include: DEV_OVERLAY_DEPENDENCIES } } + : {}), build: { write: true, manifest: true, From 79b71109c054f61d847bf889c0a84c08d560488c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 22 Oct 2026 13:30:48 +0800 Subject: [PATCH 5/5] feat(start): add skew protection for server functions - Add the `serverFunctions.skewProtection` option. It is off by default. - Each production build gets an ID that both the client and server bundles share. A configured `buildId` replaces the random one. - The client sends its build ID with every server function call. - The server answers a call with a different build ID with 409 and does not run the function. - The client reloads the page once on that response. A second skew within 10 seconds rejects the call instead. Co-Authored-By: Claude Opus 5.5 --- .changeset/server-function-skew.md | 5 ++ packages/start/src/config/index.spec.ts | 39 +++++++++++ packages/start/src/config/index.ts | 46 +++++++++++++ packages/start/src/env.d.ts | 1 + packages/start/src/fns/client.spec.ts | 61 ++++++++++++++++++ packages/start/src/fns/client.ts | 32 +++++++++- packages/start/src/fns/handler.spec.ts | 64 +++++++++++++++++++ packages/start/src/fns/handler.ts | 26 +++++++- packages/start/src/fns/shared.ts | 6 ++ .../start/src/shared/route-chunk-reload.ts | 2 +- 10 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 .changeset/server-function-skew.md 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));