Skip to content

Commit 575e36c

Browse files
committed
feat(web): hand an unscripted read its query as one URLSearchParams
A `method="get"` form submit replaces the action url's query with the form's fields — which is why only an address in the path survives one — so what arrives on an unscripted read is the form, not an argument encoding. Those fields now reach the function as a lone `URLSearchParams`, the read-side mirror of a no-JS form post decoding to a lone `FormData`: <form method="get" action={search.url}> <input name="q" /> </form> <!-- GET /_server/abc-0?q=solid --> HEAD reads it the same way, so a HEAD to a form url describes the GET response that url would produce, arguments included. The two readings of a query overlap in exactly one place: a bound action url followed as a plain link carries an `args` that decodes as an argument array, while a form field named `args` does not — so a decode failure falls through to the form reading, and only there. A scripted caller sent an encoding, so its failure stays a real one.
1 parent 5ec256c commit 575e36c

4 files changed

Lines changed: 91 additions & 8 deletions

File tree

.changeset/server-function-path-addressing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ The id used to travel in `X-Server-Function-Id`, with `?id=` as the fallback for
88

99
Arguments stay in the query. Standards-compliant caches and CDN defaults key on it, ordinary log tooling scrubs it, path normalization leaves it alone, and it is where bound arguments already ride for form posts — so a POST url does not become a hybrid of path-baked arguments plus a body.
1010

11+
The query string is also what makes `method="get"` forms work at all: a GET submit replaces the action url's query with the form's fields, so an address in the path is the only kind that survives it, and an unscripted read now hands its whole query to the function as a lone `URLSearchParams` — the read-side mirror of a no-JS form post decoding to a lone `FormData`.
12+
1113
`X-Server-Function-Id` is gone from the wire and from the package's exports; `X-Server-Function-Instance` still marks a scripted call, and `endpoint` keeps its meaning as the mount path. Both entries gain `serverFunctionUrl(id, boundArgs?)` and `parseServerFunctionUrl(url)` — the two halves of the addressing scheme, resolved against the configured endpoint — so integrations composing action urls (a router turning a bound action into a `<form action>` for the no-JS path) build them through the runtime instead of hand-rolling the shape.
1214

