Repository navigation
fix(router): RSC flight wire format becomes explicit opt-in — generic clients get the HTML document - #705
Merged
Conversation
commit: |
… 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
force-pushed
the
fix/flight-explicit-opt-in
branch
from
July 6, 2026 18:10
5c010bc to
bd923b3
Compare
3 tasks done
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.
This was referenced Sep 20, 2026
e2e: reuseExistingServer can silently attach to a foreign project's dev server on the same port
#863
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
curl https://app.example.com/products/1returned the RSC flight wire format (text/x-component) instead of the page. The render layer classified any Accept header lacking the literal substringtext/htmlas a flight request — so curl (Accept: */*), clients sending no Accept header,Accept: application/jsonon 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 thex-rango-shellheader 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:303keys HTML-vs-RSC response slots viamayNeedSSR, but the response body was decided byisRscRequest— 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:htmlcache key.Accept: text/x-componentfell through to the definition-order fallback and answered with the variant (JSON) instead of the flight stream.Before vs after
Fetching the flight payload now requires the explicit wire-format Accept (scripts relying on bare curl for flight must add it):
End-to-end consumer example
Semantics table
Accept: text/html/ browser stringsAccept: */*(curl default)Accept: application/json, no JSON variantAccept: text/x-componentAccept: text/html, text/x-component;q=0.5Accept: text/x-component, text/html;q=0.5_rsc_partial/_rsc_loader/rsc-action/__rsc*/*, variant defined first*/*, RSC defined firstAccept: text/x-component, variant defined first?__html=1overrideCode changes
src/rsc/ssr-setup.ts—mayNeedSSRandisRscRequestnow share oneacceptsFlightExplicitlypredicate. The Accept rule flips from "notext/htmlsubstring → flight" to "explicittext/x-component, ranked above the document, → flight". Aincludes()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.src/router/content-negotiation.ts— the RSC candidate registers under both of its representations via a data-levelRSC_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.tsalready importsRESPONSE_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-routesrewritten around the two-representation model (rules table + MIME mapping);ppr"Verifying it works" bare-curl gotcha removed — bare curl now seesx-rango-shelldirectly.Test plan
Accept: */*, no-Accept, andapplication/jsonreceivingtext/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,263under the old code). All pass with the fix.isRscRequestmatrix +mayNeedSSRcases incl. q-ranking,q=0refusal, wildcard-outranks, and a cross-predicate agreement test pinningisRscRequest === !mayNeedSSRacross the Accept matrix (src/rsc/__tests__/ssr-setup.test.ts);pickNegotiateVariantwire-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). Fulltest:unit:all: 5077+ tests pass.content-negotiation39/39; cloudflare-basiccontent-negotiation40/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.vite build+vite previewof the e2e test-app, includingx-rango-shell: HITon bare curl.check:e2e-bucketing; no*.gen.tsdrift (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:
isRscRequest): the 404 fallback plan hardcodesmode: "full-render"even for_rsc_partialnavigations (handler.tsRouteNotFoundError catch), so partial 404s reached the render decision withisPartial=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).isRscRequestnow also reads_rsc_partialfrom the URL — the canonical transport marker, same asmayNeedSSR— closing the class for any caller that loses the plan. Unit test pins it; multi-router + not-found suites green dev+prod.shell-cache.test.ts/ cf-basicppr-shell.test.ts"bypasses non-document requests" (wereAccept: */*), cf-basiccache-signal.test.tsmiss→hit (was default Accept). Verified the cache-signal miss→hit signal is a flight-transport contract:Accept: text/htmltwice was miss/miss before this PR too — browsers were always on that path;*/*clients now join it.ppr-shell:244andshell-cache:962failures were serial knock-on from the above; both pass in full local suite runs.Notes
Accept: text/x-component(or?__rsc=1). The router's own client runtime is unaffected — it always marks flight requests with transport params.Accept: text/html;q=0still counts as accepting HTML (pre-existing substring limitation, out of scope);*/*definition-order fallback on negotiated routes; emittedtext/x-component;charset=utf-8response-header literals across handlers (pre-existing duplication, separate concern from the matching MIME).dispatch) cannot reach trie-driven variant selection or the HTML/flight render decision (pre-existing limitation documented insrc/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.