feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(web): address server function calls by path - #3076

Merged
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing
Aug 27, 2026
Merged

feat(web): address server function calls by path#3076
ryansolid merged 2 commits into
solidjs:nextfrom
frenzzy:sf-path-addressing

Conversation

@frenzzy

@frenzzyfrenzzy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Implements the split settled in #3072: the id moves to the path, the arguments stay in the query.

The first revision of this PR put arguments in the path too. That's withdrawn — two of the objections landed. Bound arguments ride urls on POSTs as well, where path placement buys nothing and turns the url into a hybrid of path-baked arguments plus a body; and query-scrubbing is ubiquitous in log tooling while path-scrubbing isn't, which matters when arguments carry emails and search terms. The remaining case for path-baked arguments was resilience against a zone configured to ignore query strings, and with the id in the path that failure is already bounded to one function's arguments rather than serving another function's response — a config bug to document, not a reason to contort the scheme.

What changes

  • <endpoint>/<id> for GET and POST alike.fn.url is /_server/<id>; arguments stay in ?args=, unchanged in encoding.
  • X-Server-Function-Id is gone — from the wire and from the package's exports. With the id nowhere but the url, a cache in front of the app cannot be made to store one function's response under another's key, which closesX-Server-Function-Id overrides the id in the URL, making it an unkeyed input to a cacheable response #3070 by construction rather than by rule. X-Server-Function-Instance still marks a scripted call.
  • serverFunctionUrl(id, boundArgs?) / parseServerFunctionUrl(url) on both entries, resolved against the configured endpoint. This is your "export a URL builder" note: solid-router composes form-action urls with bound arguments for the no-JS path, and it currently recovers the id with new URL(url, mockBase).searchParams.get("id") (src/data/action.ts) — that line becomes parseServerFunctionUrl(url). It's the only coupling: the router passes the url on to createServerReference(id, undefined, url), which still targets a base verbatim.
  • URL length falls back to POST. A GET call whose encoded url would exceed 2000 characters dispatches over POST instead — declaring GET grants the read methods without revoking the default transport, so the call still runs, it just isn't cacheable. A cache miss, not a 414 from whichever proxy draws the line first.
  • method="get" forms work (second commit, droppable on its own). A GET submit replaces the action url's query with the form's fields — only an address in the path survives one — and those fields now reach the function as a lone URLSearchParams, the read-side mirror of a no-JS form post decoding to a lone FormData.

On your other two notes: argument encoding is left exactly as it was, since positional arguments are ordered by construction and need no canonicalization layer (the one residue is object key order inside an argument, which is a JSON property, not an addressing one); and deploy skew is treated as a docs matter, with no machinery added.

Shape of the change

188 lines of runtime across the three files, most of it comments. Both halves build and parse the address through one pair of functions in shared.ts (serverFunctionAddress / parseServerFunctionAddress), which also retires a standing hazard: endpoint had to be configured identically on client and server by hand, and there was no shared definition of what it produced. live inherits the address through fn.url; frameAddress is untouched (a logical region address, not a url). A path carrying more than the one segment the address gives meaning to answers 404 rather than matching on its prefix.

Which reading a url gets is decided by the url alone

Worth calling out because the first cut of the second commit got it wrong: the choice between "this query is an argument encoding" and "this query is a form's fields" keys on the url only, never on the instance header. Otherwise a GET-declared function with cache-control: public answers the same url with two different bodies depending on a header no cache keys on and no Vary names — a cross-site prefetch could force one variant into the entry every scripted client then reads, which is the axis #3070 is about. args stays reserved on the query, and a value under it that is not an argument array answers 400 for every caller of that url rather than spreading into whatever the encoding produced.

Verification

pnpm test at the root: 32/32 turbo tasks green, rebased on next @ 258c76a. @solidjs/web across its three configs: 609 + 322 + 146. The addressing spec is 20 cases against the built bundles: the GET and POST addresses, the POST fallback and its url-length boundary, that the fallback stays a read (no single-flight collection), bound arguments with a FormData body over the real no-JS post, the refusal to render bound arguments JSON cannot carry, the same-url-same-answer invariant, args degenerate encodings answering 400, the id header and ?id= no longer addressing anything, the query reading on GET and HEAD, building and parsing action urls from both entries, a mount path with a trailing slash, a request outside the mount, an id with characters the path must encode, and the 404s for a bare, trailing-slash, or over-long path. Existing specs that hand-built the old wire shape are updated — including server-functions-http-hygiene.spec.tsx from 258c76a — and RFC 10 is updated where it describes the format.

Two red checks (job, Run benchmarks) fail the same way on next itself at 258c76aFailed: @solidjs/element#test, a package this branch doesn't touch.

Known limits, stated rather than hidden

  • A method="get" submit reaches the function, but what the browser then renders is the raw serialized result: handleNoJS's redirect convention is a form-post one (isFormPost is POST-only), so a read that wants to render something should return a Response. Deliberate — redirecting a read would lose the thing the read produced.
  • A URLSearchParams argument has no encoding on the GET query path (isJSONSafe rejects it), so the shape the form convention produces cannot be sent by a scripted GET. The symmetric fix — a lone URLSearchParams argument becoming the query itself, which would make the scripted and unscripted urls identical — is a natural follow-up, left out to keep this a pure addressing change.
  • parseServerFunctionUrl reads the path only; a caller that cares whether a url is its own origin checks that itself.

