Describe the bug
A server function that answers with a null-body status — 204, 205, 304 — fails with a generic Internal Server Error instead, and the failure carries no hint of what was wrong.
The transport always writes a body for a scripted caller, then applies the author's status to it (packages/web/server-functions/src/server.ts, encodeResult):
if (value === undefined) {
headers.set(BODY_FORMAT_HEADER, BodyFormat.Void);
return new Response(null, { status, headers }); // fine
}
...
return new Response(JSON.stringify(value), { status, headers }); // throws for 204/205/304
new Response(<body>, { status: 204 }) is a TypeError by construction, so the encode throws, the generic handler catches it, and the caller gets a sanitized error at HTTP 200.
Only the undefined branch escapes, which makes the rule an accident of the value rather than of the status: respond(undefined, { status: 204 }) answers a real 204, and respond({ a: 1 }, { status: 204 }) answers a 200 with an error.
Returning new Response(null, { status: 204 }) — no body anywhere in sight — fails too, because the transport still encodes its own body before applying the status:
!! Response( string, status: 204 ) -> Response constructor: Invalid response status code 204
Nothing surfaces the cause, in either mode. sanitizeServerError replaces the TypeError rather than wrapping it, so even with NODE_ENV=development the payload is "Internal Server Error" and the stack starts inside the sanitizer:
Error: Internal Server Error
at sanitizeServerError (.../server-functions/dist/server.js:1030:10)
at dispatch (.../server-functions/dist/server.js:1288:20)
The words 204, status code and TypeError appear nowhere in that response. An author who writes a plausible line gets a server error pointing at framework internals.
The runtime already holds this exact scenario to be forbidden. encodeResult on next carries the rule in a comment:
By the time a result is being encoded the function has already run — side effects committed — so a failure HERE must never escape into dispatch's catch, where it would be sanitized and reported as the function itself throwing (a phantom error over a call that succeeded).
That is precisely what happens. The try around the JSON attempt swallows the first TypeError, and the codec path immediately below it throws the same one outside the guard:
try {
if (isJSONSafe(value)) {
...
return new Response(JSON.stringify(value), { status, headers }); // throws, caught
}
} catch {
// fall through
}
const response = serializedResponse(value, headers, codec, signal);
return status === 200 ? response : new Response(response.body, { status, headers }); // throws, escapes
Both throws are visible if you wrap Response: Response(string, 204), then Response(object, 204). The call succeeded; the caller is told it failed.
Steps to reproduce
mkdir sf-nullbody && cd sf-nullbody && npm init -y
npm i @solidjs/web@2.0.0-rc.4
node repro.mjs
repro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const { handleServerFunctionRequest, registerServerFunction } =
await import("@solidjs/web/server-functions/server");
const { respond } = await import("@solidjs/web");
registerServerFunction("empty-204", () => respond(undefined, { status: 204 }));
registerServerFunction("value-204", () => respond({ a: 1 }, { status: 204 }));
registerServerFunction("value-205", () => respond({ a: 1 }, { status: 205 }));
registerServerFunction("raw-204", () => new Response(null, { status: 204 }));
for (const id of ["empty-204", "value-204", "value-205", "raw-204"]) {
const response = await handleServerFunctionRequest(
new Request(`http://localhost/_server/${id}`, {
method: "POST",
body: "[]",
headers: { "Sec-Fetch-Site": "same-origin", "X-Server-Function-Instance": "i" }
})
);
console.log(
id.padEnd(10),
"->", response.status,
"| error tag:", response.headers.get("X-Server-Function-Error") ?? "(none)",
"| body:", response.body ? JSON.stringify((await response.text()).slice(0, 52)) : "(none)"
);
}
Output on 2.0.0-rc.4:
empty-204 -> 204 | error tag: (none) | body: (none)
value-204 -> 200 | error tag: Internal Server Error | body: ";0x00000044;{\"t\":13,\"i\":0,\"s\":0,\"m\":\"Internal Server"
value-205 -> 200 | error tag: Internal Server Error | body: ";0x00000044;{\"t\":13,\"i\":0,\"s\":0,\"m\":\"Internal Server"
raw-204 -> 200 | error tag: Internal Server Error | body: ";0x00000044;{\"t\":13,\"i\":0,\"s\":0,\"m\":\"Internal Server"
Expected behavior
Either answer the status the author asked for, or say why it is impossible. Not a 200 carrying an error that names nothing.
Prior art
The rule is the spec's, not this runtime's: "A null body status is a status that is 101, 103, 204, 205, or 304" (Fetch §2.2.3), and initializing a response with a body at such a status "throw[s] a TypeError" (initialize a response, step 6). Through the constructor only 204, 205 and 304 can reach it. Node words it as Response constructor: Invalid response status code 204.
Everyone meets this, and where nothing intercepts it the developer gets exactly what they get here — a 500 and a stack that never says 204:
Two frameworks do intercept it, in the two places it can be intercepted — which is why the options below are those two:
- React Router drops the body.
NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]), with 304 added on the server "because the browser should fill those responses with the cached data", and then // Skip response body for unsupported status codes → return new Response(null, { status, headers }).
- Hono rejects it in the types.
ContentlessStatusCode = 101 | 204 | 205 | 304, and every body-carrying overload takes ContentfulStatusCode: "if we return content, only allow the status codes that allow for returning the body". No runtime guard at all.
Options
- Drop the body for a null-body status. The status is the author's explicit instruction and the body is the transport's own doing, so the transport yields:
204/205/304 encode as BodyFormat.Void, exactly as undefined already does. respond({ a: 1 }, { status: 204 }) then answers a real 204 and the value is discarded — which is what the status means, and what a scripted caller can already receive (the void format decodes to undefined). Silent discarding of a value is the part worth arguing about.
- Reject it at the call site with a directed error.
respond() throws "a 204 response cannot carry a value" when given both, so the author learns at the point of the mistake rather than through a 200. Costs nothing at runtime for everyone else, but only covers respond() — a returned bare Response still reaches encodeResult.
- Both: (2) for
respond(), (1) for whatever still arrives at the encoder, including the raw-Response path.
- Let the
TypeError through unsanitized. Cheapest, and the worst of these: it turns an author mistake into a 500 in production.
I lean to (3): (2) is where the mistake is made, (1) is where it must not become an unexplained 500 anyway. Whichever you pick, the raw-Response case needs (1) — there is no call site to reject there.
Environment
|
|
@solidjs/web |
2.0.0-rc.4 (published), and next |
| Node |
v24.19.0 |
| OS |
macOS (darwin 25.6.0) |
Related
#3096 and #3097 are the other two ways a status chosen by the author fails to reach the caller. Filed apart on purpose: this one is a crash with a local fix, those two are policy.
Describe the bug
A server function that answers with a null-body status —
204,205,304— fails with a generic Internal Server Error instead, and the failure carries no hint of what was wrong.The transport always writes a body for a scripted caller, then applies the author's status to it (
packages/web/server-functions/src/server.ts,encodeResult):new Response(<body>, { status: 204 })is aTypeErrorby construction, so the encode throws, the generic handler catches it, and the caller gets a sanitized error at HTTP 200.Only the
undefinedbranch escapes, which makes the rule an accident of the value rather than of the status:respond(undefined, { status: 204 })answers a real 204, andrespond({ a: 1 }, { status: 204 })answers a 200 with an error.Returning
new Response(null, { status: 204 })— no body anywhere in sight — fails too, because the transport still encodes its own body before applying the status:Nothing surfaces the cause, in either mode.
sanitizeServerErrorreplaces theTypeErrorrather than wrapping it, so even withNODE_ENV=developmentthe payload is"Internal Server Error"and the stack starts inside the sanitizer:The words
204,status codeandTypeErrorappear nowhere in that response. An author who writes a plausible line gets a server error pointing at framework internals.The runtime already holds this exact scenario to be forbidden.
encodeResultonnextcarries the rule in a comment:That is precisely what happens. The
tryaround the JSON attempt swallows the firstTypeError, and the codec path immediately below it throws the same one outside the guard:Both throws are visible if you wrap
Response:Response(string, 204), thenResponse(object, 204). The call succeeded; the caller is told it failed.Steps to reproduce
repro.mjs:Output on 2.0.0-rc.4:
Expected behavior
Either answer the status the author asked for, or say why it is impossible. Not a 200 carrying an error that names nothing.
Prior art
The rule is the spec's, not this runtime's: "A null body status is a status that is 101, 103, 204, 205, or 304" (Fetch §2.2.3), and initializing a response with a body at such a status "throw[s] a
TypeError" (initialize a response, step 6). Through the constructor only204,205and304can reach it. Node words it asResponse constructor: Invalid response status code 204.Everyone meets this, and where nothing intercepts it the developer gets exactly what they get here — a 500 and a stack that never says
204:204route handler answering 500 with an undici stack (vercel/next.js#49005, open);204whose body was serialized, crashing hydration (sveltejs/kit#13972);Two frameworks do intercept it, in the two places it can be intercepted — which is why the options below are those two:
NO_BODY_STATUS_CODES = new Set([100, 101, 204, 205]), with304added on the server "because the browser should fill those responses with the cached data", and then// Skip response body for unsupported status codes→return new Response(null, { status, headers }).ContentlessStatusCode = 101 | 204 | 205 | 304, and every body-carrying overload takesContentfulStatusCode: "if we return content, only allow the status codes that allow for returning the body". No runtime guard at all.Options
204/205/304encode asBodyFormat.Void, exactly asundefinedalready does.respond({ a: 1 }, { status: 204 })then answers a real 204 and the value is discarded — which is what the status means, and what a scripted caller can already receive (the void format decodes toundefined). Silent discarding of a value is the part worth arguing about.respond()throws"a 204 response cannot carry a value"when given both, so the author learns at the point of the mistake rather than through a 200. Costs nothing at runtime for everyone else, but only coversrespond()— a returned bareResponsestill reachesencodeResult.respond(), (1) for whatever still arrives at the encoder, including the raw-Responsepath.TypeErrorthrough unsanitized. Cheapest, and the worst of these: it turns an author mistake into a 500 in production.I lean to (3): (2) is where the mistake is made, (1) is where it must not become an unexplained 500 anyway. Whichever you pick, the raw-
Responsecase needs (1) — there is no call site to reject there.Environment
@solidjs/webnextRelated
#3096 and #3097 are the other two ways a status chosen by the author fails to reach the caller. Filed apart on purpose: this one is a crash with a local fix, those two are policy.