From df5b25e8feab7e2b0e9ee66dfd1f2796a10863df Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Thu, 27 Aug 2026 16:55:26 +0700 Subject: [PATCH 1/6] feat(web): let an app supply the fetch server function calls go through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport sends every call with the global `fetch`. `configureServerFunctionsClient({ fetch })` replaces it: retries, telemetry, a test double, or a route of the app's own. An app-shaped url — an argument in a path segment, a tenant in a prefix — cannot be a second built-in address without teaching every gate that recognises a call: the runtime, the plugin's dev middleware, the generated dispatch gate, the router's action-url interception. It does not have to be one. The handler takes a web `Request`, so an app route that rewrites into the canonical address dispatches like any other call, and the client's side of it was the only piece missing. The seam is typed and called as `(address, init)`, including on the path where call observers are installed — that path used to hand the global `fetch` a `Request`, and keeping the difference would mean a wrapper written against the documented shape silently misrouting the moment devtools attached. Observers receive a reconstruction of the dispatched request. `null` restores the global, and a wrapper that answers with anything but a `Response` is told so by name rather than through a property read on undefined. Also tidies the `endpoint` docs on both entries, which the path-addressing change left saying the same thing twice. --- .changeset/server-functions-client-fetch.md | 9 ++ .../solid-2.0/10-server-functions.md | 2 +- packages/web/server-functions/src/client.ts | 56 ++++++-- packages/web/server-functions/src/server.ts | 12 +- .../server-functions-extensions.spec.tsx | 133 ++++++++++++++++++ 5 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 .changeset/server-functions-client-fetch.md diff --git a/.changeset/server-functions-client-fetch.md b/.changeset/server-functions-client-fetch.md new file mode 100644 index 000000000..33cc4a222 --- /dev/null +++ b/.changeset/server-functions-client-fetch.md @@ -0,0 +1,9 @@ +--- +"@solidjs/web": patch +--- + +Add `fetch` to `configureServerFunctionsClient`: the function the transport sends every server-function request with, typed and called as `(address, init)` — the address relative to the document, as the global one receives it — so an ordinary fetch wrapper drops in, a hand-written one needs no casts, and `parseServerFunctionUrl` reads the id back out for telemetry. `null` restores the global. + +An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper keeps the call same-origin and hands back what the peer answered; one that answers with anything but a `Response` is told so by name. It is the client transport's exit only: a server-side call runs in process and never reaches a fetch. + +Also tidies the `endpoint` documentation on both entries, which the path-addressing change left saying the same thing twice. diff --git a/documentation/solid-2.0/10-server-functions.md b/documentation/solid-2.0/10-server-functions.md index 12c4e4c1a..2163d2a3d 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?, 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; `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 address — `/`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **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 address — `/`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **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.) **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 aa97600b3..6bee31632 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -105,14 +105,12 @@ export type PrepareRequestHook = ( /** Options for `configureServerFunctionsClient`. */ export interface ServerFunctionsClientConfig { /** - * Endpoint the server's HTTP handler is mounted on. Must match the - * server configuration — SSR'd reference `url`s (e.g. form actions) and - * client fetches both derive from it. Prefix it when the app serves from - * a base path (e.g. `` `${BASE_URL}_server` ``). + * Mount path the server's HTTP handler answers on. Must match the server + * configuration — the id travels as the segment after it, and SSR'd + * reference `url`s (e.g. form actions) and client fetches both derive + * from it. Prefix it when the app serves from a base path + * (e.g. `` `${BASE_URL}_server` ``). * @default "/_server" - * - * Mount path the handler is mounted on. Must match the server's: the - * id travels as the segment after it. */ endpoint?: string; /** @@ -121,6 +119,25 @@ export interface ServerFunctionsClientConfig { * `decodeResponse` sees them too. */ codec?: JSONCodecOptions; + /** + * Sends every server-function request — retries, telemetry, a test + * double, or an app's own route. Always called as `(address, init)`, the + * address relative to the document as the global one receives it, so + * `parseServerFunctionUrl` reads the id back out for telemetry. `null` + * restores the global. + * + * ```ts + * configureServerFunctionsClient({ + * fetch: (address, init) => fetch(rewrite(address), init) + * }); + * ``` + * + * Keep the call same-origin — a cross-origin send is stamped + * `Sec-Fetch-Site: cross-site`, which the handler's origin gate refuses — + * and hand back what the peer answered: an unfollowed 3xx reads as a + * response the runtime did not write. + */ + fetch?: ((address: string, init: RequestInit) => Promise) | null; /** * Runs before every server-function fetch. Return (or mutate and return) * the RequestInit the transport will use; `context.meta` is the @@ -211,6 +228,7 @@ export interface ServerFunctionInvocation { const config = { endpoint: "/_server", + fetch: undefined, prepareRequest: undefined, responseHandler: undefined, serializeArgs: undefined @@ -298,7 +316,7 @@ function serializeArguments(args) { * Configures the client transport. Call once, before any server function is * invoked — typically in the client entry, next to `hydrate()`. Only needed * when deviating from the defaults (custom endpoint, codec plugins, or a - * `prepareRequest` hook). + * `prepareRequest` hook, or a custom `fetch`). */ export function configureServerFunctionsClient(config?: ServerFunctionsClientConfig): void; @@ -308,7 +326,8 @@ export function configureServerFunctionsClient(config?: ServerFunctionsClientCon * plugins etc. — must match the server's; stored in the shared layer so * `decodeResponse` sees them too), and the `prepareRequest` hook applied * to every outgoing server-function fetch (session-dynamic transport - * policy — bearer tokens, tracing headers). + * policy — bearer tokens, tracing headers), and the `fetch` the transport + * sends with. * * `responseHandler` is the response-side integration seam — the client * mirror of the handler's `transformResult`. `handle(response, ctx)` sees @@ -321,12 +340,14 @@ export function configureServerFunctionsClient(config?: ServerFunctionsClientCon export function configureServerFunctionsClient({ endpoint, codec, + fetch, prepareRequest, responseHandler, serializeArgs } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); + if (fetch !== undefined) config.fetch = fetch || undefined; if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; @@ -367,6 +388,14 @@ function serverFunctionFailure(response, value) { return error; } +// A configured `fetch` that forgets to return, or returns what it awaited +// off the response, would otherwise surface as a property read on undefined +// somewhere downstream, naming nothing. +function sent(response) { + if (response instanceof Response) return response; + throw new TypeError("The `fetch` configured for server functions must answer with a Response"); +} + async function createRequest(base, id, instance, options, meta) { const headers = { ...options.headers, @@ -398,11 +427,16 @@ async function createRequest(base, id, instance, options, meta) { if (config.prepareRequest) { init = (await config.prepareRequest(init, { id, meta })) || init; } - if (CALL_OBSERVERS.size === 0) return fetch(base, init); + const send = config.fetch || fetch; + if (CALL_OBSERVERS.size === 0) return sent(await send(base, init)); + // The send keeps the `(address, init)` shape it has on the path without + // observers — whether devtools are attached is not something a configured + // `fetch` should have to branch on — so what observers receive is a + // reconstruction of the dispatched request, not the object itself. const request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), init); notifyCallObservers("request", id, instance, request, meta); - const response = await fetch(request); + const response = sent(await send(base, init)); notifyCallObservers("response", id, instance, response, meta); return response; } diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index d1c232ddc..986e9d6d1 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -304,14 +304,12 @@ export interface ServerFunctionsServerConfig { ) => Response | Promise) | null; /** - * Endpoint the HTTP handler is mounted on, used for the `url` of SSR'd - * references (e.g. form actions) — must match the client configuration. - * Prefix it when the app serves from a base path (e.g. - * `` `${BASE_URL}_server` ``). + * Mount path the HTTP handler answers on. Must match the client + * configuration — the id travels as the segment after it, a request whose + * path does not start with it is not a call, and SSR'd reference `url`s + * (e.g. form actions) derive from it. Prefix it when the app serves from + * a base path (e.g. `` `${BASE_URL}_server` ``). * @default "/_server" - * - * Mount path the handler answers on: a request whose path does not - * start with it is not a call, and the id is the segment that follows. */ endpoint?: string; /** diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index b74852871..ef48c07b4 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -65,6 +65,13 @@ function connectTransport() { }; } +/** Delivers a transport request to the built handler, as `connectTransport` does. */ +function deliver(address: string, init?: RequestInit) { + const request = new Request(new URL(address, "http://localhost"), init); + request.headers.set("Sec-Fetch-Site", "same-origin"); + return handleServerFunctionRequest(request); +} + describe("server-function extension surface (built bundles)", () => { it("GET round-trips through both bundles and the handler enforces it", async () => { serverGET( @@ -189,6 +196,132 @@ describe("server-function extension surface (built bundles)", () => { } }); + it("sends through a configured fetch, which can address a call however it likes", async () => { + serverGET( + createServerSideReference( + registerServerReference("ext-fetch-0", async (word: string) => word.toUpperCase()) + ) + ); + const seen: string[] = []; + // An app that wants a url of its own: the transport hands over the + // canonical address, the wrapper sends an app-shaped one, and the app's + // route rewrites it back before the handler sees it. + configureServerFunctionsClient({ + fetch(address, init) { + const app = new URL(address, "http://localhost"); + app.pathname = "/api/upper"; + seen.push(app.pathname + app.search); + return deliver( + app.pathname.replace("/api/upper", "/_server/ext-fetch-0") + app.search, + init + ); + } + }); + try { + expect(await GET(createServerReference("ext-fetch-0"))("solid")).toBe("SOLID"); + expect(seen).toEqual(["/api/upper?args=%5B%22solid%22%5D"]); + } finally { + configureServerFunctionsClient({ fetch: null }); + } + }); + + it("hands the fetch one shape whether or not observers are attached", async () => { + registerServerFunction("ext-fetch-1", async () => "ok"); + const shapes: string[] = []; + configureServerFunctionsClient({ + fetch(address, init) { + shapes.push(`${typeof address}:${init?.method}`); + return deliver(address, init); + } + }); + try { + expect(await createServerReference("ext-fetch-1")()).toBe("ok"); + const stop = observeServerFunctionCalls(() => {}); + try { + expect(await createServerReference("ext-fetch-1")()).toBe("ok"); + } finally { + stop(); + } + expect(shapes).toEqual(["string:POST", "string:POST"]); + } finally { + configureServerFunctionsClient({ fetch: null }); + } + }); + + it("hands the fetch the init prepareRequest produced", async () => { + registerServerFunction("ext-fetch-2", async () => { + const store = (globalThis as any)[RequestContext].getStore(); + return store.request.headers.get("X-Prepared"); + }); + configureServerFunctionsClient({ + prepareRequest: init => ({ + ...init, + headers: { ...(init.headers as Record), "X-Prepared": "yes" } + }), + fetch: (address, init) => deliver(address, init) + }); + try { + expect(await createServerReference("ext-fetch-2")()).toBe("yes"); + } finally { + configureServerFunctionsClient({ prepareRequest: null as any, fetch: null }); + } + }); + + it("names the seam when a wrapper answers with something else", async () => { + registerServerFunction("ext-fetch-4", async () => "ok"); + configureServerFunctionsClient({ fetch: (() => undefined) as any }); + try { + await expect(createServerReference("ext-fetch-4")()).rejects.toThrow( + /must answer with a Response/ + ); + } finally { + configureServerFunctionsClient({ fetch: null }); + } + }); + + it("sends a GET-declared read through the configured fetch too", async () => { + serverGET( + createServerSideReference(registerServerReference("ext-fetch-5", async (n: number) => n * 2)) + ); + const seen: string[] = []; + const restore = connectTransport(); + const send = globalThis.fetch; + configureServerFunctionsClient({ + fetch: (address, init) => { + seen.push(`${init?.method ?? "POST"} ${address}`); + return send(address, init); + } + }); + try { + expect(await GET(createServerReference("ext-fetch-5"))(21)).toBe(42); + expect(seen).toEqual(["GET /_server/ext-fetch-5?args=%5B21%5D"]); + } finally { + configureServerFunctionsClient({ fetch: null }); + restore(); + } + }); + + it("restores the global fetch when the option is set to null", async () => { + registerServerFunction("ext-fetch-3", async () => "ok"); + let sends = 0; + configureServerFunctionsClient({ + fetch: (address, init) => { + sends++; + return deliver(address, init); + } + }); + const restore = connectTransport(); + try { + await createServerReference("ext-fetch-3")(); + configureServerFunctionsClient({ fetch: null }); + await createServerReference("ext-fetch-3")(); + expect(sends).toBe(1); + } finally { + configureServerFunctionsClient({ fetch: null }); + restore(); + } + }); + it("observes calls through the client bridge", async () => { registerServerFunction("ext-observe-0", async (value: number) => value * 2); const calls: ServerFunctionCall[] = []; From 79d070bde449b53e4e350bd27c5af49009108cc9 Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Fri, 28 Aug 2026 19:40:33 +0700 Subject: [PATCH 2/6] fix(web): keep observers out of the way of the call they observe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the seam turned up two things the observed path got wrong, both introduced by giving it the same `(address, init)` shape as the path without observers. The reconstructed `Request` was built from the live `init`, so a streaming body was disturbed before the send got it — a call that worked without devtools failed with them attached. It is now built without a streaming body, and skipped altogether if the init will not make a `Request` at all: an init the configured fetch would have tolerated must not fail the call because something was watching. The return guard was `instanceof Response`, which refuses a mock, a polyfill and another realm's response for their identity rather than their shape. It now duck-types, and also catches the likelier mistake of handing back a response the wrapper already read. Along with it: `init` forwarding is stated as the contract it is — dropping `signal` voids both the caller's abort and a live source's teardown — the option refuses a non-callable value where it is set rather than per call, the declared return admits a synchronous `Response` the code already accepted, and the server entry carries a no-op configurator so a shared config module resolves on both builds. --- .changeset/server-functions-client-fetch.md | 2 +- packages/web/server-functions/src/client.ts | 48 +++++++++---- packages/web/server-functions/src/server.ts | 11 +++ .../server-functions-extensions.spec.tsx | 69 +++++++++++++++++-- 4 files changed, 110 insertions(+), 20 deletions(-) diff --git a/.changeset/server-functions-client-fetch.md b/.changeset/server-functions-client-fetch.md index 33cc4a222..fa4e89146 100644 --- a/.changeset/server-functions-client-fetch.md +++ b/.changeset/server-functions-client-fetch.md @@ -4,6 +4,6 @@ Add `fetch` to `configureServerFunctionsClient`: the function the transport sends every server-function request with, typed and called as `(address, init)` — the address relative to the document, as the global one receives it — so an ordinary fetch wrapper drops in, a hand-written one needs no casts, and `parseServerFunctionUrl` reads the id back out for telemetry. `null` restores the global. -An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper keeps the call same-origin and hands back what the peer answered; one that answers with anything but a `Response` is told so by name. It is the client transport's exit only: a server-side call runs in process and never reaches a fetch. +An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper forwards `init` — the call's `signal` rides on it — keeps the call same-origin, and hands back what the peer answered, unread; one that answers with something it has already read, or with no response at all, is told so by name. The seam is the client transport's exit only: a server-side call runs in process and never reaches a fetch, and the server entry carries a no-op of the configurator so a shared config module resolves on both builds. Also tidies the `endpoint` documentation on both entries, which the path-addressing change left saying the same thing twice. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 6bee31632..6e25b60af 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -132,12 +132,13 @@ export interface ServerFunctionsClientConfig { * }); * ``` * - * Keep the call same-origin — a cross-origin send is stamped - * `Sec-Fetch-Site: cross-site`, which the handler's origin gate refuses — - * and hand back what the peer answered: an unfollowed 3xx reads as a - * response the runtime did not write. + * Forward `init` — the call's `signal` rides on it, and dropping it voids + * both the caller's abort and the teardown a live source's `break` + * performs. Keep the call same-origin, since a cross-origin send is + * stamped `Sec-Fetch-Site: cross-site` and the handler's origin gate + * refuses it, and hand back what the peer answered, unread. */ - fetch?: ((address: string, init: RequestInit) => Promise) | null; + fetch?: ((address: string, init: RequestInit) => Response | Promise) | null; /** * Runs before every server-function fetch. Return (or mutate and return) * the RequestInit the transport will use; `context.meta` is the @@ -347,7 +348,12 @@ export function configureServerFunctionsClient({ } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); - if (fetch !== undefined) config.fetch = fetch || undefined; + if (fetch !== undefined) { + if (fetch !== null && typeof fetch !== "function") { + throw new TypeError("`fetch` must be a function, or null to restore the global one"); + } + config.fetch = fetch || undefined; + } if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; @@ -388,12 +394,15 @@ function serverFunctionFailure(response, value) { return error; } -// A configured `fetch` that forgets to return, or returns what it awaited -// off the response, would otherwise surface as a property read on undefined -// somewhere downstream, naming nothing. +// A configured `fetch` that forgets to return, or hands back a response it +// already read, would otherwise surface as a property read on undefined or an +// undici clone error somewhere downstream, naming nothing. Duck-typed on +// purpose: a mock, a polyfill and another realm's `Response` are all fine. function sent(response) { - if (response instanceof Response) return response; - throw new TypeError("The `fetch` configured for server functions must answer with a Response"); + if (response && typeof response.clone === "function" && !response.bodyUsed) return response; + throw new TypeError( + "The `fetch` configured for server functions must answer with an unread Response" + ); } async function createRequest(base, id, instance, options, meta) { @@ -433,9 +442,20 @@ async function createRequest(base, id, instance, options, meta) { // The send keeps the `(address, init)` shape it has on the path without // observers — whether devtools are attached is not something a configured // `fetch` should have to branch on — so what observers receive is a - // reconstruction of the dispatched request, not the object itself. - const request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), init); - notifyCallObservers("request", id, instance, request, meta); + // reconstruction of the dispatched request, not the object itself. Built + // without a streaming body, and skipped entirely if the init will not make + // one: a read-only dev seam may not consume the call's body, and may not + // decide whether the call is sent at all. + let request; + try { + request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), { + ...init, + body: init.body instanceof ReadableStream ? undefined : init.body + }); + } catch { + request = undefined; + } + if (request) notifyCallObservers("request", id, instance, request, meta); const response = sent(await send(base, init)); notifyCallObservers("response", id, instance, response, meta); return response; diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 986e9d6d1..700e50ae1 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -1479,6 +1479,17 @@ export function observeServerFunctionCalls( export function observeServerFunctionCalls() { return () => {}; } /** + * Configures the client transport. A no-op on this entry, so a config module + * shared by both builds resolves — the options it carries describe a wire + * this entry never uses. + */ +export function configureServerFunctionsClient(config?: unknown): void; + +// Client-only transport configuration. Present as a no-op so isomorphic +// `@solidjs/web/server-functions` imports resolve on the server entry. +export function configureServerFunctionsClient() {} + +/** * Builds the url a reference is called at, for integrations composing action * urls the runtime did not render — a router turning a bound action into a * `
` for the no-JS path. `boundArgs` must be JSON-safe: the diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index ef48c07b4..8921ae13b 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -267,15 +267,39 @@ describe("server-function extension surface (built bundles)", () => { } }); - it("names the seam when a wrapper answers with something else", async () => { + it("names the seam when a wrapper answers with something it cannot read", async () => { registerServerFunction("ext-fetch-4", async () => "ok"); - configureServerFunctionsClient({ fetch: (() => undefined) as any }); + const restore = connectTransport(); + const send = globalThis.fetch; + for (const answer of [ + () => undefined, + // the likeliest wrapper mistake: log the body, hand back the response + async (address: string, init: RequestInit) => { + const response = await send(address, init); + await response.text(); + return response; + } + ]) { + configureServerFunctionsClient({ fetch: answer as any }); + await expect(createServerReference("ext-fetch-4")()).rejects.toThrow(/unread Response/); + } + // a Response from another realm or a mock is not refused for its identity + configureServerFunctionsClient({ + fetch: (address, init) => + send(address, init).then(response => ({ + ...response, + status: response.status, + headers: response.headers, + body: response.body, + clone: () => response.clone(), + text: () => response.text() + })) as any + }); try { - await expect(createServerReference("ext-fetch-4")()).rejects.toThrow( - /must answer with a Response/ - ); + expect(await createServerReference("ext-fetch-4")()).toBe("ok"); } finally { configureServerFunctionsClient({ fetch: null }); + restore(); } }); @@ -301,6 +325,41 @@ describe("server-function extension surface (built bundles)", () => { } }); + it("keeps observers out of the call's way", async () => { + registerServerFunction( + "ext-fetch-6", + async (value: unknown) => (value as any)?.constructor?.name ?? "none" + ); + const restore = connectTransport(); + const send = globalThis.fetch; + let seenBody: unknown; + configureServerFunctionsClient({ + fetch: (address, init) => { + seenBody = init.body; + return send(address, init); + } + }); + const stop = observeServerFunctionCalls(() => {}); + try { + // the observed request is reconstructed from the same init, so it must + // not consume what the send is about to use + const body = new FormData(); + body.set("k", "v"); + expect(await createServerReference("ext-fetch-6")(body)).toBe("FormData"); + expect(seenBody).toBe(body); + } finally { + stop(); + configureServerFunctionsClient({ fetch: null }); + restore(); + } + }); + + it("refuses a fetch option that is not callable", () => { + expect(() => configureServerFunctionsClient({ fetch: "nope" as any })).toThrow( + /must be a function/ + ); + }); + it("restores the global fetch when the option is set to null", async () => { registerServerFunction("ext-fetch-3", async () => "ok"); let sends = 0; From 2aa31940b19f3889b9238ccafaa957b470a29725 Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Fri, 28 Aug 2026 19:48:40 +0700 Subject: [PATCH 3/6] refactor(web): let the type carry the fetch contract The previous commit added two runtime checks the declared option already rules out: a guard rejecting a value that is not callable, and a per-call guard on what the wrapper answered with. Both describe author mistakes a `((address, init) => Response | Promise) | null` catches at the call site, and one of them ran on every request to do it. What stays is the part types cannot express: the observed request is reconstructed without a streaming body, because reconstructing one consumes it before the send can use it. --- .changeset/server-functions-client-fetch.md | 2 +- packages/web/server-functions/src/client.ts | 44 +++++-------------- .../server-functions-extensions.spec.tsx | 42 ------------------ 3 files changed, 12 insertions(+), 76 deletions(-) diff --git a/.changeset/server-functions-client-fetch.md b/.changeset/server-functions-client-fetch.md index fa4e89146..5a3b0bed0 100644 --- a/.changeset/server-functions-client-fetch.md +++ b/.changeset/server-functions-client-fetch.md @@ -4,6 +4,6 @@ Add `fetch` to `configureServerFunctionsClient`: the function the transport sends every server-function request with, typed and called as `(address, init)` — the address relative to the document, as the global one receives it — so an ordinary fetch wrapper drops in, a hand-written one needs no casts, and `parseServerFunctionUrl` reads the id back out for telemetry. `null` restores the global. -An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper forwards `init` — the call's `signal` rides on it — keeps the call same-origin, and hands back what the peer answered, unread; one that answers with something it has already read, or with no response at all, is told so by name. The seam is the client transport's exit only: a server-side call runs in process and never reaches a fetch, and the server entry carries a no-op of the configurator so a shared config module resolves on both builds. +An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper forwards `init` — the call's `signal` rides on it — keeps the call same-origin, and hands back what the peer answered, unread. The seam is the client transport's exit only: a server-side call runs in process and never reaches a fetch, and the server entry carries a no-op of the configurator so a shared config module resolves on both builds. Also tidies the `endpoint` documentation on both entries, which the path-addressing change left saying the same thing twice. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 6e25b60af..172987b62 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -348,12 +348,7 @@ export function configureServerFunctionsClient({ } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); - if (fetch !== undefined) { - if (fetch !== null && typeof fetch !== "function") { - throw new TypeError("`fetch` must be a function, or null to restore the global one"); - } - config.fetch = fetch || undefined; - } + if (fetch !== undefined) config.fetch = fetch || undefined; if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; @@ -394,17 +389,6 @@ function serverFunctionFailure(response, value) { return error; } -// A configured `fetch` that forgets to return, or hands back a response it -// already read, would otherwise surface as a property read on undefined or an -// undici clone error somewhere downstream, naming nothing. Duck-typed on -// purpose: a mock, a polyfill and another realm's `Response` are all fine. -function sent(response) { - if (response && typeof response.clone === "function" && !response.bodyUsed) return response; - throw new TypeError( - "The `fetch` configured for server functions must answer with an unread Response" - ); -} - async function createRequest(base, id, instance, options, meta) { const headers = { ...options.headers, @@ -437,26 +421,20 @@ async function createRequest(base, id, instance, options, meta) { init = (await config.prepareRequest(init, { id, meta })) || init; } const send = config.fetch || fetch; - if (CALL_OBSERVERS.size === 0) return sent(await send(base, init)); + if (CALL_OBSERVERS.size === 0) return send(base, init); // The send keeps the `(address, init)` shape it has on the path without // observers — whether devtools are attached is not something a configured // `fetch` should have to branch on — so what observers receive is a - // reconstruction of the dispatched request, not the object itself. Built - // without a streaming body, and skipped entirely if the init will not make - // one: a read-only dev seam may not consume the call's body, and may not - // decide whether the call is sent at all. - let request; - try { - request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), { - ...init, - body: init.body instanceof ReadableStream ? undefined : init.body - }); - } catch { - request = undefined; - } - if (request) notifyCallObservers("request", id, instance, request, meta); - const response = sent(await send(base, init)); + // reconstruction of the dispatched request, not the object itself — built + // without a streaming body, which reconstructing would consume before the + // send could use it. + const request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), { + ...init, + body: init.body instanceof ReadableStream ? undefined : init.body + }); + notifyCallObservers("request", id, instance, request, meta); + const response = await send(base, init); notifyCallObservers("response", id, instance, response, meta); return response; } diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index 8921ae13b..a35b6b725 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -267,42 +267,6 @@ describe("server-function extension surface (built bundles)", () => { } }); - it("names the seam when a wrapper answers with something it cannot read", async () => { - registerServerFunction("ext-fetch-4", async () => "ok"); - const restore = connectTransport(); - const send = globalThis.fetch; - for (const answer of [ - () => undefined, - // the likeliest wrapper mistake: log the body, hand back the response - async (address: string, init: RequestInit) => { - const response = await send(address, init); - await response.text(); - return response; - } - ]) { - configureServerFunctionsClient({ fetch: answer as any }); - await expect(createServerReference("ext-fetch-4")()).rejects.toThrow(/unread Response/); - } - // a Response from another realm or a mock is not refused for its identity - configureServerFunctionsClient({ - fetch: (address, init) => - send(address, init).then(response => ({ - ...response, - status: response.status, - headers: response.headers, - body: response.body, - clone: () => response.clone(), - text: () => response.text() - })) as any - }); - try { - expect(await createServerReference("ext-fetch-4")()).toBe("ok"); - } finally { - configureServerFunctionsClient({ fetch: null }); - restore(); - } - }); - it("sends a GET-declared read through the configured fetch too", async () => { serverGET( createServerSideReference(registerServerReference("ext-fetch-5", async (n: number) => n * 2)) @@ -354,12 +318,6 @@ describe("server-function extension surface (built bundles)", () => { } }); - it("refuses a fetch option that is not callable", () => { - expect(() => configureServerFunctionsClient({ fetch: "nope" as any })).toThrow( - /must be a function/ - ); - }); - it("restores the global fetch when the option is set to null", async () => { registerServerFunction("ext-fetch-3", async () => "ok"); let sends = 0; From ed7ff5aa827fc2e2ca4a6c9aff7f2fe4511c8f02 Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Fri, 28 Aug 2026 20:08:43 +0700 Subject: [PATCH 4/6] refactor(web): drop the server-side no-op configurator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It filled a gap this PR did not open: `configureServerFunctionsClient` has no server mirror on `next` either, and the option's own documentation points at the client entry as the place to call it. A no-op there also swallows the call silently, which is the wrong answer if someone reaches for it from shared code — that deserves its own issue and its own decision, not a line in a PR about the transport's exit. --- .changeset/server-functions-client-fetch.md | 2 +- packages/web/server-functions/src/server.ts | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.changeset/server-functions-client-fetch.md b/.changeset/server-functions-client-fetch.md index 5a3b0bed0..ede5f0faa 100644 --- a/.changeset/server-functions-client-fetch.md +++ b/.changeset/server-functions-client-fetch.md @@ -4,6 +4,6 @@ Add `fetch` to `configureServerFunctionsClient`: the function the transport sends every server-function request with, typed and called as `(address, init)` — the address relative to the document, as the global one receives it — so an ordinary fetch wrapper drops in, a hand-written one needs no casts, and `parseServerFunctionUrl` reads the id back out for telemetry. `null` restores the global. -An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper forwards `init` — the call's `signal` rides on it — keeps the call same-origin, and hands back what the peer answered, unread. The seam is the client transport's exit only: a server-side call runs in process and never reaches a fetch, and the server entry carries a no-op of the configurator so a shared config module resolves on both builds. +An app-shaped url is what makes it worth a seam: the handler takes a web `Request`, so a route that rewrites into the canonical address dispatches like any other call, and nothing downstream — the router's action-url interception, the plugin's dev middleware, the generated dispatch gate — has to learn a second address format. A wrapper forwards `init` — the call's `signal` rides on it — keeps the call same-origin, and hands back what the peer answered, unread. The seam is the client transport's exit only: a server-side call runs in process and never reaches a fetch. Also tidies the `endpoint` documentation on both entries, which the path-addressing change left saying the same thing twice. diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 700e50ae1..986e9d6d1 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -1479,17 +1479,6 @@ export function observeServerFunctionCalls( export function observeServerFunctionCalls() { return () => {}; } /** - * Configures the client transport. A no-op on this entry, so a config module - * shared by both builds resolves — the options it carries describe a wire - * this entry never uses. - */ -export function configureServerFunctionsClient(config?: unknown): void; - -// Client-only transport configuration. Present as a no-op so isomorphic -// `@solidjs/web/server-functions` imports resolve on the server entry. -export function configureServerFunctionsClient() {} - -/** * Builds the url a reference is called at, for integrations composing action * urls the runtime did not render — a router turning a bound action into a * `` for the no-JS path. `boundArgs` must be JSON-safe: the From 4e7c89f1ca7d6c9cec37e93e3d02db7e4f6cd641 Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Fri, 28 Aug 2026 20:11:47 +0700 Subject: [PATCH 5/6] test(web): pin the streaming body, drop what it covers twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverting each part of this change one at a time showed the tests did not watch two of them. Nothing failed when the reconstruction for observers was allowed to consume a streaming body — the regression the previous commit fixed — so there is a test for it now, and it is the only one that fails when that guard goes. Nothing failed either when `null` stopped resetting the option, because `config.fetch || fetch` already answers for a null. The normalisation on the way in was dead; it is gone. One test went with them: with the streaming case pinned, the FormData one died on exactly the same reverts as the shape test above it. --- packages/web/server-functions/src/client.ts | 2 +- .../server-functions-extensions.spec.tsx | 29 ++++++++----------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 172987b62..de5f90aa6 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -348,7 +348,7 @@ export function configureServerFunctionsClient({ } = {}) { if (endpoint !== undefined) config.endpoint = endpoint; if (codec !== undefined) configureServerFunctionsCodec(codec); - if (fetch !== undefined) config.fetch = fetch || undefined; + if (fetch !== undefined) config.fetch = fetch; if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index a35b6b725..063326525 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -289,31 +289,26 @@ describe("server-function extension surface (built bundles)", () => { } }); - it("keeps observers out of the call's way", async () => { - registerServerFunction( - "ext-fetch-6", - async (value: unknown) => (value as any)?.constructor?.name ?? "none" - ); + it("does not consume a streaming body to show it to observers", async () => { + registerServerFunction("ext-fetch-7", async (value: unknown) => String(value)); const restore = connectTransport(); const send = globalThis.fetch; - let seenBody: unknown; configureServerFunctionsClient({ - fetch: (address, init) => { - seenBody = init.body; - return send(address, init); - } + prepareRequest: init => ({ + ...init, + body: new Blob(["streamed"]).stream(), + // @ts-expect-error — duplex is required for a stream body and absent + // from the DOM lib's RequestInit + duplex: "half" + }), + fetch: (address, init) => send(address, init) }); const stop = observeServerFunctionCalls(() => {}); try { - // the observed request is reconstructed from the same init, so it must - // not consume what the send is about to use - const body = new FormData(); - body.set("k", "v"); - expect(await createServerReference("ext-fetch-6")(body)).toBe("FormData"); - expect(seenBody).toBe(body); + expect(await createServerReference("ext-fetch-7")("ignored")).toBe("streamed"); } finally { stop(); - configureServerFunctionsClient({ fetch: null }); + configureServerFunctionsClient({ prepareRequest: null as any, fetch: null }); restore(); } }); From fa55a1479d09b62b6af54bf741ea7ef455a63768 Mon Sep 17 00:00:00 2001 From: Vladimir Kutepov Date: Fri, 28 Aug 2026 20:13:27 +0700 Subject: [PATCH 6/6] test(web): drop an unused ts-expect-error The hook's init is loosely typed enough to take `duplex` without complaint, so the directive above it had nothing to suppress and `tsc --project tsconfig.test.json` failed on the directive itself. --- packages/web/test/server/server-functions-extensions.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index 063326525..927d12e58 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -296,9 +296,8 @@ describe("server-function extension surface (built bundles)", () => { configureServerFunctionsClient({ prepareRequest: init => ({ ...init, + // a stream body needs `duplex`, which the DOM lib's RequestInit omits body: new Blob(["streamed"]).stream(), - // @ts-expect-error — duplex is required for a stream body and absent - // from the DOM lib's RequestInit duplex: "half" }), fetch: (address, init) => send(address, init)