Skip to content

Commit 3ac88c8

Browse files
committed
fix(web): fail a call on a response the runtime did not write (#3087)
Only the protocol's error header and a 5xx counted as failure, so every other non-2xx was decoded as a result — and decoding a login page, or an empty 405, yields nothing. The call resolved to `undefined`, reading as "the function returned nothing" where the request had not reached it. A response at 400 or above carrying neither the error header nor a body format now fails the call with the status on the error. Undecoded, because its body is someone else's; and before the passthrough that control flow uses, because a foreign refusal carries a `Location` of its own often enough — an SSO interstitial is exactly that — that the passthrough would have swallowed it. `BodyFormat.Void` marks the one response the runtime encodes without a format to carry, so a void result with a status stays a result, matching what the equivalent bare `Response` already did.
1 parent 91f720e commit 3ac88c8

5 files changed

Lines changed: 275 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Fail a server function call on a response the runtime did not write, instead of resolving it to `undefined` (#3087).
6+
7+
Only the protocol's error header and a 5xx counted as failure, so every other non-2xx was decoded as a result — and decoding a login page, or an empty 405, yields nothing. A response at 400 or above carrying neither the error header nor a body format now fails the call with the status on the error, undecoded, and before the passthrough control flow uses: a foreign refusal carries a `Location` of its own often enough that the passthrough would have swallowed it.
8+
9+
One runtime-produced shape is caught with them: a verbatim `X-Content-Raw` response at a non-2xx status, whose body a call site without an integration could not read anyway — an integration's `responseHandler` claims the response before the check. Otherwise the runtime's own responses are unaffected. `BodyFormat.Void` marks the one it encoded without a format to carry, so `respond(undefined, { status: 400 })`, `new Response(null, { status: 404 })` and `respond(value, { status: 400 })` all stay results. A 2xx is not judged at all: a login page served at 200 is indistinguishable from a void result by header alone.

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,24 @@ async function fetchServerFunction(base, id, options, args, meta, callArgs = arg
544544
if (handled !== undefined) return handled;
545545
}
546546

547+
// A refusal the runtime did not write is not a result, and decoding one
548+
// yields nothing — which used to resolve the call to `undefined`, reading
549+
// as "the function returned nothing" where the request never reached it.
550+
// Every response the runtime encodes carries the body format (a void one
551+
// included), so the two markers below identify its own; anything else at
552+
// 400 and up is the peer refusing: the handler's own gates, a route it is
553+
// not mounted on, an auth interstitial, a CDN block. Answered before the
554+
// passthrough beneath, because a foreign refusal carries a `Location` of
555+
// its own — an SSO interstitial is exactly that — and undecoded, because
556+
// its body is someone else's, not a payload to hand the caller.
557+
if (
558+
response.status >= 400 &&
559+
!response.headers.has(ERROR_HEADER) &&
560+
!response.headers.has(BODY_FORMAT_HEADER)
561+
) {
562+
throw serverFunctionFailure(response, undefined);
563+
}
564+
547565
// Proxies may omit the protocol error header on 5xx responses.
548566
const failed = response.headers.has(ERROR_HEADER) || response.status >= 500;
549567

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,7 @@ function encodeResult(value, headers, status, codec, signal) {
13761376
// client load its decode half (see shared.js loadSerializer). Negotiated
13771377
// per response: mixed pages simply carry both formats.
13781378
if (value === undefined) {
1379+
headers.set(BODY_FORMAT_HEADER, BodyFormat.Void);
13791380
return new Response(null, { status, headers });
13801381
}
13811382
// By the time a result is being encoded the function has already run —

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,15 @@ export const BodyFormat = {
539539
* legs: argument lists on the request, results (single-flight envelopes
540540
* included) on the response.
541541
*/
542-
Json: "8"
542+
Json: "8",
543+
/**
544+
* No body at all — a function that returned nothing. The format carries no
545+
* payload; it marks the response as one the runtime encoded, which is what
546+
* separates a void result with a status on it from a refusal answered by
547+
* something else. Decoding falls through to `undefined`, so a peer that
548+
* predates the tag reads it the same way.
549+
*/
550+
Void: "9"
543551
};
544552

545553
// Nesting deeper than this is not JSON-safe. The guard itself walks an
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* What the transport does with a response the runtime did not write (#3087).
3+
*
4+
* Its own responses are recognisable: they carry the error header, or the
5+
* body format every encoding path stamps — a void result included. Anything
6+
* else at 400 and up is the peer refusing, and decoding one yields nothing,
7+
* which used to resolve the call to `undefined`. These pin the refusals that
8+
* now fail, the value-shaped statuses that must not, and the limit of what a
9+
* status can tell you.
10+
*
11+
* One runtime-produced shape is caught along with them: a verbatim
12+
* `X-Content-Raw` response at a non-2xx status, whose body no call site
13+
* without an integration could read anyway. An integration is unaffected —
14+
* its `responseHandler` claims the response before any of this.
15+
*
16+
* Like the extension specs, these run against the built bundles
17+
* (server-functions/dist/*, wired up in vite.config.server.mjs).
18+
*/
19+
import { AsyncLocalStorage } from "node:async_hooks";
20+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
21+
import { redirect, respond } from "@solidjs/web";
22+
import {
23+
handleServerFunctionRequest,
24+
registerServerFunction
25+
} from "@solidjs/web/server-functions/server";
26+
import {
27+
configureServerFunctionsClient,
28+
createServerReference
29+
} from "@solidjs/web/server-functions/client";
30+
31+
const RequestContext = Symbol.for("solid.RequestContext");
32+
33+
beforeAll(() => {
34+
(globalThis as any)[RequestContext] = new AsyncLocalStorage();
35+
});
36+
37+
afterAll(() => {
38+
delete (globalThis as any)[RequestContext];
39+
});
40+
41+
/**
42+
* The transport's fetch. `answer` replaces the handler with a response from
43+
* somewhere else; `rewrite` sends the call to a different address.
44+
*/
45+
function connectTransport({
46+
answer,
47+
rewrite
48+
}: { answer?: () => Response; rewrite?: (address: string) => string } = {}) {
49+
const original = globalThis.fetch;
50+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
51+
if (answer) return Promise.resolve(answer());
52+
const address = input instanceof Request ? input.url : input.toString();
53+
const request = new Request(
54+
new URL(rewrite ? rewrite(address) : address, "http://localhost"),
55+
input instanceof Request ? input : init
56+
);
57+
request.headers.set("Sec-Fetch-Site", "same-origin");
58+
return handleServerFunctionRequest(request);
59+
}) as typeof fetch;
60+
return () => {
61+
globalThis.fetch = original;
62+
};
63+
}
64+
65+
const foreign = (status: number, body: BodyInit | null, type?: string) => () =>
66+
new Response(body, { status, headers: type ? { "content-type": type } : undefined });
67+
68+
describe("server-function transport failures (#3087)", () => {
69+
it("fails the call when the handler refuses it", async () => {
70+
const restore = connectTransport();
71+
try {
72+
// a client that outlived the build registering its function
73+
await expect(createServerReference("fail-never-registered")()).rejects.toMatchObject({
74+
status: 404
75+
});
76+
} finally {
77+
restore();
78+
}
79+
});
80+
81+
it("fails the call when the handler rejects the request itself", async () => {
82+
registerServerFunction("fail-args", async () => "ok");
83+
// something under `args` that is not an argument array: a 400 for every
84+
// caller of that url
85+
const restore = connectTransport({ rewrite: address => `${address}?args=nope` });
86+
try {
87+
await expect(createServerReference("fail-args")()).rejects.toMatchObject({ status: 400 });
88+
} finally {
89+
restore();
90+
}
91+
});
92+
93+
it("fails the call on a response nothing in the runtime wrote", async () => {
94+
registerServerFunction("fail-foreign", async () => "ok");
95+
for (const answer of [
96+
foreign(404, "<h1>Not found</h1>", "text/html"),
97+
foreign(401, "<html>login</html>", "text/html"),
98+
foreign(403, null)
99+
]) {
100+
const restore = connectTransport({ answer });
101+
try {
102+
await expect(createServerReference("fail-foreign")()).rejects.toMatchObject({
103+
status: answer().status
104+
});
105+
} finally {
106+
restore();
107+
}
108+
}
109+
});
110+
111+
it("fails one carrying integration metadata of its own", async () => {
112+
registerServerFunction("fail-interstitial", async () => "ok");
113+
// an SSO interstitial answers 403 with a Location, and a gateway can
114+
// answer 404 with anything — neither is the runtime's control flow, so
115+
// neither may take the passthrough that control flow uses
116+
for (const [status, headers] of [
117+
[403, { "content-type": "text/html", Location: "https://sso.example/login" }],
118+
[404, { "X-Revalidate": "stories" }]
119+
] as const) {
120+
const restore = connectTransport({ answer: () => new Response(null, { status, headers }) });
121+
try {
122+
await expect(createServerReference("fail-interstitial")()).rejects.toMatchObject({
123+
status
124+
});
125+
} finally {
126+
restore();
127+
}
128+
}
129+
});
130+
131+
it("fails with an Error even when the foreign body sniffs as a known encoding", async () => {
132+
registerServerFunction("fail-sniffed", async () => "ok");
133+
// content-type sniffing would decode this as URLSearchParams; a refusal's
134+
// body is not a payload, and a caller reads `.status`, not a form
135+
const restore = connectTransport({
136+
answer: foreign(403, "reason=blocked", "application/x-www-form-urlencoded")
137+
});
138+
try {
139+
await expect(createServerReference("fail-sniffed")()).rejects.toMatchObject({ status: 403 });
140+
await expect(createServerReference("fail-sniffed")()).rejects.toBeInstanceOf(Error);
141+
} finally {
142+
restore();
143+
}
144+
});
145+
146+
it("resolves a value-shaped status the function itself produced", async () => {
147+
registerServerFunction("fail-validated", async () =>
148+
respond({ field: "required" }, { status: 400 })
149+
);
150+
registerServerFunction("fail-void", async () => respond(undefined, { status: 400 }));
151+
registerServerFunction("fail-empty", async () => new Response(null, { status: 404 }));
152+
const restore = connectTransport();
153+
try {
154+
expect(await createServerReference("fail-validated")()).toEqual({ field: "required" });
155+
// nothing, with a status on it — both spellings answer the same way
156+
expect(await createServerReference("fail-void")()).toBeUndefined();
157+
expect(await createServerReference("fail-empty")()).toBeNull();
158+
} finally {
159+
restore();
160+
}
161+
});
162+
163+
it("leaves the runtime's own control flow on its passthrough", async () => {
164+
registerServerFunction("fail-redirect", async () => {
165+
throw redirect("/login");
166+
});
167+
const restore = connectTransport();
168+
try {
169+
const response = await createServerReference("fail-redirect")();
170+
expect(response).toBeInstanceOf(Response);
171+
expect((response as Response).headers.get("Location")).toBe("/login");
172+
} finally {
173+
restore();
174+
}
175+
});
176+
177+
it("cannot judge a 2xx, and does not try", async () => {
178+
registerServerFunction("fail-spa", async () => "ok");
179+
// a login page or an SPA index served at 200 is indistinguishable from a
180+
// void result by header alone; the status is all this rule reads
181+
const restore = connectTransport({ answer: foreign(200, "<html>login</html>", "text/html") });
182+
try {
183+
expect(await createServerReference("fail-spa")()).toBeUndefined();
184+
} finally {
185+
restore();
186+
}
187+
});
188+
189+
it("fails a verbatim passthrough that carries a refusal's status", async () => {
190+
registerServerFunction(
191+
"fail-raw",
192+
async () => new Response("nope", { status: 404, headers: { "X-Content-Raw": "1" } })
193+
);
194+
registerServerFunction(
195+
"fail-raw-ok",
196+
async () => new Response("here", { status: 200, headers: { "X-Content-Raw": "1" } })
197+
);
198+
const restore = connectTransport();
199+
try {
200+
// a raw body is for whoever claims the response; a call site with no
201+
// integration could not read it at either status, and a status beats
202+
// minting `undefined`
203+
await expect(createServerReference("fail-raw")()).rejects.toMatchObject({ status: 404 });
204+
expect(await createServerReference("fail-raw-ok")()).toBeUndefined();
205+
} finally {
206+
restore();
207+
}
208+
});
209+
210+
it("leaves the response to an integration that claims it", async () => {
211+
registerServerFunction(
212+
"fail-claimed",
213+
async () => new Response("frame", { status: 404, headers: { "X-Content-Raw": "1" } })
214+
);
215+
const restore = connectTransport();
216+
configureServerFunctionsClient({
217+
responseHandler: { handle: (response: Response) => response.status }
218+
});
219+
try {
220+
// the seam runs before the check, which is what keeps server
221+
// components free to answer with a status of their own
222+
expect(await createServerReference("fail-claimed")()).toBe(404);
223+
} finally {
224+
configureServerFunctionsClient({ responseHandler: null as any });
225+
restore();
226+
}
227+
});
228+
229+
it("leaves an ordinary call alone", async () => {
230+
registerServerFunction("fail-plain", async (word: string) => word.toUpperCase());
231+
const restore = connectTransport();
232+
try {
233+
expect(await createServerReference("fail-plain")("solid")).toBe("SOLID");
234+
} finally {
235+
restore();
236+
}
237+
});
238+
});

0 commit comments

Comments
 (0)