Skip to content

fix(router): RSC flight wire format becomes explicit opt-in — generic clients get the HTML document - #705

Merged
ivogt merged 1 commit into
mainfrom
fix/flight-explicit-opt-in
Jul 6, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/flight-explicit-opt-in

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

curl https://app.example.com/products/1 returned the RSC flight wire format (text/x-component) instead of the page. The render layer classified any Accept header lacking the literal substring text/html as a flight request — so curl (Accept: */*), clients sending no Accept header, Accept: application/json on a page with no JSON variant, health checks, uptime monitors, and link unfurlers all received an internal transport payload they cannot render. On PPR routes this also hid the x-rango-shell header from curl, which read as "PPR is off" (the ppr skill carried a workaround note for exactly this).

Two internal defects rode on the same rule:

  • document-cache.ts:303 keys HTML-vs-RSC response slots via mayNeedSSR, but the response body was decided by isRscRequest — and the two disagreed on a missing Accept header, so a no-Accept GET on a document-cached route stored a flight body under the :html cache key.
  • On a negotiated route with a variant defined before the RSC page, an explicit Accept: text/x-component fell through to the definition-order fallback and answered with the variant (JSON) instead of the flight stream.

Before vs after

# Before
curl https://app.example.com/products/1
# → 200 text/x-component;charset=utf-8   (flight wire format)

# After
curl https://app.example.com/products/1
# → 200 text/html;charset=utf-8          (the rendered page)

Fetching the flight payload now requires the explicit wire-format Accept (scripts relying on bare curl for flight must add it):

curl -H "Accept: text/x-component" https://app.example.com/products/1
# → 200 text/x-component;charset=utf-8

End-to-end consumer example

// urls.tsx — one URL, two representations + a JSON variant
export const urlpatterns = urls(({ path }) => [
  path("/products/:id", ProductPage, { name: "product" }),
  path.json("/products/:id", (ctx) => db.getProduct(ctx.params.id), {
    name: "productJson",
  }),
]);
curl https://shop.example.com/products/42                                   # HTML page
curl -H "Accept: application/json"    https://shop.example.com/products/42  # JSON variant
curl -H "Accept: text/x-component"   https://shop.example.com/products/42  # flight stream
curl -s -D - -o /dev/null https://shop.example.com/products/42 | grep x-rango-shell
# x-rango-shell: HIT — PPR shells are now observable from a bare curl

Semantics table

