feat(web): let an app supply the fetch server function calls go through - #3080
feat(web): let an app supply the fetch server function calls go through#3080frenzzy wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: d095e43 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merging this PR will regress 1 benchmark
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ❌ | merge |
71.4 µs | 138 µs | -48.26% |
| ⚡ | omit |
319.6 µs | 235.1 µs | +35.91% |
| ⚡ | merge |
363 µs | 267.6 µs | +35.66% |
| ⚡ | merge |
303.5 µs | 225 µs | +34.86% |
| ⚡ | merge |
346.1 µs | 262.5 µs | +31.85% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing frenzzy:sf-client-fetch (d095e43) with next (12fa729)
Footnotes
-
132 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
The transport sends every call with the global `fetch`.
`configureServerFunctionsClient({ fetch })` replaces it, for the 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 the reason this is worth a seam. A url shaped for a cache — an
argument in a path segment, a tenant in a prefix — cannot be a second built-in
address without teaching it to 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 only thing missing was the client's side of it.
The seam is always 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
still see the request the transport dispatched. `null` restores the global,
which is what the option's own tests reset with.
Also tidies the `endpoint` docs on both entries, which the path-addressing
change left saying the same thing twice.
c1e8b68 to
d095e43
Compare
Follow-up to #3076, and the userland half of the addressing question from #3072.
configureServerFunctionsClient({ fetch })replaces the function the transport sends every server-function request with.Why a seam rather than an option
An app that wants a url shaped for its cache — an argument in a path segment, a tenant in a prefix — cannot get one from a second built-in address format, because a second format has to be taught to every gate that recognises a call: the runtime, the plugin's dev middleware, the generated
dispatchRequestgate, the router's action-url interception. solidjs/solid-router#590 and solidjs/solid-vite-plugin#332 are what one address change costs downstream; a configurable one would cost that permanently.It doesn't have to be a second address. The handler takes a web
Request, so an app route that rewrites into the canonical address dispatches like any other call:That already works on
next. The missing half was the client's: a scripted call had no way to reach that route. This adds it, and nothing downstream learns anything.The shape
Always
(address, init)— the address relative to the document, as the globalfetchreceives it on this path, so an ordinary wrapper drops in andparseServerFunctionUrlreads the id back out of it for telemetry.nullrestores the global.One behaviour change fell out of review: the observed path (
observeServerFunctionCalls, i.e. devtools) used to hand the globalfetchaRequestrather than(base, init). Leaving that difference in place would mean a wrapper written against the documented shape — normalising the address, rewriting the path — works in production and silently misroutes the moment anyone attaches an observer, resolving the call toundefinedrather than erroring. The seam now gets one shape on both paths; observers still see the request the transport dispatched.The option's doc states what a wrapper owes the transport: forward
init.signal(a live source'sbreakaborts through it), return theResponseunread (the transport clones it), and keep the call same-origin — a cross-origin send is stampedSec-Fetch-Site: cross-site, which the handler's origin gate refuses before it consults a configuredorigin.Verification
pnpm testat the root: 32/32 turbo tasks green. Four tests inserver-functions-extensions.spec.tsx, next toprepareRequest's: an app-shaped url rewritten back into the canonical address end to end, the same shape with and without observers attached, that the initprepareRequestproduced reaches the seam, and thatnullrestores the global. Confirmed by hand that every dispatch path goes through it — plain POST,GET-declared reads, the long-url POST fallback, andlive()including reconnect.Known sharp edge, not introduced here
A response the runtime did not produce resolves the call to
undefinedrather than throwing: the transport treats only its own error header or a 5xx as failure (client.ts). A mis-mounted app route, an auth interstitial or a CDN block therefore arrive as a successfulundefined. This predates the seam, but the seam makes foreign responses reachable, so it is worth its own issue — happy to file it.Also here
The
endpointdocumentation on both entries, which #3076 left saying the same thing twice with prose after@default.