Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)
, '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

Commit 9522945

Browse files
feat(web): give scripted server-function calls their own data address (#3094)
One url served two answer shapes — codec encodings for the client transport (keyed on the instance header), plain HTTP for everyone else — and shared caches key on the url alone, so one caller kind's cached answer could be replayed to the other. Scripted calls now go to <endpoint>/data/<id>; the bare <endpoint>/<id> stays plain HTTP (a reference's .url, rendered form actions, direct callers). The shape is a function of the url, never a header. Transitional: the instance header still summons the scripted shape at the bare address so loaded tabs survive a deploy, with those answers forced no-store. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1a95943 commit 9522945

8 files changed

Lines changed: 254 additions & 52 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": minor
3+
---
4+
5+
Scripted server-function calls now go to their own data address, `<endpoint>/data/<id>`, leaving the bare `<endpoint>/<id>` address to plain HTTP (#3094). The two caller kinds get differently shaped answers — codec encodings for the client transport, verbatim responses / form-convention handling for everyone else — and shared caches key on the URL, so a header-driven shape meant one caller kind's cached answer could be replayed to the other (a `GET`-declared function returning a raw `Response` with a public cache policy could serve its codec encoding to a browser navigation, or its raw body to the app's own transport). The answer's shape is now a function of the URL alone. A reference's `.url` and rendered action urls stay on the bare address; reconstructed callables splice the `data` segment in ahead of the id for their own calls. Transitional: the instance header still summons the scripted shape at the bare address so already-loaded tabs survive a server deploy, with those answers forced `no-store`.

‎documentation/solid-2.0/10-server-functions.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ One architectural fact worth stating, because the two directive levels land on o
4444

4545
The package resolves to a client entry in the browser and a server entry elsewhere.
4646

47-
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s address — `<endpoint>/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
47+
**Client:** `configureServerFunctionsClient({ endpoint?, codec?, fetch?, prepareRequest?, serializeArgs?, responseHandler? })` — call once in the client entry, only when deviating from the defaults (endpoint defaults to `/_server`; `codec` takes seroval plugin options and must match the server’s; `fetch` replaces the function the transport sends with — always called as `(address, init)` — for concerns the runtime has no opinion about: retries, telemetry, a test double, or pointing calls at a route of the app’s own, which the handler serves through the same `Request` it serves everything else with; `prepareRequest` is the transport middleware hook below; `responseHandler` is the integration seam server components install — see [RFC 11](11-server-components.md)). Compiled client output produces callables that POST to the call’s **data address** — `<endpoint>/data/<id>`, the id in a path segment — with a per-call `X-Server-Function-Instance` id in the headers. The data address is the scripted transport’s own path, where answers are the codec’s; the bare `<endpoint>/<id>` address (a reference’s `.url`, what renders into form actions) answers plain HTTP. Two paths because the two caller kinds get differently shaped answers and shared caches key on the URL: with one shape per path, a cached answer can only ever be replayed to the caller kind it was made for. **Argument encoding (updated since first draft):** arguments with a natural HTTP encoding (a lone string, FormData, File, Blob, ...) go as-is; everything else is sent as **plain JSON by default** — no serializer in the client bundle — and values JSON can’t carry faithfully (Dates, Maps, Sets, typed arrays, cycles) **throw with a directed message** unless you opt in once via `enableRichArguments()` from `@solidjs/web/server-functions/rich-args`, which installs the codec’s write half (~5 KB gz) as `serializeArgs` — importing the entry is the opt-in at the module-graph level, so the serializer ships only when the app asks for it. _Results_ are unaffected — they always travel through the codec, whose decode half the client carries regardless. Async returns (promises, streams) settle over the open connection via length-prefixed chunk framing. (A `@solidjs/web/serialization` subpath exists; most of it is integration-facing plumbing — the bridge exposing the runtime’s serializer machinery for the runtime’s own entries and for integrations building transports — exempt from the 2.0 stability guarantee and subject to change. The one application-facing part is plugin _authoring_: `createPlugin` and `OpaqueReference` are re-exported there from the runtime’s own seroval instance, and custom plugins for the `codec` option must be built from that import — a plugin built against your own `seroval` dependency edge would not fail the build, it would emit nodes the other end of the wire can’t interpret (the version-pinning lesson of solid-start #1474). Application and router code authors plugins there and feeds them to `codec`; everything else on the subpath it should leave alone.)
4848

4949
**Server:**`configureServerFunctionsServer({ endpoint?, codec?, provideEvent?, wrapInvocation?, collectFlightData?, transformResult?, transformDirectResult? })` plus the web-standard HTTP handler:
5050

@@ -118,7 +118,7 @@ The protocol folds integration data (typically revalidated route data) into a mu
118118

119119
### No-JS and progressive enhancement
120120

121-
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies. `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The absence of the `X-Server-Function-Instance` header marks an unscripted call (a form submit or direct HTTP); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
121+
A reference’s `.url` serves as a form `action`, and action urls are **self-describing** (`<endpoint>/<id>?args=...`): an integration can reconstruct a callable from a server-rendered action url alone, with bound arguments kept in the query string where the server reads them for natural-encoding bodies (the callable’s own calls go to the rendered address’s data-address sibling — same mount, same query). `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` are the two halves of that scheme for integrations composing action urls the runtime did not render. The bare address marks an unscripted call (a form submit or direct HTTP) — the shape of the answer is the address’s, never a header’s (the `X-Server-Function-Instance` header still signals scripted-ness at the bare address as a transitional courtesy to pre-split clients, with the answer forced `no-store`); arguments are parsed from the query string or FormData by content-type sniffing — a no-JS form post decodes to a lone `FormData` argument, and a read whose query is not an argument encoding hands that query over as a lone `URLSearchParams`, which is what a `method="get"` form submits (the browser replaces the action url’s query with its fields, so only an address in the path survives one). Which reading applies is decided by the url alone, never by a header, so a cache cannot be made to store one reading and serve it for the other; `args` is reserved on the query, and a value under it that is not an argument array answers 400. What a GET submit renders is the function’s to shape — the no-JS redirect convention is a form-post one. The `handleNoJS` handler hook builds the response for these calls (default: the normal serialized response).
122122

123123
The full unscripted flow (flash cookie → redirect → SSR-seeded submission state) has a settled ownership chain:
124124

@@ -151,7 +151,7 @@ export const getUser = GET(async (id: string) => {
151151
});
152152
```
153153

154-
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
154+
Calls go over HTTP GET with arguments codec-encoded in the query string of the call’s data address — cacheable by HTTP infrastructure (the varying instance header doesn’t break caching; caches key on URL unless `Vary` says otherwise, and the data address serves the codec shape to every caller, so what a cache stores there is right for anyone who reads it). Arguments too long for a url dispatch over POST instead, which costs the cache entry rather than meeting whichever proxy in the chain draws the line at a 414. Cache headers flow through the handler’s existing header forwarding: `respond(data, { headers: { "cache-control": "max-age=60" } })`. Server-side, the wrapper is identity-flavored — SSR calls stay in-process. Because function-level directives round-trip wrapper calls (above), this needs **no compiler involvement**.
155155

156156
Under the sugar sits a symbol-branded metadata channel (`Symbol.for`, surviving duplicated module instances — the same trick as the `ResponseEnvelope` brand), populated on both proxies and read through typed accessors. `withMeta(fn, meta)` is its public write path — it exists because `prepareRequest`’s `meta` parameter was otherwise unreachable for user declarations — and `GET` is sugar over the same write:
157157

‎packages/web/server-functions/src/client.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
parseServerFunctionAddress,
2525
provideServerFunctionRPC,
2626
serverFunctionAddress,
27+
serverFunctionDataAddress,
2728
withMeta
2829
}from"./shared.js";
2930

@@ -305,10 +306,11 @@ export function parseServerFunctionUrl(url: string): string | null;
305306

306307
/** Reads the function id back out of a server-rendered action url. */
307308
exportfunctionparseServerFunctionUrl(url){
308-
returnparseServerFunctionAddress(
309+
constparsed=parseServerFunctionAddress(
309310
newURL(url,globalThis.location?.href||"http://localhost").pathname,
310311
config.endpoint
311312
);
313+
returnparsed&&parsed.id;
312314
}
313315

314316
functionserializeArguments(args){
@@ -389,6 +391,20 @@ function provideRPC() {
389391
provideServerFunctionRPC({GET, decodeResponse });
390392
}
391393

394+
// A reconstructed callable's base is a rendered PLAIN-HTTP address
395+
// (`/_server/<id>?args=...`) — what a form posts to without the runtime.
396+
// The transport's own calls belong at the data address, where answers are
397+
// the codec's (#3094), so the data segment is spliced in ahead of the id;
398+
// mount, origin and the query (bound arguments) ride along untouched.
399+
functiondataAddressFor(base){
400+
constsplitAt=base.search(/[?#]/);
401+
constpath=splitAt<0 ? base : base.slice(0,splitAt);
402+
constrest=splitAt<0 ? "" : base.slice(splitAt);
403+
constslash=path.lastIndexOf("/");
404+
if(path.endsWith("/data/",slash+1))returnbase;// already one
405+
return`${path.slice(0,slash+1)}data/${path.slice(slash+1)}${rest}`;
406+
}
407+
392408
functionserverFunctionFailure(response,value){
393409
consterror=value??newError(`Server function call failed with status ${response.status}`);
394410
// Stamp the HTTP status so policy layers (live retry loops, router
@@ -681,12 +697,14 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
681697
* metadata channel; never emitted in production). Not meant for
682698
* hand-written code.
683699
*
684-
* The optional `base` targets calls at that url verbatim instead of the
685-
* configured endpoint — for integrations reconstructing a callable from a
700+
* The optional `base` roots calls at that url instead of the configured
701+
* endpoint — for integrations reconstructing a callable from a
686702
* server-rendered action url (e.g. a router intercepting a form submit whose
687703
* `action="/_server/<id>?args=..."` came off the wire): bound arguments
688704
* stay in the query string, where the server reads them for natural-encoding
689-
* bodies (FormData, urlencoded).
705+
* bodies (FormData, urlencoded). The rendered url is the plain-HTTP address;
706+
* the callable's own calls are scripted, so they go to its data-address
707+
* sibling (`/_server/data/<id>?args=...`) — same mount, same query.
690708
* @internal
691709
*/
692710
exportfunctioncreateServerReference(id: string,name?: string,base?: string): ServerFunction;
@@ -704,11 +722,13 @@ export function createServerReference(id: string, name?: string, base?: string):
704722
exportfunctioncreateServerReference(id,name,base){
705723
provideRPC();
706724
constmetadata=name===undefined ? {} : { name };
707-
// An explicit base targets that url verbatim — integrations reconstructing
725+
// An explicit base roots calls at that url — integrations reconstructing
708726
// a callable from a server-rendered action url (`/_server/<id>?args=...`) keep
709727
// its bound arguments in the query string, where the server reads them
710-
// for natural-encoding bodies. Default calls derive from the configured
711-
// endpoint (lazily — it may be configured after module scope runs).
728+
// for natural-encoding bodies; the call itself goes to the rendered
729+
// address's data-address sibling (see dataAddressFor). Default calls
730+
// derive from the configured endpoint (lazily — it may be configured
731+
// after module scope runs).
712732
// One body for both entrances — `fn(...args)` and `invoke(fn, args,
713733
// options)`: the invocation channel IS the call path with the per-call
714734
// options slot exposed, so the two can never drift.
@@ -724,7 +744,7 @@ export function createServerReference(id, name, base) {
724744
if(hit!==undefined)returnhit;
725745
}
726746
returnfetchServerFunction(
727-
base||serverFunctionAddress(config.endpoint,id),
747+
base? dataAddressFor(base) : serverFunctionDataAddress(config.endpoint,id),
728748
id,
729749
invokeOptions ? { ...invokeOptions} : {},
730750
args,
@@ -812,7 +832,7 @@ export function GET(fn) {
812832
if(hit!==undefined)returnhit;
813833
}
814834
constopts=invokeOptions||{};
815-
constaddress=serverFunctionAddress(config.endpoint,id);
835+
constaddress=serverFunctionDataAddress(config.endpoint,id);
816836
if(!args.length){
817837
returnfetchServerFunction(address,id,{ ...opts,method: "GET"},[],metadata,args);
818838
}

0 commit comments

Comments
 (0)