Summary
extractBody's switch ends in a bare return undefined, and the transport only inspects X-Server-Function-Format once the status is ≥ 400. So a 2xx response the client cannot decode resolves the call with undefined instead of failing it.
Correction to how I filed this: it is the intended behaviour, not an oversight. It is pinned by packages/web/test/server/server-functions-transport-failure.spec.tsx → "cannot judge a 2xx, and does not try", whose comment names this exact scenario — "a login page or an SPA index served at 200 is indistinguishable from a void result by header alone; the status is all this rule reads" — in a file headed "What the transport does with a response the runtime did not write (#3087)". Both docblocks state it too (extractBody: "Resolves undefined for bodies without a recognized encoding").
So this is a request to revisit #3087's decision, with the cost measured below — not a bug report.
Tested against next @ a536e29b, built from source, Node 24.19.
case format === BodyFormat.Uint8Array:
return new Uint8Array(await clone.arrayBuffer());
}
return undefined;
Reproduction
200 text/html "<html>Sign in to the WiFi</html>" -> RESOLVED value = undefined
200 empty body, no headers -> RESOLVED value = undefined
200 application/json (valid) -> RESOLVED value = "ok" (control)
Why it may be worth revisiting
The reachable cases are not hostile servers — they are ordinary infrastructure answering in place of the origin, all of which return 200 with HTML:
- a captive portal (hotel, airport, corporate guest wifi)
- a WAF or bot-protection interstitial
- a CDN or proxy error page served with a 200
- a misrouted request that lands on the SPA's own index.html
In every one of those, each server-function call quietly returns "no data". The caller cannot distinguish it from a function that legitimately returned nothing, error boundaries never fire, retry logic never triggers, and a UI renders an empty state rather than a failure. For a mutation the consequence is worse: the call appears to have succeeded.
This is the same failure shape the runtime already rejected elsewhere. A body whose format header is unusable is refused with a 400 rather than calling the function with a phantom undefined argument (#3130), and a truncated stream ships an in-band error rather than decoding as undefined (#3117) — with the stated reasoning that a silent undefined is indistinguishable from a void return and invites a retry of a committed mutation. The response leg reaches the opposite outcome for the same reason.
Options
- Throw on an unrecognized encoding, naming the content-type. Measured: this breaks two tests —
"cannot judge a 2xx, and does not try" and "fails a verbatim passthrough that carries a refusal's status".
- (1) plus a passthrough carve-out — return
undefined when the response carries X-Content-Raw, since a verbatim passthrough is the runtime's own answer and is documented to decode as nothing. Measured: this saves the second test, leaving exactly one failure — the pinned decision itself. So the whole price is reversing one test, plus the risk it was pinned to avoid: any 200 whose body the runtime cannot read now fails the call.
- Require the protocol header on every status, not only ≥ 400. A response without
X-Server-Function-Format is not ours; treating its absence as "not a server-function response" at any status is stricter and catches the captive-portal case by construction. Risk: a proxy that strips unknown X- headers would turn working calls into failures — worth checking whether that is a real deployment shape before choosing this.
- Distinguish "decoded to undefined" from "could not decode" internally. On its own this changes nothing observable — the transport maps both to
undefined on purpose. It is a prerequisite for (1)/(2), not an alternative.
- Leave it and document it at the call site — say plainly that a 200 the runtime cannot decode resolves as
undefined, and that an application wanting to detect a captive portal must inspect the response itself.
My read, having measured it: (2) if you are willing to reverse #3087's judgement here, (5) if not. The current behaviour is defensible for a raw response and much less so for text/html — but that distinction is a content-type heuristic, which is exactly what #3087 declined to build.
Happy to send a PR. The regression test shape is the table above: an undecodable 2xx rejects with the content-type named, a valid response still resolves, and a genuine void return still resolves as undefined — that last one being the control that distinguishes the two meanings.
Summary
extractBody's switch ends in a barereturn undefined, and the transport only inspectsX-Server-Function-Formatonce the status is ≥ 400. So a 2xx response the client cannot decode resolves the call withundefinedinstead of failing it.Correction to how I filed this: it is the intended behaviour, not an oversight. It is pinned by
packages/web/test/server/server-functions-transport-failure.spec.tsx→"cannot judge a 2xx, and does not try", whose comment names this exact scenario — "a login page or an SPA index served at 200 is indistinguishable from a void result by header alone; the status is all this rule reads" — in a file headed "What the transport does with a response the runtime did not write (#3087)". Both docblocks state it too (extractBody: "Resolves undefined for bodies without a recognized encoding").So this is a request to revisit #3087's decision, with the cost measured below — not a bug report.
Tested against
next@a536e29b, built from source, Node 24.19.Reproduction
Why it may be worth revisiting
The reachable cases are not hostile servers — they are ordinary infrastructure answering in place of the origin, all of which return 200 with HTML:
In every one of those, each server-function call quietly returns "no data". The caller cannot distinguish it from a function that legitimately returned nothing, error boundaries never fire, retry logic never triggers, and a UI renders an empty state rather than a failure. For a mutation the consequence is worse: the call appears to have succeeded.
This is the same failure shape the runtime already rejected elsewhere. A body whose format header is unusable is refused with a 400 rather than calling the function with a phantom
undefinedargument (#3130), and a truncated stream ships an in-band error rather than decoding asundefined(#3117) — with the stated reasoning that a silentundefinedis indistinguishable from a void return and invites a retry of a committed mutation. The response leg reaches the opposite outcome for the same reason.Options
"cannot judge a 2xx, and does not try"and"fails a verbatim passthrough that carries a refusal's status".undefinedwhen the response carriesX-Content-Raw, since a verbatim passthrough is the runtime's own answer and is documented to decode as nothing. Measured: this saves the second test, leaving exactly one failure — the pinned decision itself. So the whole price is reversing one test, plus the risk it was pinned to avoid: any 200 whose body the runtime cannot read now fails the call.X-Server-Function-Formatis not ours; treating its absence as "not a server-function response" at any status is stricter and catches the captive-portal case by construction. Risk: a proxy that strips unknownX-headers would turn working calls into failures — worth checking whether that is a real deployment shape before choosing this.undefinedon purpose. It is a prerequisite for (1)/(2), not an alternative.undefined, and that an application wanting to detect a captive portal must inspect the response itself.My read, having measured it: (2) if you are willing to reverse #3087's judgement here, (5) if not. The current behaviour is defensible for a raw response and much less so for
text/html— but that distinction is a content-type heuristic, which is exactly what #3087 declined to build.Happy to send a PR. The regression test shape is the table above: an undecodable 2xx rejects with the content-type named, a valid response still resolves, and a genuine void return still resolves as
undefined— that last one being the control that distinguishes the two meanings.