Skip to content

Start: untagged 2xx non-JSON server-function response resolves as a raw Response instead of rejecting #8333

Description

@quanglam2807

Which project does this relate to?

Start

Describe the bug

When a server-function response carries neither x-tss-serialized nor x-tss-raw, is 2xx, and is not application/json, getResponse returns the Response object itself as the server function's return value. The loader/caller then receives a Response where it expected data, and the failure surfaces far from its cause — for us, as TypeError: n.map is not a function inside a component, with nothing pointing at the network.

packages/start-client-core/src/client-rpc/serverFnFetcher.ts (current main, L334-340):

  // Otherwise, if it's not OK, throw the content
  if (!response.ok) {
    throw new Error(await response.text())
  }

  // Or return the response itself
  return response

The reason this is reachable at all is that the server tags every response it produces, so an untagged response did not come from the server-functions handler. In packages/start-server-core/src/server-functions-handler.ts (L172-180):

      if (unwrapped instanceof Response) {
        if (isRedirect(unwrapped)) {
          return unwrapped
        }
        unwrapped.headers.set(X_TSS_RAW_RESPONSE, 'true')
        return unwrapped
      }

      return serializeResult(res)   // sets X_TSS_SERIALIZED

So a handler that returns a Response gets x-tss-raw (and the client returns it early at L246), and everything else gets x-tss-serialized. The only untagged legitimate path is the isRedirect branch at L173. Every other untagged response reaching getResponse came from something between the browser and the server-functions handler.

The asymmetry is what makes this hard to defend as intentional: the same untagged non-JSON response throws when it is 500 (L335) but is returned silently when it is 200.

What triggers it in practice

An intermediary that answers /_serverFn/* itself. In our case Cloudflare's bot management started serving managed challenges and 403s to server-function calls on a production site. Server-function calls are same-origin fetch(), so they can never satisfy an interstitial. The challenge body names the intercepted path directly:

cType: 'managed',  cZone: '<our domain>',
cUPMDTk: "/_serverFn/c29844191716252ad5d6ebe1cf8a3d5443549155d94a720b2239f3e2d46e76c0?__cf_chl_tk=..."

Confirming the response never reaches the Worker (no x-tss-* headers):

$ curl -D- https://<our domain>/_serverFn/deadbeef -H 'x-tsr-serverFn: true'
HTTP/2 403
content-type: text/plain;charset=UTF-8
server: cloudflare

Depending on what the intermediary returns, one call site produced three different symptoms:

Intermediary response Path taken Symptom
non-2xx, text/html L335 throws Error whose message is the entire HTML body
2xx, text/html L340 returns Response n.map is not a function in a component
non-2xx, application/json L322-331 resolves undefined (this is #8280)

The 2xx row is the one this issue is about. It is the worst of the three because it corrupts data silently instead of failing.

This is not Cloudflare-specific — any reverse proxy, gateway, captive portal, or ISP interception page does the same thing. #8280 reports hitting it via ISP-level blocking.

Complete minimal reproducer

Any gateway that answers /_serverFn/* with a 2xx non-JSON body reproduces it. Minimal version, in front of a built Start app on 3000:

// gateway.mjs — node gateway.mjs, then open http://localhost:8080
import { createServer } from 'node:http'

createServer(async (req, res) => {
  if (req.url.startsWith('/_serverFn/')) {
    res.writeHead(200, { 'content-type': 'text/html' })
    res.end('<!DOCTYPE html><html><body>interstitial</body></html>')
    return
  }
  const upstream = await fetch(`http://localhost:3000${req.url}`, {
    method: req.method,
    headers: req.headers,
    body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
    duplex: 'half',
  })
  res.writeHead(upstream.status, Object.fromEntries(upstream.headers))
  res.end(Buffer.from(await upstream.arrayBuffer()))
}).listen(8080)

Give the app a route whose loader calls a server function returning an array, and render data.map(...). Through :3000 it renders; through :8080 the loader data is a Response and the component throws .map is not a function.

Steps to Reproduce the Bug

  1. Build and start a Start app on port 3000.
  2. Add a route with loader: () => someServerFn() where the server function returns an array, and a component that does Route.useLoaderData().map(...).
  3. node gateway.mjs
  4. Open the route through http://localhost:8080. The component throws TypeError: ... .map is not a function; Route.useLoaderData() is a Response instance.

Expected behavior

An untagged, non-JSON response is not a server-function response, so it should reject rather than be handed back as data — matching the existing treatment of the untagged non-JSON non-2xx case, and of the other impossible states in the same function (the two invariant() calls at L251-259 and L306-312).

Because x-tss-raw already marks every deliberate raw Response, throwing here does not affect the "return a Response from a server function" feature. The isRedirect branch at server-functions-handler L173 looks like the one path that would need to stay allowed.

An error that names the cause would also save a lot of debugging time versus a .map failure in a component — something like "server function response was not produced by TanStack Start (no x-tss-serialized/x-tss-raw header); got 200 text/html — a proxy or gateway may have intercepted the request".

Platform

@tanstack/react-start 1.168.51, @tanstack/react-router 1.170.34 (resolved start-client-core 1.170.29, start-server-core 1.169.33, router-core 1.171.28), vite 8.2.2, react 19.3.0, node 24.20.0. Deployed on Cloudflare Workers. Code quoted above is from current main.

Additional context

Related but distinct:

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