Skip to content

Revisit #3087: a 2xx the client cannot decode resolves as undefined, and a captive portal is indistinguishable from a void result #3173

Description

@frenzzy

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

  1. 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".
  2. (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.
  3. 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.
  4. 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.
  5. 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.

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