Request Before After
Accept: text/html / browser strings HTML HTML (unchanged)
Accept: */* (curl default) flight HTML
No Accept header flight HTML
Accept: application/json, no JSON variant flight HTML
Accept: text/x-component flight flight (unchanged)
Accept: text/html, text/x-component;q=0.5 HTML HTML (now via q-ranking)
Accept: text/x-component, text/html;q=0.5 flight flight (now via q-ranking)
_rsc_partial / _rsc_loader / rsc-action / __rsc flight flight (unchanged)
Negotiated route, */*, variant defined first variant variant (unchanged)
Negotiated route, */*, RSC defined first flight HTML (RSC still wins; renders its canonical representation)
Negotiated route, Accept: text/x-component, variant defined first variant flight
?__html=1 override HTML HTML (unchanged)

Code changes

  • src/rsc/ssr-setup.ts — mayNeedSSR and isRscRequest now share one acceptsFlightExplicitly predicate. The Accept rule flips from "no text/html substring → flight" to "explicit text/x-component, ranked above the document, → flight". A includes() fast path keeps generic traffic allocation-free. Because both functions share the predicate, the document-cache key split can no longer diverge from the render decision.

    function acceptsFlightExplicitly(request: Request, url: URL): boolean {
      if (url.searchParams.has("__html")) return false;
      const accept = request.headers.get("accept");
      if (accept === null || !accept.includes(RSC_WIRE_MIME)) return false;
      return prefersFlightRepresentation(parseAcceptTypes(accept));
    }
  • src/router/content-negotiation.ts — the RSC candidate registers under both of its representations via a data-level RSC_MIMES = ["text/html", RSC_WIRE_MIME] list, and the representation ranking (prefersFlightRepresentation) is co-located with it so registration and ranking cannot drift. Location rationale: rsc/ → router/ is the established import direction (response-route-handler.ts already imports RESPONSE_TYPE_MIME), so the shared MIME knowledge lives in the lower layer.

  • src/rsc/handler.ts — comment update only (the mayNeedSSR/isRscRequest asymmetry note is obsolete).

  • Skills: mime-routes rewritten around the two-representation model (rules table + MIME mapping); ppr "Verifying it works" bare-curl gotcha removed — bare curl now sees x-rango-shell directly.

Test plan

  • Red-before-green: 6 e2e failures captured on unfixed code — Accept: */*, no-Accept, and application/json receiving text/x-component; JSON-first route answering an explicit wire-format request with JSON (packages/rangojs-router/e2e/content-negotiation.test.ts:143,171,220,239,252,263 under the old code). All pass with the fix.
  • Unit: new isRscRequest matrix + mayNeedSSR cases incl. q-ranking, q=0 refusal, wildcard-outranks, and a cross-predicate agreement test pinning isRscRequest === !mayNeedSSR across the Accept matrix (src/rsc/__tests__/ssr-setup.test.ts); pickNegotiateVariant wire-format selection + no-shadowing (src/router/__tests__/content-negotiation.test.ts); one old-semantics kickoff test updated — no-Accept now correctly starts SSR setup (src/rsc/__tests__/handler-ssr-kickoff.test.ts). Full test:unit:all: 5077+ tests pass.
  • E2e dev + production, both apps (Hard rule 3): router content-negotiation 39/39; cloudflare-basic content-negotiation 40/40 (new: wire-format on JSON-first route, plain-route */*→HTML, plain-route wire-format→flight). Contract pinned: flight is explicit-opt-in; generic clients get HTML; internal transports unchanged.
  • Adjacent e2e suites pass dev + production: mw-chain, content-ownership, loader-fetchable-guard, loader-fetch-thrown-response, stream-mode, prefetch-cache-control, origin-guard, app-middleware, shell-cache, progressive-enhancement.
  • Production semantics additionally verified by curl matrix against a real vite build + vite preview of the e2e test-app, including x-rango-shell: HIT on bare curl.
  • typecheck (16/16), lint (0 warnings), format, check:e2e-bucketing; no *.gen.ts drift (no route changes).

CI round 1 findings (amended into this PR)

The first CI run surfaced one real regression and three tests pinning the old transport implicitly:

  • Real regression (fixed in isRscRequest): the 404 fallback plan hardcodes mode: "full-render" even for _rsc_partial navigations (handler.ts RouteNotFoundError catch), so partial 404s reached the render decision with isPartial=false. The old Accept rule masked this by classifying */* as flight; without it, client navigations to missing routes received an HTML 404 they cannot apply and never committed (multi-router soft-404, popstate not-found). isRscRequest now also reads _rsc_partial from the URL — the canonical transport marker, same as mayNeedSSR — closing the class for any caller that loses the plan. Unit test pins it; multi-router + not-found suites green dev+prod.
  • Tests re-pinned to explicit flight (intent unchanged): shell-cache.test.ts / cf-basic ppr-shell.test.ts "bypasses non-document requests" (were Accept: */*), cf-basic cache-signal.test.ts miss→hit (was default Accept). Verified the cache-signal miss→hit signal is a flight-transport contract: Accept: text/html twice was miss/miss before this PR too — browsers were always on that path; */* clients now join it.
  • ppr-shell:244 and shell-cache:962 failures were serial knock-on from the above; both pass in full local suite runs.

Notes

  • Migration: any out-of-tree script fetching flight with default headers must add Accept: text/x-component (or ?__rsc=1). The router's own client runtime is unaffected — it always marks flight requests with transport params.
  • Bots/monitors now trigger SSR instead of the cheaper flight render; intended — they were previously getting an unusable payload.
  • Deliberately unchanged: Accept: text/html;q=0 still counts as accepting HTML (pre-existing substring limitation, out of scope); */* definition-order fallback on negotiated routes; emitted text/x-component;charset=utf-8 response-header literals across handlers (pre-existing duplication, separate concern from the matching MIME).
  • The testing primitives (dispatch) cannot reach trie-driven variant selection or the HTML/flight render decision (pre-existing limitation documented in src/testing/__tests__/dispatch.test.ts:1355); per that precedent the selection behavior is pinned by dev+prod e2e and the pure functions by unit tests.

@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@705

commit: bd923b3

… clients (curl, */*, missing Accept) get the HTML document

The render layer classified any Accept header lacking the literal substring
"text/html" as a flight request, so curl (Accept: */*), clients sending no
Accept at all, health checks, and link unfurlers received text/x-component
instead of the page. The rule also disagreed with document-cache's HTML/RSC
cache-key split on a missing Accept header, storing a flight body under the
:html slot.

Flight now requires explicit opt-in: the _rsc_*/__rsc transport params
(unchanged) or Accept: text/x-component ranked above text/html. The rule
lives in one shared predicate (acceptsFlightExplicitly) used by both
mayNeedSSR and isRscRequest, ranking via the same parseAcceptTypes the
variant picker uses. In the negotiation table, the RSC candidate registers
under both of its representations (text/html and text/x-component via
RSC_MIMES), so an explicit wire-format Accept selects RSC deterministically
even on routes where a response variant is defined first.

Consumer-visible changes:
- curl / no Accept / */* on any RSC route: HTML document (was flight)
- Accept: application/json on a route with no JSON variant: HTML (was flight)
- Accept: text/x-component on a JSON-first negotiated route: flight (was JSON)
- PPR: bare curl now sees x-rango-shell directly
- Unchanged: browser requests, _rsc_partial/_rsc_loader/rsc-action/__rsc
  transports, definition-order fallback for */* on negotiated routes

Docs: mime-routes skill rewritten around the two-representation model;
ppr skill bare-curl gotcha removed (now obsolete).
@ivogt
ivogt force-pushed the fix/flight-explicit-opt-in branch from 5c010bc to bd923b3 Compare July 6, 2026 18:10
@ivogt
ivogt merged commit bc054c1 into main Jul 6, 2026
34 checks passed
@ivogt
ivogt deleted the fix/flight-explicit-opt-in branch July 6, 2026 18:20
ivogt added a commit that referenced this pull request Jul 6, 2026
…he shared Playwright webServers (#711)

Two clones of this repo running e2e simultaneously collide on the fixed
fixture ports (rangojs-router 5188/5189 + host 5296/5297, cloudflare-basic
5198/5199): reuseExistingServer silently runs one checkout's tests against
the OTHER checkout's server (wrong code, no error), and each run's cleanup
kills the other's servers mid-flight. Bit hard during PR #705 verification.

RANGO_E2E_PORT_OFFSET (default 0 — no behavior change) shifts every fixture
port uniformly in both configs and in host-routing.test.ts (which builds
absolute URLs from the same constants), preserving the deliberate
cross-suite port spacing. Documented in AGENTS.md Environment gotchas.

Verified: offset 400 boots servers on 5588/5589/5696/5697 and 5599/5598;
content-negotiation (router dev, 23 passed) and cache-signal (cf-basic dev)
green against the shifted ports; default runs unchanged.
ivogt added a commit that referenced this pull request Jul 7, 2026
…nameless-ppr coverage (closes #714, #715)

resolvePprConfig -> ShellCaptureDescriptor -> captureShellHTML maxWaitMs,
bounding BOTH the fizz prerender and the deferred-material settle window.
The freeze is ORDERED behind full handle-stream convergence: the gate
never fires while a top-level push is pending, staged/chained pushes
(immediate + slow + Meta chained off the slow one) converge to a single
yield, and budget expiry with pending pushes yields a body-less prelude
-> sanity-gate refusal — a partial-prefix bake (wrong/missing meta in
the snapshot) is unrepresentable. Producer B and the dev /__rsc_shell
read-through honor the same knob; the dev fetch bound now models the
FULL server envelope (probe + 2x budget + retry margin — the old
2x+10s bound was exceedable even at defaults, aborting healthy captures
into spurious MISSes). workerd waitUntil (~30s) documented as ceiling.

DSL stores ppr on the synthesized $path_* entry regardless of name
(path-helper.ts); the field symptom was a stale-server artifact plus
the pre-#705 Accept-negotiation silence. Nameless param-bearing ppr
fixtures + MISS->HIT e2e now pin the combination in both apps.

Red->green: slow-meta fixture (5.5s push + 1s chained Meta) with the
plumb reverted stays eternal-MISS with the once-per-key warning;
restored, it HITs with the resolved chained <title> frozen in the
stored prelude. The no-knob sibling remains the permanent in-tree red
pin. Validated end-to-end in a real storefront (7s deferred meta chain,
captureTimeout: 12000 -> HIT at 16s, baked title byte-frozen across
HITs, package restored after).

Simplify review applied (6): shared normalizeCaptureTimeout (was
3 copies), complete dev fetch-bound formula w/ hoisted
DEV_SHELL_PROBE_TIMEOUT_MS, 2 duplicate fixture components deleted,
comment triplication collapsed, stale timing comment fixed, duplicate
6.5s live-path test folded into the MISS body read. Skipped (noted):
test-local splitPrelude convention; cross-app fixture duplication
(different presets/negatives — deliberate).

Batteries: router unit 5112; shell-cache+capture-timeout 33 dev/34
prod; cloudflare ppr suites 30 dev/28 prod; typecheck/lint/format/
bucketing/docs-api green.
ivogt added a commit that referenced this pull request Jul 7, 2026
…nameless-ppr coverage (closes #714, #715) (#716)

resolvePprConfig -> ShellCaptureDescriptor -> captureShellHTML maxWaitMs,
bounding BOTH the fizz prerender and the deferred-material settle window.
The freeze is ORDERED behind full handle-stream convergence: the gate
never fires while a top-level push is pending, staged/chained pushes
(immediate + slow + Meta chained off the slow one) converge to a single
yield, and budget expiry with pending pushes yields a body-less prelude
-> sanity-gate refusal — a partial-prefix bake (wrong/missing meta in
the snapshot) is unrepresentable. Producer B and the dev /__rsc_shell
read-through honor the same knob; the dev fetch bound now models the
FULL server envelope (probe + 2x budget + retry margin — the old
2x+10s bound was exceedable even at defaults, aborting healthy captures
into spurious MISSes). workerd waitUntil (~30s) documented as ceiling.

DSL stores ppr on the synthesized $path_* entry regardless of name
(path-helper.ts); the field symptom was a stale-server artifact plus
the pre-#705 Accept-negotiation silence. Nameless param-bearing ppr
fixtures + MISS->HIT e2e now pin the combination in both apps.

Red->green: slow-meta fixture (5.5s push + 1s chained Meta) with the
plumb reverted stays eternal-MISS with the once-per-key warning;
restored, it HITs with the resolved chained <title> frozen in the
stored prelude. The no-knob sibling remains the permanent in-tree red
pin. Validated end-to-end in a real storefront (7s deferred meta chain,
captureTimeout: 12000 -> HIT at 16s, baked title byte-frozen across
HITs, package restored after).

Simplify review applied (6): shared normalizeCaptureTimeout (was
3 copies), complete dev fetch-bound formula w/ hoisted
DEV_SHELL_PROBE_TIMEOUT_MS, 2 duplicate fixture components deleted,
comment triplication collapsed, stale timing comment fixed, duplicate
6.5s live-path test folded into the MISS body read. Skipped (noted):
test-local splitPrelude convention; cross-app fixture duplication
(different presets/negatives — deliberate).

Batteries: router unit 5112; shell-cache+capture-timeout 33 dev/34
prod; cloudflare ppr suites 30 dev/28 prod; typecheck/lint/format/
bucketing/docs-api green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant