From a13c8ae49647814dd48ec26f5258a66417a8aaa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:46:22 +0000 Subject: [PATCH 1/3] feat(web): wrapServerFunctionsFetch, a send layer that composes with the app's fetch An integration that answers some server-function requests itself (a prerendered build's static artifacts) had to install itself as the transport fetch, so it and an app's own configureServerFunctionsClient ({ fetch }) replaced each other depending on which ran last. A layer wraps a send that reads the configured fetch at call time, so the order no longer matters. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NkjGmX4hAoiU9VhXNt7j3E --- .changeset/wrap-server-functions-fetch.md | 5 +++ packages/web/server-functions/src/client.ts | 45 ++++++++++++++++++- .../server-functions-extensions.spec.tsx | 37 ++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 .changeset/wrap-server-functions-fetch.md diff --git a/.changeset/wrap-server-functions-fetch.md b/.changeset/wrap-server-functions-fetch.md new file mode 100644 index 0000000000..ed824785e0 --- /dev/null +++ b/.changeset/wrap-server-functions-fetch.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Add `wrapServerFunctionsFetch(wrap)` to the server-functions client: an integration layers a send over the transport (answering some requests itself, passing the rest to `next`) without replacing the app's `configureServerFunctionsClient({ fetch })` or being replaced by it, whichever runs first. Exported type `ServerFunctionsSend`. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index ef0146513e..f973a53001 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -150,6 +150,12 @@ export type PrepareRequestHook = ( context: PrepareRequestContext ) => RequestInit | Promise; +/** How the transport sends a request: `fetch`'s shape, as the runtime calls it. */ +export type ServerFunctionsSend = ( + address: string, + init: RequestInit +) => Response | Promise; + /** Options for `configureServerFunctionsClient`. */ export interface ServerFunctionsClientConfig { /** @@ -210,7 +216,7 @@ export interface ServerFunctionsClientConfig { * The wrapper replaces delivery for the requests the runtime chooses to * send; the call-to-request mapping itself is not contractual. */ - fetch?: ((address: string, init: RequestInit) => Response | Promise) | null; + fetch?: ServerFunctionsSend | null; /** * Runs before every server-function fetch. Return (or mutate and return) * the RequestInit the transport will use; `context.meta` is the @@ -434,6 +440,42 @@ function serializeArguments(args) { } return config.serializeArgs(args); } /** + * Layers a send over the transport for an INTEGRATION — something that + * reroutes some requests (a static artifact, a cache) and hands the rest on. + * `wrap` receives `next`, the send it wraps, and returns the send to use. + * Unlike `configureServerFunctionsClient({ fetch })`, which belongs to the + * app, layers never replace each other or the app's `fetch`: `next` reads + * the configured `fetch` (or the global) at call time, so the order of the + * app's configuration and an integration's setup does not matter. The + * latest layer is outermost. Layers are permanent: install one once, at + * startup. + * + * ```ts + * wrapServerFunctionsFetch(next => (address, init) => + * isMine(address) ? answerFromCache(address) : next(address, init) + * ); + * ``` + * + * The contract for the send is `fetch`'s (see + * `ServerFunctionsClientConfig.fetch`): forward `init`, hand back what the + * peer answered, unread. Hydration adopts a server-rendered read only if + * its send reaches the global `fetch` in the same tick, so a layer that + * answers a read itself should fetch synchronously too. + */ +export function wrapServerFunctionsFetch( + wrap: (next: ServerFunctionsSend) => ServerFunctionsSend +): void; + +export function wrapServerFunctionsFetch(wrap) { + send = wrap(send); +} + +// The app's send, read per call: `configureServerFunctionsClient({ fetch })` +// may run before or after a layer is added, and hydration swaps the global +// for one synchronous compute. +let send = (address, init) => (config.fetch || fetch)(address, init); + +/** * 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 @@ -656,7 +698,6 @@ async function createRequest(base, id, options, meta) { // The send, as observed: the `"request"` record leaves from here — the // final address and init, immediately before `fetch` receives them. if (IS_OBSERVE && observation) observation.request(base, init); - const send = config.fetch || fetch; return send(base, init); } diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index e2bc4528e7..0b5e5da7c3 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -30,7 +30,8 @@ import { createServerReference, getServerFunctionMetadata, isServerFunction, - withMeta + withMeta, + wrapServerFunctionsFetch } from "@solidjs/web/server-functions/client"; import type { PrepareRequestHook } from "@solidjs/web/server-functions/client"; @@ -222,6 +223,40 @@ describe("server-function extension surface (built bundles)", () => { } }); + it("layers an integration's send over the app's fetch, whichever is configured first", async () => { + registerServerFunction("ext-fetch-9", async (word: string) => word); + const order: string[] = []; + // layers are permanent: this one only answers its own address and + // passes everything else on, as an integration's must + wrapServerFunctionsFetch(next => (address, init) => { + if (!address.includes("ext-fetch-9") && !address.includes("ext-fetch-cached")) + return next(address, init); + order.push("layer"); + return address.includes("cached") + ? new Response(JSON.stringify("from layer"), { + headers: { "X-Server-Function-Format": "8" } + }) + : next(address, init); + }); + // the app's fetch configured AFTER the layer: neither replaces the other + configureServerFunctionsClient({ + fetch(address, init) { + order.push("app"); + return deliver(address, init); + } + }); + try { + expect(await createServerReference("ext-fetch-9")("live")).toBe("live"); + expect(order).toEqual(["layer", "app"]); + order.length = 0; + // what the layer answers itself never reaches the app's send + expect(await createServerReference("ext-fetch-cached")()).toBe("from layer"); + expect(order).toEqual(["layer"]); + } finally { + configureServerFunctionsClient({ fetch: null }); + } + }); + it("hands the fetch the (address, init) shape", async () => { registerServerFunction("ext-fetch-1", async () => "ok"); const shapes: string[] = []; From 7428b87e0e0d28f5aa7c9e2cdea70b6213b1e59e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:54:48 +0000 Subject: [PATCH 2/3] perf(web): leave the transport's direct send in place until a layer is added An unlayered app now ships no extra bytes: with wrapServerFunctionsFetch tree-shaken, the send variable is never assigned and minifies away. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NkjGmX4hAoiU9VhXNt7j3E --- packages/web/server-functions/src/client.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index f973a53001..5c69641a42 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -467,13 +467,14 @@ export function wrapServerFunctionsFetch( ): void; export function wrapServerFunctionsFetch(wrap) { - send = wrap(send); + send = wrap(send || ((address, init) => (config.fetch || fetch)(address, init))); } -// The app's send, read per call: `configureServerFunctionsClient({ fetch })` -// may run before or after a layer is added, and hydration swaps the global -// for one synchronous compute. -let send = (address, init) => (config.fetch || fetch)(address, init); +// The layered send, once an integration adds one. Its base reads the app's +// send per call: `configureServerFunctionsClient({ fetch })` may run before +// or after a layer is added, and hydration swaps the global for one +// synchronous compute. Unset (the common case) the transport sends directly. +let send; /** * Configures the client transport. Call once, before any server function is @@ -698,7 +699,7 @@ async function createRequest(base, id, options, meta) { // The send, as observed: the `"request"` record leaves from here — the // final address and init, immediately before `fetch` receives them. if (IS_OBSERVE && observation) observation.request(base, init); - return send(base, init); + return (send || config.fetch || fetch)(base, init); } async function initializeResponse(base, id, options, args, meta) { From 27b6f34f27de9613be774a38ff5891c53ddf096c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:17:03 +0000 Subject: [PATCH 3/3] refactor(web): a per-reference send instead of a global send layer wrapServerFunctionsFetch added a second, global transport hook. A reference can now declare its own send in its metadata instead (withMeta(fn, { fetch: (address, init, next) => ... })): the transport uses it for that function's requests with the app's configured send as next. The intent lives on the declaration, the app's fetch stays the app's, and there is nothing to order. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NkjGmX4hAoiU9VhXNt7j3E --- .changeset/reference-fetch-meta.md | 5 ++ .changeset/wrap-server-functions-fetch.md | 5 -- packages/web/server-functions/src/client.ts | 49 ++++--------------- packages/web/server-functions/src/shared.ts | 23 +++++++++ .../server-functions-extensions.spec.tsx | 42 ++++++++-------- 5 files changed, 59 insertions(+), 65 deletions(-) create mode 100644 .changeset/reference-fetch-meta.md delete mode 100644 .changeset/wrap-server-functions-fetch.md diff --git a/.changeset/reference-fetch-meta.md b/.changeset/reference-fetch-meta.md new file mode 100644 index 0000000000..ec691fd004 --- /dev/null +++ b/.changeset/reference-fetch-meta.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +A server function reference can declare its own client send with `withMeta(fn, { fetch: (address, init, next) => ... })`. The transport uses it for that function's requests, with the configured send (the app's `fetch`, or the global) as `next`, so an integration that answers some calls itself (a prerendered build's static artifacts) no longer competes with the app's `configureServerFunctionsClient({ fetch })`. Exported type `ServerFunctionsSend`. diff --git a/.changeset/wrap-server-functions-fetch.md b/.changeset/wrap-server-functions-fetch.md deleted file mode 100644 index ed824785e0..0000000000 --- a/.changeset/wrap-server-functions-fetch.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@solidjs/web": patch ---- - -Add `wrapServerFunctionsFetch(wrap)` to the server-functions client: an integration layers a send over the transport (answering some requests itself, passing the rest to `next`) without replacing the app's `configureServerFunctionsClient({ fetch })` or being replaced by it, whichever runs first. Exported type `ServerFunctionsSend`. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 5c69641a42..7e9e54a332 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -190,9 +190,10 @@ export interface ServerFunctionsClientConfig { codec?: JSONCodecOptions; /** * Sends every server-function request — retries, telemetry, a test - * double, or an app's own route. Always called as `(address, init)`, the + * double, or an app's own route. Called as `(address, init)`, the * address relative to the document as the global one receives it, so - * `parseServerFunctionActionUrl` reads the id back out for telemetry. `null` + * `parseServerFunctionActionUrl` reads the id back out for telemetry (a + * trailing third argument is the transport's own; ignore it). `null` * restores the global. * * ```ts @@ -440,43 +441,6 @@ function serializeArguments(args) { } return config.serializeArgs(args); } /** - * Layers a send over the transport for an INTEGRATION — something that - * reroutes some requests (a static artifact, a cache) and hands the rest on. - * `wrap` receives `next`, the send it wraps, and returns the send to use. - * Unlike `configureServerFunctionsClient({ fetch })`, which belongs to the - * app, layers never replace each other or the app's `fetch`: `next` reads - * the configured `fetch` (or the global) at call time, so the order of the - * app's configuration and an integration's setup does not matter. The - * latest layer is outermost. Layers are permanent: install one once, at - * startup. - * - * ```ts - * wrapServerFunctionsFetch(next => (address, init) => - * isMine(address) ? answerFromCache(address) : next(address, init) - * ); - * ``` - * - * The contract for the send is `fetch`'s (see - * `ServerFunctionsClientConfig.fetch`): forward `init`, hand back what the - * peer answered, unread. Hydration adopts a server-rendered read only if - * its send reaches the global `fetch` in the same tick, so a layer that - * answers a read itself should fetch synchronously too. - */ -export function wrapServerFunctionsFetch( - wrap: (next: ServerFunctionsSend) => ServerFunctionsSend -): void; - -export function wrapServerFunctionsFetch(wrap) { - send = wrap(send || ((address, init) => (config.fetch || fetch)(address, init))); -} - -// The layered send, once an integration adds one. Its base reads the app's -// send per call: `configureServerFunctionsClient({ fetch })` may run before -// or after a layer is added, and hydration swaps the global for one -// synchronous compute. Unset (the common case) the transport sends directly. -let send; - -/** * 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 @@ -699,7 +663,12 @@ async function createRequest(base, id, options, meta) { // The send, as observed: the `"request"` record leaves from here — the // final address and init, immediately before `fetch` receives them. if (IS_OBSERVE && observation) observation.request(base, init); - return (send || config.fetch || fetch)(base, init); + // A reference may declare its own send (`withMeta(fn, { fetch })`): it + // receives the app's as `next`, so the app's transport policy still + // applies to whatever it passes on. (The app's send ignores the extra + // argument, as `fetch` does.) + const send = config.fetch || fetch; + return (meta.fetch || send)(base, init, send); } async function initializeResponse(base, id, options, args, meta) { diff --git a/packages/web/server-functions/src/shared.ts b/packages/web/server-functions/src/shared.ts index 53b1bcd743..7234c5835b 100644 --- a/packages/web/server-functions/src/shared.ts +++ b/packages/web/server-functions/src/shared.ts @@ -182,6 +182,29 @@ export interface ServerFunctionMetadata { * `withMeta` write wins. */ readonly name?: string; + /** + * This function's own send, client side: the transport calls it instead + * of the configured `fetch` for every request the function makes, with + * the configured send (the app's `fetch`, or the global) as `next`. For + * an integration that answers a declared function's calls itself — a + * prerendered build's static artifacts, a cache — while the app's + * transport `fetch` stays the app's: pass on what you don't answer + * through `next`. The contract is `fetch`'s (see + * `ServerFunctionsClientConfig.fetch`); hydration adopts a + * server-rendered read only if the send reaches the global `fetch` in the + * same tick. + * + * ```ts + * withMeta(getPosts, { + * fetch: (address, init, next) => cached(address) ?? next(address, init) + * }); + * ``` + */ + readonly fetch?: ( + address: string, + init: RequestInit, + next: (address: string, init: RequestInit) => Response | Promise + ) => Response | Promise; /** User-declared transport metadata attached with `withMeta`. */ readonly [key: string]: unknown; } diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index 0b5e5da7c3..6da62ee623 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -30,8 +30,7 @@ import { createServerReference, getServerFunctionMetadata, isServerFunction, - withMeta, - wrapServerFunctionsFetch + withMeta } from "@solidjs/web/server-functions/client"; import type { PrepareRequestHook } from "@solidjs/web/server-functions/client"; @@ -223,22 +222,20 @@ describe("server-function extension surface (built bundles)", () => { } }); - it("layers an integration's send over the app's fetch, whichever is configured first", async () => { + it("sends a reference's calls through its declared fetch, with the app's as next", async () => { registerServerFunction("ext-fetch-9", async (word: string) => word); const order: string[] = []; - // layers are permanent: this one only answers its own address and - // passes everything else on, as an integration's must - wrapServerFunctionsFetch(next => (address, init) => { - if (!address.includes("ext-fetch-9") && !address.includes("ext-fetch-cached")) - return next(address, init); - order.push("layer"); - return address.includes("cached") - ? new Response(JSON.stringify("from layer"), { - headers: { "X-Server-Function-Format": "8" } - }) - : next(address, init); + // the reference's own send, declared before the app configures its fetch + const ref = withMeta(createServerReference("ext-fetch-9"), { + fetch: (address: string, init: RequestInit, next: (a: string, i: RequestInit) => any) => { + order.push("own"); + return init.body === undefined || String(init.body).includes("cached") + ? new Response(JSON.stringify("from own send"), { + headers: { "X-Server-Function-Format": "8" } + }) + : next(address, init); + } }); - // the app's fetch configured AFTER the layer: neither replaces the other configureServerFunctionsClient({ fetch(address, init) { order.push("app"); @@ -246,12 +243,17 @@ describe("server-function extension surface (built bundles)", () => { } }); try { - expect(await createServerReference("ext-fetch-9")("live")).toBe("live"); - expect(order).toEqual(["layer", "app"]); + expect(await ref("live")).toBe("live"); + expect(order).toEqual(["own", "app"]); + order.length = 0; + // what the reference's send answers never reaches the app's + expect(await ref("cached")).toBe("from own send"); + expect(order).toEqual(["own"]); order.length = 0; - // what the layer answers itself never reaches the app's send - expect(await createServerReference("ext-fetch-cached")()).toBe("from layer"); - expect(order).toEqual(["layer"]); + // other references are untouched + registerServerFunction("ext-fetch-10", async () => "plain"); + expect(await createServerReference("ext-fetch-10")()).toBe("plain"); + expect(order).toEqual(["app"]); } finally { configureServerFunctionsClient({ fetch: null }); }