Relationship to 258c76a

The method allowlist and cache hygiene need nothing from this change and vice versa; the rebase only touched that commit's new spec request builders. One interaction is deliberate: HEAD is a read that mirrors GET, so an unscripted HEAD gets the same lone URLSearchParams a GET would — a HEAD to a form url describes the GET response, arguments included.

@changeset-bot

changeset-botBot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3988224

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

This PR includes changesets to release 11 packages
NameType
@solidjs/webPatch
@solidjs/babel-pluginPatch
@solidjs/elementPatch
@solidjs/hPatch
@solidjs/htmlPatch
test-integrationPatch
solid-jsPatch
@solidjs/compilerPatch
@solidjs/universalPatch
@solidjs/signalsPatch
@solidjs/diagnosticsPatch

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

@frenzzy
frenzzyforce-pushed the sf-path-addressing branch 4 times, most recently from 575e36c to b76fc66CompareAugust 27, 2026 06:16
The function id travelled in `X-Server-Function-Id`, with `?id=` as the
fallback for requests the client runtime did not make. Both are gone; it moves
into the path — `<endpoint>/<id>` — which is what per-function policy keys on:
edge rules, cache policies by path pattern, rate limits, log aggregation,
`http.route` labels in traces.
It also leaves exactly one place in the request carrying the id, which closessolidjs#3070 by construction: with no header to override the url, a cache in front of
the app cannot be made to store one function's response under another's key.
POST addresses move too — no caching reason, the same legibility — and that is
what lets the header disappear entirely instead of surviving as the POST
addressing channel. `endpoint` gates dispatch on both halves as a result: a
request whose path does not start with it is not a call.
Arguments stay in the query, where caches key on them, log tooling scrubs
them, path normalization leaves them alone, and bound arguments already ride
for form posts. A value under `args` that is not an argument array answers 400
rather than spreading into whatever the encoding happened to produce. A GET
call whose url would exceed 2000 characters — measured on the wire, origin
included — dispatches over POST instead, marked as a read so it asks for no
single-flight collection: a cache miss rather than a 414 from whichever proxy
in the chain draws the line first.
`serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` export
the two halves of the scheme from both entries, so integrations composing
action urls build them through the runtime rather than hand-rolling the shape.
A `method="get"` form submit replaces the action url's query with the form's
fields — which is why only an address in the path survives one — so a read
whose query is not an argument encoding is carrying a form, not arguments.
That query now reaches the function as a lone `URLSearchParams`, the read-side
mirror of a no-JS form post decoding to a lone `FormData`:
<form method="get" action={search.url}>
<input name="q" />
</form>
<!-- GET /_server/abc-0?q=solid -->
Which reading applies is decided by the url alone, never by a header: a cache
keys on the url, so the same url has to mean the same call whether or not the
client runtime made it. HEAD reads it the same way as the GET it mirrors.
What a GET submit renders is the function's to shape — the no-JS redirect
convention is a form-post one, and a read has something to say.
@codspeed-hq

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

⚡ 3 improved benchmarks
❌ 1 regressed benchmark
✅ 127 untouched benchmarks
⏩ 131 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

BenchmarkBASEHEADEfficiency
merge70.7 µs138.2 µs-48.85%
merge173.7 µs73.7 µs×2.4
merge279.9 µs187.8 µs+49.04%
merge357.3 µs267.5 µs+33.55%

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-path-addressing (3988224) with next (e8c8947)

Open in CodSpeed

Footnotes

  1. 131 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.

@ryansolid
ryansolid merged commit 79b96cf into solidjs:nextAug 27, 2026
6 of 7 checks passed
ryansolid added a commit to solidjs/solid-vite-plugin that referenced this pull request Aug 28, 2026
rc.4 publishes the path-addressed server function calls this branch
routes (solidjs/solid#3076): the client computes `<endpoint>/<id>` and
the published server handler resolves the id from the path alone — the
X-Server-Function-Id header and `?id=` forms are gone from the runtime,
not just retired client-side. The workspace catalog moves to
^2.0.0-rc.4 (minimumReleaseAgeExclude extended per the existing
pattern), and the start-ssr suite's synthetic dispatches switch from
`?id=` to the path form, since the published handler no longer answers
the legacy shape. The plugin's own transitional fallbacks stay: they
serve runtimes older than the addressing change, which the peer range
still admits.
Full gate green against published rc.4 (no linking): ssr 12/12 +
boundary 8/8, css-matrix 82/82 + bridge 19/19, start-ssr 366/366 +
http-bridge 10/10, start-client 45/45, start-env 47/47, vite-8 vitest
1/1, cypress e2e 1/1.
Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit to solidjs/solid-router that referenced this pull request Aug 28, 2026
The dist-types check failed because the lockfile still resolved
@solidjs/web 2.0.0-rc.3, which predates parseServerFunctionUrl — the
runtime half of the addressing scheme this branch adopts. The peer and
dev floors now say what the code means: ^2.0.0-rc.4.
The vite plugin moves to 3.0.0-next.35 (the plugin half of
solidjs/solid#3076), which requires vite 8, which requires vitest 4 —
test-toolchain-only, but vitest 4 constructs `new FormData()` through
the mock's implementation, so the arrow-function stubs become real
functions (arrows aren't constructable), and one Mock type annotation
gains its signature.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for freeto 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.

2 participants

@frenzzy@ryansolid