diff --git a/.changeset/server-functions-client-fetch.md b/.changeset/server-functions-client-fetch.md new file mode 100644 index 000000000..ede5f0faa --- /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 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/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..de5f90aa6 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,26 @@ 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) + * }); + * ``` + * + * 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) => Response | 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 +229,7 @@ export interface ServerFunctionInvocation { const config = { endpoint: "/_server", + fetch: undefined, prepareRequest: undefined, responseHandler: undefined, serializeArgs: undefined @@ -298,7 +317,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 +327,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 +341,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; if (prepareRequest !== undefined) config.prepareRequest = prepareRequest; if (responseHandler !== undefined) config.responseHandler = responseHandler; if (serializeArgs !== undefined) config.serializeArgs = serializeArgs; @@ -398,11 +420,21 @@ 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 send(base, init); - const request = new Request(new URL(base, globalThis.location?.href || "http://localhost"), 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, 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 fetch(request); + const response = 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..927d12e58 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,143 @@ 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("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("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; + configureServerFunctionsClient({ + prepareRequest: init => ({ + ...init, + // a stream body needs `duplex`, which the DOM lib's RequestInit omits + body: new Blob(["streamed"]).stream(), + duplex: "half" + }), + fetch: (address, init) => send(address, init) + }); + const stop = observeServerFunctionCalls(() => {}); + try { + expect(await createServerReference("ext-fetch-7")("ignored")).toBe("streamed"); + } finally { + stop(); + configureServerFunctionsClient({ prepareRequest: null as any, 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[] = [];