1315
A GET call whose encoded url would exceed 2000 characters dispatches over POST instead: declaring GET grants the read methods without revoking the default transport, so the call still runs, it just stops being cacheable — a cache miss rather than a 414 from whichever proxy in the chain draws the line first.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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. 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. `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 an unscripted read hands its whole 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). The two readings of a query overlap in one place: a bound action url followed as a plain link carries an `args` that decodes, a form field named `args` does not. 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

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

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -868,19 +868,39 @@ async function parseArguments(request, url, instance, codec) {
868868
// client stubs with bound arguments serialize the full argument array in
869869
// the body and never put arguments in the url.
870870
const bodyFormat = request.method === "POST" ? request.headers.get(BODY_FORMAT_HEADER) : null;
871+
// A read made without the client runtime carries the form's own parameters,
872+
// not an argument encoding: a `method="get"` submit replaces the action
873+
// url's query with its fields (which is why only an address in the path
874+
// survives one). They reach the function as a lone `URLSearchParams`, the
875+
// read-side mirror of a no-JS form post decoding to a lone `FormData`.
876+
const unscriptedRead = !instance && (request.method === "GET" || request.method === "HEAD");
877+
let decodedArguments = false;
871878
if (!instance || request.method === "GET" || bodyFormat !== BodyFormat.Serialized) {
872879
const args = url.searchParams.get("args");
873880
if (args) {
874-
// framed codec output (from the client runtime) or plain JSON (from
875-
// integrations building no-JS urls by hand)
876-
const result = args.startsWith(";0x")
877-
? await deserializeString(args, codec)
878-
: JSON.parse(args);
879-
for (const arg of result) {
880-
parsed.push(arg);
881+
try {
882+
// framed codec output (from the client runtime) or plain JSON (from
883+
// integrations building no-JS urls by hand)
884+
const result = args.startsWith(";0x")
885+
? await deserializeString(args, codec)
886+
: JSON.parse(args);
887+
for (const arg of result) {
888+
parsed.push(arg);
889+
}
890+
decodedArguments = true;
891+
} catch (error) {
892+
// The one place the two readings of a query overlap: a bound action
893+
// url followed as a plain link carries `args` that decodes, a form
894+
// field named `args` does not. Only the form reading survives a
895+
// decode failure — a scripted caller sent an encoding, so its
896+
// failure is a real one.
897+
if (!unscriptedRead) throw error;
881898
}
882899
}
883900
}
901+
if (unscriptedRead && !decodedArguments && url.search) {
902+
parsed.push(url.searchParams);
903+
}
884904
if (request.method === "POST" && request.body !== null) {
885905
const decoded = await extractBody(request.clone(), codec);
886906
// Both argument-array encodings: codec-framed and plain JSON.

packages/web/test/server/server-functions-addressing.spec.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,50 @@ describe("server function addressing (built bundles)", () => {
171171
expect(parseServerFunctionUrl("/somewhere/else")).toBeNull();
172172
});
173173

174+
it("hands an unscripted read its own query as one URLSearchParams", async () => {
175+
// What `<form method="get" action={fn.url}>` sends: the browser replaces
176+
// the action url's query with the form's fields, and the address in the
177+
// path is what survives it.
178+
serverGET(
179+
createServerSideReference(
180+
registerServerReference("addr-form-0", async (params: URLSearchParams) => ({
181+
q: params.get("q"),
182+
page: params.get("page")
183+
}))
184+
)
185+
);
186+
const response = await unscripted("/_server/addr-form-0?q=solid&page=2");
187+
expect(response.status).toBe(200);
188+
expect(await response.json()).toEqual({ q: "solid", page: "2" });
189+
});
190+
191+
it("describes the same read on HEAD: same arguments, no body", async () => {
192+
serverGET(
193+
createServerSideReference(
194+
registerServerReference("addr-form-2", async (params: URLSearchParams) => params.get("q"))
195+
)
196+
);
197+
const response = await unscripted("/_server/addr-form-2?q=solid", { method: "HEAD" });
198+
expect(response.status).toBe(200);
199+
expect(response.body).toBeNull();
200+
});
201+
202+
it("reads a field named `args` as a field, and a bound action url as arguments", async () => {
203+
serverGET(
204+
createServerSideReference(
205+
registerServerReference("addr-form-1", async (value: URLSearchParams | string) =>
206+
typeof value === "string" ? `bound:${value}` : `field:${value.get("args")}`
207+
)
208+
)
209+
);
210+
// a form whose field happens to be named `args`
211+
expect(await (await unscripted("/_server/addr-form-1?args=hello")).text()).toBe("field:hello");
212+
// a bound action url followed as a plain link
213+
expect(await (await unscripted(serverFunctionUrl("addr-form-1", ["hello"]))).text()).toBe(
214+
"bound:hello"
215+
);
216+
});
217+
174218
it("keeps the address well-formed when the endpoint carries a trailing slash", () => {
175219
try {
176220
configureServerFunctionsClient({ endpoint: "/rpc/" });
@@ -198,4 +242,21 @@ describe("server function addressing (built bundles)", () => {
198242
expect(() => serverFunctionUrl("addr-bound-1", [new Date()])).toThrow(/JSON-safe/);
199243
});
200244

245+
it("still fails a scripted call whose argument encoding is broken", async () => {
246+
// the form reading is for calls the client runtime did not make; a
247+
// scripted caller sent an encoding, so a decode failure stays a failure
248+
serverGET(
249+
createServerSideReference(
250+
registerServerReference("addr-broken-0", async (value: unknown) => typeof value)
251+
)
252+
);
253+
await expect(
254+
unscripted("/_server/addr-broken-0?args=not-an-encoding", {
255+
headers: { "X-Server-Function-Instance": "server-function:test" }
256+
})
257+
).rejects.toThrow();
258+
// the same request without the instance header reads it as a form field
259+
const asForm = await unscripted("/_server/addr-broken-0?args=not-an-encoding");
260+
expect(await asForm.text()).toBe("object");
261+
});
201262
});

0 commit comments

Comments
 (0)