Skip to content

A null-body status turns into a 200 carrying an unexplained Internal Server Error #3095

Description

@frenzzy

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 codesreturn 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

  1. 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.
  2. 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.
  3. Both: (2) for respond(), (1) for whatever still arrives at the encoder, including the raw-Response path.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions