Skip to content

feat(web): let an app supply the fetch server function calls go through - #3080

Open
frenzzy wants to merge 1 commit into
solidjs:nextfrom
frenzzy:sf-client-fetch
Open

feat(web): let an app supply the fetch server function calls go through#3080
frenzzy wants to merge 1 commit into
solidjs:nextfrom
frenzzy:sf-client-fetch

Conversation

@frenzzy

@frenzzy frenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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 dispatchRequest gate, 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:

// GET /api/stories/42/2
export async function GET({ request }) {
  const [, , , story, page] = new URL(request.url).pathname.split("/");
  return handleServerFunctionRequest(
    new Request(new URL(serverFunctionUrl(getStory.id, [story, Number(page)]), request.url), request)
  );
}

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 global fetch receives it on this path, so an ordinary wrapper drops in and parseServerFunctionUrl reads the id back out of it for telemetry. null restores the global.

One behaviour change fell out of review: the observed path (observeServerFunctionCalls, i.e. devtools) used to hand the global fetch a Request rather 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 to undefined rather 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's break aborts through it), return the Response unread (the transport clones it), and keep the call same-origin — a cross-origin send is stamped Sec-Fetch-Site: cross-site, which the handler's origin gate refuses before it consults a configured origin.

Verification

pnpm test at the root: 32/32 turbo tasks green. Four tests in server-functions-extensions.spec.tsx, next to prepareRequest's: an app-shaped url rewritten back into the canonical address end to end, the same shape with and without observers attached, that the init prepareRequest produced reaches the seam, and that null restores the global. Confirmed by hand that every dispatch path goes through it — plain POST, GET-declared reads, the long-url POST fallback, and live() including reconnect.

Known sharp edge, not introduced here

A response the runtime did not produce resolves the call to undefined rather 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 successful undefined. 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 endpoint documentation on both entries, which #3076 left saying the same thing twice with prose after @default.

@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d095e43

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@solidjs/web Patch
@solidjs/babel-plugin Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
solid-js Patch
@solidjs/compiler Patch
@solidjs/universal Patch
@solidjs/signals Patch
@solidjs/diagnostics Patch

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

@codspeed-hq

codspeed-hq Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 4 improved benchmarks
❌ 1 regressed benchmark
✅ 131 untouched benchmarks
⏩ 132 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

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)

Open in CodSpeed

Footnotes

  1. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant