Replies: 3 comments
|
A follow-up on the addressing part, because I think the version in the first post stops half way. Moving only the id to the path doesn't fix the case that motivated it. On a zone that drops
|
| arguments | ?args= (percent-encoded) |
path segment (base64url) |
|---|---|---|
[10] |
8 | 6 |
["solid",2] |
21 | 15 |
[{"page":1,"tags":["a/b"]}] |
59 | 36 |
Why one opaque segment rather than a readable segment per argument. Anything readable has to
survive the path, and the path is the most aggressively rewritten part of a URL: Envoy normalizes
it, nginx merges slashes, several CDNs decode %2F before matching — so a string argument
containing / cannot be carried as %2F reliably. A visible . in a segment is worse: it can
flip a CDN's extension-based caching rules (report.pdf as an argument getting the static-asset
TTL). The base64url alphabet (A–Z a–z 0–9 - _) produces none of %, /, .. Per-argument
segments would also need their own encoding for arity, holes, undefined and objects, and they
buy nothing for the cache key, which is the whole path either way. (One caveat worth writing down:
a zone configured for a case-insensitive cache key would corrupt base64url, and base32 —
~1.6× instead of ~1.33× — is the safe alternative there.)
What this buys beyond the cache key
The query string stops being ours. Path carries what the machine says — function id and
encoded arguments. Query carries what a human or a form says — named fields. Nothing overlaps,
which retires the args reserved name and with it the collision tiebreak I listed under "details
worth settling": a no-JS GET form's field named args can't collide with the runtime's encoding,
because the runtime no longer reads the query at all. The two callers stop sharing a namespace,
and the method="get" form convention (whole query as one URLSearchParams argument) becomes
unambiguous instead of heuristic.
X-Server-Function-Id stops being an addressing input, which closes #3070 by construction
rather than by rule — there is no second source of truth to disagree with the URL. Today the
header is written in exactly one place (client.ts:320) and read in exactly one
(server.ts:844-850), so removing it as an id source is contained.
Bound arguments stop needing the query too. action.with(id) currently appends ?args= to
the action url and puts the trailing FormData in the body (client.ts:411-442); those leading
arguments fit the same path segment, so a POST form action becomes /_server/<id>/<bound-args>
with the fields as the body — the same shape as today, minus the query.
Layering
This stays a transport mechanism, not policy: no router involvement, no compiler involvement (the
same reason GET needs none), and it adds no caching layer of its own — it makes the HTTP one
work. Which is the argument from the article itself: if the point of GET is 30 years of web
infrastructure, then the address has to be the shape that infrastructure actually keys on.
Cost
Roughly a hundred lines. The client builds the address in three places
(client.ts:605, 674, 688) and the transport already takes a fully-built base string, so live
(client.ts:826) inherits it for free and frameAddress is unaffected (it's a logical region
address, not a URL). The server reads it in two (resolveFunctionId, parseArguments). The one
piece worth adding is a shared address codec used by both halves — which also retires an existing
hazard, since endpoint today has to be configured identically on client and server by hand.
I'd argue for a single canonical shape rather than a "path" | "query" option: two shapes mean
two cache keys for the same call and two parse paths, and this branch has been explicit elsewhere
about no compatibility shims during the prerelease. If someone genuinely needs a different
address, that's the builder seam, not a second built-in.
Happy to put this up as a PR if the direction sounds right.
|
Agreed with the direction, with one split: the id belongs in the path, the args should stay in the query. Id in path — yes. Args in query — deliberately. Standards-compliant caches and every major CDN default already include the query string in the cache key, so We're aware addressing is the kind of thing you'd rather break once — if there's a compelling case that args must be path-based, now is the time to make it. But "a misconfigured cache might ignore the query" hasn't cleared that bar for us. Notes for whoever PRs this:
Groundwork already landed on |
|
Agreed — and the two downstream PRs are a better argument for the split than anything I wrote. #3076 is merged, solidjs/solid-router#590 and solidjs/solid-vite-plugin#332 adopt it, and between them they show where a call's address is actually known: the runtime, the plugin's dev middleware (which also pre-evaluates the owning module so the registration exists), the generated That settles the question I came in with. Argument placement should not be a knob in core. A second built-in shape would have to be taught to all four of those gates, and every integration downstream would have to handle both. One canonical shape is worth more than the flexibility. What I'd still like to leave in the thread is the userland answer, because it turns out to need none of that. An app that wants a cache-shaped url does not need a second server-function address — it needs an ordinary route that delegates. The handler takes a web // GET /api/stories/42/2
import { getStory } from "./stories"; // registers the function
import { serverFunctionUrl } from "@solidjs/web/server-functions";
export async function GET({ request }) {
const [, , , story, page] = new URL(request.url).pathname.split("/");
return handleServerFunctionRequest(
new Request(new URL(serverFunctionUrl(getStory.id, [story, Number(page)]), request.url), request)
);
}
The half that was missing is on the client. A scripted call had no way to reach that route. TanStack Start solves the same problem with the smallest possible shape: Solid's transport already funnelled through a single configureServerFunctionsClient({
fetch: (address, init) => fetch(rewriteForCache(address), init)
});Writing it turned up one thing worth flagging here. The observed path — the one To be explicit: not a second built-in address, and not a reason to revisit the default. An app that opts out owns both ends and the consequences — its own cache key, its own WAF story, and the fact that a rendered If the shape reads wrong, say so and I'll change it or close it — the server half already unblocks anyone who needs this today, so there's nothing urgent behind it. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Async Solid — One Graph, Two Machines puts the goal of the
GEThelper plainly:I've been reading the 2.0 server-function runtime with exactly that sentence in mind, and I
think the addressing deserves a second look, because it decides how much of that
infrastructure is actually reachable. Today the identity of a call lives in the query string —
/_server?id=…&args=…(client.ts:674-692) withX-Server-Function-Idas the authoritativesource (
server.ts:844-850). Caches, proxies, WAFs, service workers, access logs and HTMLforms all key on the path; a query-only address is the one shape most of that
infrastructure either ignores or normalizes away.
Everything below is verified against the published
@solidjs/web@2.0.0-rc.3; line referencesare
next@f5939ef. Three concrete defects came out of the same reading and are filedseparately: HEAD bypassing the method gate (#3069), the id header overriding the URL
(#3070), and the cache headers on responses (#3071). This post is about the
part that isn't a bug — the default shape.
An HTML GET form cannot address a server function at all
Per the form submission algorithm, a
method="get"submit discards the action URL's queryand replaces it with the serialized form data ("mutate action URL":
url.query = <form data>).So
<form method="get" action={fn.url}>ships/_server?<fields>, the id is gone, and:The only workaround is a hidden
<input name="id">, sitting in the same namespace as theuser's own fields. And even then the fields aren't arguments —
parseArguments(
server.ts:852-883) reads exactly one name,args, and expects the whole argument array asone encoded string:
So a no-JS GET form can only ever send arguments hardcoded into a hidden field. Meanwhile the
POST side of progressive enhancement is fully designed:
fn.urlsurvives (POST keeps theaction query), the fields arrive as a single
FormDataargument, andcreateNoJSHandlerredirects back with a flash cookie (
server.ts:1103-1146). GET has no equivalent —isFormPostis POST-only by construction (server.ts:1158), so a GET form navigation lands on/_server?…displaying raw JSON.What else the path buys, including for POST
routing one heavy function to a different upstream — Envoy, nginx, CDN rules and API gateways
all express this as a path prefix; query matching is second-class or absent. Today every call
in an app is one route,
POST /_server. This part matters for POST as much as GET.http.routeis/_serverfor everything, and the discriminator sits in aheader access logs don't record by default. Per-function latency and error rate — the first
thing anyone asks of an RPC layer — needs custom parsing today.
registerRoute(/^\/_server\//)is trivial; matching a query parameteris not. And
caches.match(request, { ignoreSearch: true })collapses every call into one entry.path patterns are what that tooling is written against.
IgnoreQueryStrings, CloudFront's legacy "forward query strings: none") gives every serverfunction in the app the same cache key. With the id in the path, a misconfigured zone is
still wrong about arguments, but the blast radius is one function instead of "any function's
body may be served in place of any other". A default should degrade into a stale answer, not
into another feature's data.
%5B%22, brackets,quotes) trips common ModSecurity/CRS injection rules; plain named parameters don't.
Proposal
Address every call at
/_server/<encodeURIComponent(id)>, GET and POST alike..urlstays self-describing (
/_server/<id>?args=…), so an integration can still reconstruct acallable from a server-rendered action url. Ids are path-safe already (
<hash>-<count>,<hash>-<count>-<name>in dev).No-JS GET forms: for an unscripted call (no
X-Server-Function-Instance) with nodecodable
args, pass the whole query string as a singleURLSearchParamsargument — theexact mirror of the no-JS POST convention, where the body decodes to a lone
FormData. Thenthis works with no hidden fields and no client runtime:
Keep
?args=as the canonical encoding for scripted calls. A field literally namedargsis the only collision; treating
argsas the argument array only when it decodes as one(codec frame or JSON array) is a robust tiebreak.
Things worth settling alongside
JSON.stringify(args)(client.ts:678) preserves object key insertionorder, so the same logical call from two call sites can produce two cache entries. Sorting
keys when encoding for a URL makes the cache key canonical.
argument becomes a 414 somewhere in the stack (nginx ~8k request line, IIS 2048 query, CDNs
8–16k). A documented cap with a directed error beats an infrastructure-dependent failure.
ETag/If-None-Matchstory and no defined transport behaviorfor a 304 — revalidation is a large part of why GET is worth being on.
a
GET(fn)with side effects will be called more than once. Worth stating next toGET.Referer, CDN and proxy access logs, and browserhistory — inherent to any GET encoding, but it deserves a note.
Alternatives I considered
idandargsin the cache key). Works, butit's per-zone configuration whose failure mode is serving another function's response.
Correctness that depends on every intermediary being configured correctly isn't a default.
?arg1=&arg2=) as the scripted encoding. Loses types, has noencoding for
undefined/holes/objects/cycles, collides with user-chosen field names in theno-JS case, and still needs the codec for anything non-primitive. One canonical
argsforscripted calls plus the whole query for unscripted ones matches how the two callers differ.
endpointaccepting a builder plusresolveFunctionId/parseArgumentsoptions on the handler. Both halves already exist asprivate functions (
client.ts:605,674,688,server.ts:844,852), andprepareRequestdeliberately never sees the URL (
client.ts:339-353), so today addressing can't be changedwithout patching the package. Useful as an escape hatch, but most apps run on whatever core
ships — which is the argument for fixing the default first.
Happy to put up a PR for any part of this, including the three bugs above, which are
independent of the addressing question and could land first.
All reactions