Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/server-functions-client-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@solidjs/web": patch
---

Add `fetch` to `configureServerFunctionsClient`: the function the transport sends every server-function request with, always called as `(address, init)` — the address relative to the document, as the global one receives it — so an ordinary fetch wrapper drops in and `parseServerFunctionUrl` reads the id back out for telemetry. `null` restores the global.

The seam is for transport concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app's own. The last one is what makes an app-shaped url possible without a second address format in core: 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 new shape. A wrapper forwards `init.signal`, returns the `Response` unread, and keeps the call same-origin.

Also tidies the `endpoint` documentation on both entries, which the path-addressing change left saying the same thing twice.
2 changes: 1 addition & 1 deletion documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<endpoint>/<id>`, 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 — `<endpoint>/<id>`, 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:

Expand Down
58 changes: 47 additions & 11 deletions packages/web/server-functions/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -121,6 +119,33 @@ export interface ServerFunctionsClientConfig {
* `decodeResponse` sees them too.
*/
codec?: JSONCodecOptions;
/**
* Sends every server-function request. A drop-in `fetch`, always called
* as `(address, init)` — the address relative to the document, as the
* global one receives it on this path, so `parseServerFunctionUrl` reads
* the id back out of it for telemetry. `null` restores the global.
*
* The seam for transport concerns the runtime has no opinion about:
* retries, telemetry, a test double, or pointing calls at an app's own
* route — the handler takes a web `Request`, so a route that rewrites
* into the canonical address dispatches without the runtime knowing, and
* without any gate downstream learning a second address.
*
* ```ts
* configureServerFunctionsClient({
* fetch: (address, init) => fetch(rewrite(address), init)
* });
* ```
*
* What a wrapper owes the transport: forward `init.signal` (a live
* source's `break` aborts through it), return the `Response` unread (the
* transport clones it itself), and keep the call same-origin — a
* cross-origin send is stamped `Sec-Fetch-Site: cross-site`, which the
* handler's origin gate refuses. A foreign response reaching the
* transport is worse than an error: a 4xx that is not the runtime's own
* resolves the call to `undefined`.
*/
fetch?: typeof globalThis.fetch | null;
/**
* Runs before every server-function fetch. Return (or mutate and return)
* the RequestInit the transport will use; `context.meta` is the
Expand Down Expand Up @@ -211,6 +236,7 @@ export interface ServerFunctionInvocation {

const config = {
endpoint: "/_server",
fetch: undefined,
prepareRequest: undefined,
responseHandler: undefined,
serializeArgs: undefined
Expand Down Expand Up @@ -298,7 +324,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;

Expand All @@ -308,7 +334,10 @@ 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). `fetch` replaces the function
* the transport sends with, for concerns the runtime has no opinion about:
* retries, telemetry, a test double, or pointing calls at an app's own
* route.
*
* `responseHandler` is the response-side integration seam — the client
* mirror of the handler's `transformResult`. `handle(response, ctx)` sees
Expand All @@ -321,12 +350,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;
Expand Down Expand Up @@ -398,11 +429,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 send(base, init);

// Observers see the request the transport dispatched; the send keeps the
// `(address, init)` shape it has on the path without them, because whether
// devtools are attached is not something a configured `fetch` should have
// to branch on.
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 = await send(base, init);
notifyCallObservers("response", id, instance, response, meta);
return response;
}
Expand Down
12 changes: 5 additions & 7 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,14 +304,12 @@ export interface ServerFunctionsServerConfig {
) => Response | Promise<Response>)
| 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;
/**
Expand Down
101 changes: 101 additions & 0 deletions packages/web/test/server/server-functions-extensions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -189,6 +196,100 @@ 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 — so no gate
// downstream has to learn a second address.
configureServerFunctionsClient({
fetch(address, init) {
const app = new URL(address as string, "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 as string, init);
}
});
try {
expect(await createServerReference("ext-fetch-1")()).toBe("ok");
const stop = observeServerFunctionCalls(() => {});
try {
expect(await createServerReference("ext-fetch-1")()).toBe("ok");
} finally {
stop();
}
// devtools attaching must not change what a wrapper is handed
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<string, string>), "X-Prepared": "yes" }
}),
fetch: (address, init) => deliver(address as string, init)
});
try {
expect(await createServerReference("ext-fetch-2")()).toBe("yes");
} finally {
configureServerFunctionsClient({ prepareRequest: null as any, fetch: null });
}
});

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 as string, 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[] = [];
Expand Down
Loading