diff --git a/.changeset/reference-fetch-meta.md b/.changeset/reference-fetch-meta.md new file mode 100644 index 000000000..ec691fd00 --- /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/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index ef0146513..7e9e54a33 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 { /** @@ -184,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 @@ -210,7 +217,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 @@ -656,8 +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); + // 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 send(base, init); + 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 53b1bcd74..7234c5835 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 e2bc4528e..6da62ee62 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -222,6 +222,43 @@ describe("server-function extension surface (built bundles)", () => { } }); + 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[] = []; + // 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); + } + }); + configureServerFunctionsClient({ + fetch(address, init) { + order.push("app"); + return deliver(address, init); + } + }); + try { + 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; + // 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 }); + } + }); + it("hands the fetch the (address, init) shape", async () => { registerServerFunction("ext-fetch-1", async () => "ok"); const shapes: string[] = [];