diff --git a/.changeset/server-fn-undefined-before-string.md b/.changeset/server-fn-undefined-before-string.md new file mode 100644 index 000000000..9f3b1350a --- /dev/null +++ b/.changeset/server-fn-undefined-before-string.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Server-function calls that end in a string no longer turn an earlier `undefined` argument into `null`. `search(1, undefined, "milk")` took the bound form-post path, which moves the leading arguments into `?args=` as JSON, so the function ran with `limit = null` and its default parameter never applied. A trailing string now goes through the codec like any other argument list that JSON cannot carry: with `enableRichArguments()` the function receives `undefined`, and without it the call throws the same "sent as JSON by default" error as `search(1, undefined)`. Bound form actions (`action.with(id)` posting FormData, URLSearchParams or a File) keep the `?args=` shape and still send `undefined` as `null`, which matches the no-JS action url. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 582f06d0d..460343a96 100644 --- a/documentation/solid-2.0/10-server-functions.md +++ b/documentation/solid-2.0/10-server-functions.md @@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o The package resolves to a client entry in the browser and a server entry elsewhere. -**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `/data/`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and ``). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `/` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.) +**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `/data/`, the id in a path segment — with no per-call header of their own (a read is exactly its url to caches and ``). The data address is the scripted transport’s own path, where answers are the codec’s; the bare `/` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles, a top-level `undefined` — except in a bound call whose trailing argument is a body-like object (FormData, URLSearchParams, File, Blob, ...), where the leading arguments ride `?args=` and `undefined` is sent as `null` as in the action url; a trailing string is not a body in this sense, so `fn(1, undefined, "str")` goes through the codec) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.) **Server:** `configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler: diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 131583ddb..829fa3fdf 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -601,10 +601,12 @@ async function initializeResponse(base, id, options, args, meta) { // handler prepends url arguments before natural-encoding bodies) and the // trailing argument IS the body. The same wire shape the no-JS fallback // produces, so bound form actions need no codec. `undefined` coerces to - // null exactly as it does in a rendered action url (JSON has none). + // null as it does in a router-rendered action url (JSON has none). + // Strings are excluded so an `undefined` before one reaches the codec. if (args.length > 1) { try { - const trailing = getHeadersAndBody(args[args.length - 1]); + const last = args[args.length - 1]; + const trailing = typeof last !== "string" && getHeadersAndBody(last); const leading = args.slice(0, -1).map(arg => (arg === undefined ? null : arg)); if (trailing && isJSONSafe(leading)) { const target = diff --git a/packages/web/test/server/server-function-matrix/MATRIX.md b/packages/web/test/server/server-function-matrix/MATRIX.md index 027e23a27..1eebf339e 100644 --- a/packages/web/test/server/server-function-matrix/MATRIX.md +++ b/packages/web/test/server/server-function-matrix/MATRIX.md @@ -40,6 +40,7 @@ Status legend: **pass** (ordinary green guard) · **audit** (reported by | Body-format tags are recognized before decoding | **pass** | n/a | **pass** | `server-functions-body-formats`; #3245 covers the client half | | Unsafe own keys are removed at every untrusted decode boundary | **pass** | n/a | #3233 **audit** | `server-functions-proto-keys`, `server-functions-open-gaps` | | Decoded promises are always owned, even when their container is abandoned | n/a | n/a | #3232 **audit** | new focused spec required | +| The bound `?args=` fast path applies only when the trailing argument is a body-like object; an `undefined` before a trailing string rides the codec | **pass** | n/a | **pass** | `server-functions-undefined-arguments`, `server-functions-body-formats`; #3622 **ruling** | ## Result graph and request scope @@ -314,6 +315,30 @@ Pinned in `server-functions-dev-rebind-grant` (carry, origin gate on a carried id, re-declaration upgrade, stale re-declaration, chained rebinds, stale-grant refusal, production revocation). +## Ruling — `undefined` before a trailing string (#3622, 2026-09-23) + +The bound fast path (`action.with(id)` posting a body: leading arguments in +`?args=` as JSON, `undefined` coerced to `null` as in the router-rendered +action url) admitted a trailing plain string, because `getHeadersAndBody` +gives strings a natural encoding. A string call reaches that path only when +the JSON fast path refused the list — in practice only over a leading +`undefined` — so `search(1, undefined, "milk")` ran with `limit = null` and +the default parameter never applied, rich arguments or not. Resolved: + +1. **The fast path is for body-like objects only** — FormData, + URLSearchParams, File, Blob, ArrayBuffer, Uint8Array. A trailing string is + excluded; those bound calls keep their `?args=[...,null]` shape. +2. **`undefined` otherwise rides the codec, like any other `undefined` + argument.** With `enableRichArguments()` the function receives a real + `undefined`; under the default config the call rejects with the existing + "sent as JSON by default" error, exactly as `search(1, undefined)` does. + The fast path was the exception, not the rule; a default-config app that + observed `null` was relying on the defect. + +Pinned in `server-functions-undefined-arguments` (default-config refusal, +rich-argument delivery, plain-JSON string call, FormData/URLSearchParams/File +controls). + ## Extraction and merge discipline - A red test demonstrates current behavior; it becomes an ordinary guard only diff --git a/packages/web/test/server/server-functions-undefined-arguments.spec.tsx b/packages/web/test/server/server-functions-undefined-arguments.spec.tsx new file mode 100644 index 000000000..83956a9b1 --- /dev/null +++ b/packages/web/test/server/server-functions-undefined-arguments.spec.tsx @@ -0,0 +1,194 @@ +// Runs against the built bundles (server-functions/dist/*, see vite.config.server.mjs). +import { AsyncLocalStorage } from "node:async_hooks"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + handleServerFunctionRequest, + registerServerFunction +} from "@solidjs/web/server-functions/server"; +import { + configureServerFunctionsClient, + createServerReference, + getServerFunctionsCodec, + serializeString +} from "@solidjs/web/server-functions/client"; + +const RequestContext = Symbol.for("solid.RequestContext"); +const BODY_FORMAT_HEADER = "X-Server-Function-Format"; +const SERIALIZED_FORMAT = "0"; +const FORM_DATA_FORMAT = "2"; +const URL_PARAMS_FORMAT = "3"; +const FILE_FORMAT = "5"; +const JSON_FORMAT = "8"; + +beforeAll(() => { + (globalThis as any)[RequestContext] = new AsyncLocalStorage(); +}); + +afterAll(() => { + delete (globalThis as any)[RequestContext]; +}); + +function connectTransport() { + const original = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request + ? input + : new Request(new URL(input.toString(), "https://app.example"), init); + request.headers.set("Sec-Fetch-Site", "same-origin"); + requests.push(request.clone()); + return handleServerFunctionRequest(request); + }) as typeof fetch; + return { + requests, + restore() { + globalThis.fetch = original; + } + }; +} + +function registerSearch(id: string) { + let ran = 0; + const impl = async (id: number, limit = 10, q = "") => { + ran++; + return `id=${id} limit=${limit} q=${q}`; + }; + registerServerFunction(id, impl); + return { + search: createServerReference(id) as unknown as typeof impl, + runs: () => ran + }; +} + +describe("without rich arguments", () => { + it("refuses an undefined before a trailing string instead of sending null", async () => { + const { search, runs } = registerSearch("undefined-args-default-string"); + const transport = connectTransport(); + try { + await expect(search(1, undefined, "milk")).rejects.toThrow(/sent as JSON by default/); + expect(runs()).toBe(0); + expect(transport.requests).toHaveLength(0); + } finally { + transport.restore(); + } + }); + + it("refuses a trailing undefined the same way", async () => { + const { search, runs } = registerSearch("undefined-args-default-trailing"); + const transport = connectTransport(); + try { + await expect(search(1, undefined)).rejects.toThrow(/sent as JSON by default/); + expect(runs()).toBe(0); + } finally { + transport.restore(); + } + }); + + it("sends a string call without undefined as a plain JSON body", async () => { + const { search } = registerSearch("undefined-args-default-json"); + const transport = connectTransport(); + try { + expect(await search(1, 5, "milk")).toBe("id=1 limit=5 q=milk"); + const [request] = transport.requests; + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(JSON_FORMAT); + expect(await request.text()).toBe('[1,5,"milk"]'); + expect(new URL(request.url).searchParams.has("args")).toBe(false); + } finally { + transport.restore(); + } + }); + + it("keeps a bound form action's wire shape, undefined included", async () => { + let seen: unknown[] = []; + registerServerFunction("undefined-args-bound-form", async (...args: unknown[]) => { + seen = args; + return "ok"; + }); + const transport = connectTransport(); + try { + const form = new FormData(); + form.append("title", "milk"); + expect( + await createServerReference("undefined-args-bound-form")("list", undefined, form) + ).toBe("ok"); + expect(seen.slice(0, 2)).toEqual(["list", null]); + expect((seen[2] as FormData).get("title")).toBe("milk"); + const [request] = transport.requests; + expect(new URL(request.url).searchParams.get("args")).toBe('["list",null]'); + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(FORM_DATA_FORMAT); + } finally { + transport.restore(); + } + }); + + it("keeps the bound shape for a trailing URLSearchParams", async () => { + let seen: unknown[] = []; + registerServerFunction("undefined-args-bound-params", async (...args: unknown[]) => { + seen = args; + return "ok"; + }); + const transport = connectTransport(); + try { + await createServerReference("undefined-args-bound-params")( + 7, + undefined, + new URLSearchParams("q=milk") + ); + expect(seen.slice(0, 2)).toEqual([7, null]); + expect(seen[2]).toBeInstanceOf(URLSearchParams); + expect((seen[2] as URLSearchParams).get("q")).toBe("milk"); + const [request] = transport.requests; + expect(new URL(request.url).searchParams.get("args")).toBe("[7,null]"); + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(URL_PARAMS_FORMAT); + } finally { + transport.restore(); + } + }); + + it("keeps the bound shape for a trailing File", async () => { + let seen: unknown[] = []; + registerServerFunction("undefined-args-bound-file", async (...args: unknown[]) => { + seen = args; + return "ok"; + }); + const transport = connectTransport(); + try { + await createServerReference("undefined-args-bound-file")( + "/inbox", + undefined, + new File(["scan"], "scan.pdf", { type: "application/pdf" }) + ); + expect(seen.slice(0, 2)).toEqual(["/inbox", null]); + expect(seen[2]).toBeInstanceOf(File); + expect((seen[2] as File).name).toBe("scan.pdf"); + const [request] = transport.requests; + expect(new URL(request.url).searchParams.get("args")).toBe('["/inbox",null]'); + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(FILE_FORMAT); + } finally { + transport.restore(); + } + }); +}); + +// Last in the file: the client config has no way to remove serializeArgs. +describe("with rich arguments", () => { + beforeAll(() => { + configureServerFunctionsClient({ + serializeArgs: args => serializeString(args, getServerFunctionsCodec()) + }); + }); + + it("delivers an undefined before a trailing string, so the default applies", async () => { + const { search } = registerSearch("undefined-args-rich-string"); + const transport = connectTransport(); + try { + expect(await search(1, undefined, "milk")).toBe("id=1 limit=10 q=milk"); + const [request] = transport.requests; + expect(request.headers.get(BODY_FORMAT_HEADER)).toBe(SERIALIZED_FORMAT); + expect(new URL(request.url).searchParams.has("args")).toBe(false); + } finally { + transport.restore(); + } + }); +});