Repository navigation
feat(router)!: bare-T JSON responses + RFC 9457 problem+json errors - #539
Merged
Merged
Conversation
ivogt
force-pushed
the
feat/bare-t-json-responses
branch
from
June 7, 2026 14:44
c92a59a to
ab548b5
Compare
commit: |
ivogt
force-pushed
the
feat/bare-t-json-responses
branch
from
June 7, 2026 15:07
ab548b5 to
cb0c945
Compare
JSON response routes (path.json) now serialize the handler's return value
verbatim — no { data } envelope — and return a bare RFC 9457
application/problem+json body on non-2xx (replacing the { error } envelope).
- Success wire: JSON.stringify(result) at the handler's status (was { data }).
- Error wire: { type, title, status, detail, code, stack?(dev) } with
content-type application/problem+json (was { error: {...} }).
- Types: ResponseError -> ProblemDetails; ResponseEnvelope + isResponseError
removed; RouteResponse/PathResponse resolve to the bare payload (the
_responses phantom inference chain is unchanged); dead RouterError.type dropped.
- createResponseErrorPayload -> createProblemDetails (adds status->phrase map,
default code "INTERNAL" for non-RouterError; dev/prod gating preserved).
Breaking wire change. Pre-release: removed, not deprecated. Migrates e2e
suites (dev + production), example apps, skills, and internal docs.
ivogt
force-pushed
the
feat/bare-t-json-responses
branch
from
June 7, 2026 15:18
cb0c945 to
4c79cce
Compare
ivogt
added a commit
that referenced
this pull request
Jun 7, 2026
…539) main #539 changed the JSON response-route wire contract — bare handler value on success and an RFC 9457 problem+json body on error, dropping the { data }/{ error } envelope and RouterError.type. The testing module's dispatch() primitive was added on this branch, so #539 could not touch it; it mirrors handleResponseRoute and still emitted the old envelope (and imported the renamed-away createResponseErrorPayload). Bring it onto the merged contract, and close a pre-existing handler-returned-Response parity gap found in review. - src/testing/dispatch.ts: json success serializes the bare value (application/json); json error emits createProblemDetails(...) as application/problem+json. Resolve the effective ctx.setStatus() override BEFORE building the problem body so body.status/title track the actual HTTP status, matching handleResponseRoute's catch (P2). Import createResponseErrorPayload -> createProblemDetails. - src/testing/dispatch.ts: a handler that RETURNS a Response now goes through rewrapHandlerResponse, byte-identical to handleResponseRoute's rewrapResponse — WebSocket-upgrade bypass via mergeStubHeadersAndFinalize (status 101 throws in the Response constructor; the webSocket prop is otherwise lost), a Set-Cookie-preserving header rebuild, and statusText dropped — instead of the generic re-wrap that preserved statusText and reconstructed upgrades. - Tests (router unit + cloudflare-basic dogfood): assert bare values and the problem+json shape (title/status/detail/code, type omitted); drop the removed RouterError `type` option; add a P2 effective-status test; add 5 tests pinning the returned-Response rewrap (multiple Set-Cookie + statusText drop, setStatus override, stub-header merge, WebSocket pass-through without reconstruction). - Docs/skills (docs/testing.md, skills/testing/{response-routes,setup}.md, cloudflare-basic FINDINGS): bare value + problem+json. - feature-file-map.md conflict: keep main's updated Response-route note AND the branch's Consumer testing primitives row. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3487 + 3 todo, cloudflare-basic 39, vite-rsc-demo 15, mini 16, all RSC suites), lint 0/0, format clean. Two adversarial multi-lens review passes (bare-T migration, then the returned-Response fix) confirmed parity and surfaced no in-scope defects.
ivogt
added a commit
that referenced
this pull request
Jun 7, 2026
…539) main #539 changed the JSON response-route wire contract — bare handler value on success and an RFC 9457 problem+json body on error, dropping the { data }/{ error } envelope and RouterError.type. The testing module's dispatch() primitive was added on this branch, so #539 could not touch it; it mirrors handleResponseRoute and still emitted the old envelope (and imported the renamed-away createResponseErrorPayload). Bring it onto the merged contract, and close a pre-existing handler-returned-Response parity gap found in review. - src/testing/dispatch.ts: json success serializes the bare value (application/json); json error emits createProblemDetails(...) as application/problem+json. Resolve the effective ctx.setStatus() override BEFORE building the problem body so body.status/title track the actual HTTP status, matching handleResponseRoute's catch (P2). Import createResponseErrorPayload -> createProblemDetails. - src/testing/dispatch.ts: a handler that RETURNS a Response now goes through rewrapHandlerResponse, byte-identical to handleResponseRoute's rewrapResponse — WebSocket-upgrade bypass via mergeStubHeadersAndFinalize (status 101 throws in the Response constructor; the webSocket prop is otherwise lost), a Set-Cookie-preserving header rebuild, and statusText dropped — instead of the generic re-wrap that preserved statusText and reconstructed upgrades. - Tests (router unit + cloudflare-basic dogfood): assert bare values and the problem+json shape (title/status/detail/code, type omitted); drop the removed RouterError `type` option; add a P2 effective-status test; add 5 tests pinning the returned-Response rewrap (multiple Set-Cookie + statusText drop, setStatus override, stub-header merge, WebSocket pass-through without reconstruction). - Docs/skills (docs/testing.md, skills/testing/{response-routes,setup}.md, cloudflare-basic FINDINGS): bare value + problem+json. - feature-file-map.md conflict: keep main's updated Response-route note AND the branch's Consumer testing primitives row. Post-merge review polish (review of the whole testing module): - RSC config doc template + the 3 in-repo vitest.rsc.config.ts now carry the mandatory server.deps.inline (rangoInlineDeps) — an installed RSC consumer on Node >= 23 otherwise hit ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. - runMiddleware opts.request is now optional (parity with runLoader / runInRequestContext; defaults to http://localhost/). Type-only — emitted JS unchanged. - JSDoc/doc notes: flight-tree pool:"forks" requirement, renderRoute handle $$id mutation + persistent loaderData across navigate(), stale vi.mock cross-reference in the response-routes skill. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3487 + 3 todo, cloudflare-basic 39, vite-rsc-demo 15, mini 16, all RSC suites), lint 0/0, format clean. Two adversarial multi-lens review passes (bare-T migration, then the returned-Response fix) confirmed parity and surfaced no in-scope defects.
ivogt
added a commit
that referenced
this pull request
Jun 7, 2026
…539) main #539 changed the JSON response-route wire contract — bare handler value on success and an RFC 9457 problem+json body on error, dropping the { data }/{ error } envelope and RouterError.type. The testing module's dispatch() primitive was added on this branch, so #539 could not touch it; it mirrors handleResponseRoute and still emitted the old envelope (and imported the renamed-away createResponseErrorPayload). Bring it onto the merged contract, and close a pre-existing handler-returned-Response parity gap found in review. - src/testing/dispatch.ts: json success serializes the bare value (application/json); json error emits createProblemDetails(...) as application/problem+json. Resolve the effective ctx.setStatus() override BEFORE building the problem body so body.status/title track the actual HTTP status, matching handleResponseRoute's catch (P2). Import createResponseErrorPayload -> createProblemDetails. - src/testing/dispatch.ts: a handler that RETURNS a Response now goes through rewrapHandlerResponse, byte-identical to handleResponseRoute's rewrapResponse — WebSocket-upgrade bypass via mergeStubHeadersAndFinalize (status 101 throws in the Response constructor; the webSocket prop is otherwise lost), a Set-Cookie-preserving header rebuild, and statusText dropped — instead of the generic re-wrap that preserved statusText and reconstructed upgrades. - Tests (router unit + cloudflare-basic dogfood): assert bare values and the problem+json shape (title/status/detail/code, type omitted); drop the removed RouterError `type` option; add a P2 effective-status test; add 5 tests pinning the returned-Response rewrap (multiple Set-Cookie + statusText drop, setStatus override, stub-header merge, WebSocket pass-through without reconstruction). - Docs/skills (docs/testing.md, skills/testing/{response-routes,setup}.md, cloudflare-basic FINDINGS): bare value + problem+json. - feature-file-map.md conflict: keep main's updated Response-route note AND the branch's Consumer testing primitives row. Post-merge review polish (review of the whole testing module): - RSC config doc template + the 3 in-repo vitest.rsc.config.ts now carry the mandatory server.deps.inline (rangoInlineDeps) — an installed RSC consumer on Node >= 23 otherwise hit ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. - runMiddleware opts.request is now optional (parity with runLoader / runInRequestContext; defaults to http://localhost/). Type-only — emitted JS unchanged. - JSDoc/doc notes: flight-tree pool:"forks" requirement, renderRoute handle $$id mutation + persistent loaderData across navigate(), stale vi.mock cross-reference in the response-routes skill. - docs/testing.md "deliberately don't cover" trap table completed with 3 more false-confidence traps: "use cache" without a seeded cacheStore (silently uncached), whole-app router import blocked by Prerender/createLoader $$id, and the ctx.rendered() barrier-wiring loader trap. Capability polish (making these fail loud + full-router import) tracked in the #533 follow-up issue. Gate: typecheck 12/12, test:unit:all EXIT=0 (router 3487 + 3 todo, cloudflare-basic 39, vite-rsc-demo 15, mini 16, all RSC suites), lint 0/0, format clean. Two adversarial multi-lens review passes (bare-T migration, then the returned-Response fix) confirmed parity and surfaced no in-scope defects.
8 tasks done
ivogt
added a commit
that referenced
this pull request
Jun 10, 2026
The cache-tag e2e helpers read (await res.json()).data.ts from path.json routes, but #539 (bare-T JSON responses) removed the { data } envelope: path.json now serializes the handler's return value verbatim. The tagged routes return a bare { ts, id }, so .data was undefined and reading .ts threw "Cannot read properties of undefined (reading 'ts')". This broke the cache-tag invalidation suites in four places (router dev + production, cloudflare-basic dev + production). On the rangojs-router shards the failing test's 8s expect.poll retries also destabilized the shard, cascading into is-action and cache-loader-null timeouts; fixing the root read clears those too. - e2e/cache.test.ts: pollTs reads .ts (dev + production helpers) - cloudflare-basic/e2e/cache-tag.test.ts: ts() reads .ts - urls/cache-tag.tsx: correct the stale { data } envelope comment - docs/internal/feature-map.md: oxfmt table padding Verified locally: router dev cache-tag (6 passed), router production cache-tag + is-action + cache-loader-null (9 passed), unit (3501), typecheck, lint, format.
ivogt
added a commit
that referenced
this pull request
Jun 11, 2026
…534) * feat(cache): tag invalidation — cacheTag / updateTag / revalidateTag Adds runtime cache-tag invalidation across the in-memory and Cloudflare cache stores. Public API (server-only, from @rangojs/router): - cacheTag(...tags): tag the current "use cache" entry at runtime. - updateTag(...tags): awaitable, read-your-own-writes invalidation for Server Actions (resolves after in-process invalidation completes; on CFCacheStore, rejects if a durable KV marker write fails instead of reporting a false success). - revalidateTag(...tags): fire-and-forget background invalidation for route handlers / webhooks. Like updateTag it hard-purges; the only difference is awaitability (it is NOT stale-while-revalidate). Tags attach via three axes: runtime cacheTag() inside "use cache", cache({ tags }) on the route DSL, and loader cache tags. A per-handler explicit-store registry (LRU-bounded) lets updateTag/revalidateTag reach every configured store, including per-scope cache({ store }) boundaries. Stores: - MemorySegmentCacheStore: forward + reverse tag index; eager delete on invalidate. - CFCacheStore: per-tag KV markers compared against an entry's taggedAt; optional per-colo L1 marker cache (tagCacheTtl) and onRevalidateTag CDN purge hook; tags encodeURIComponent'd so commas / non-Latin1 stay header-safe; over-512-byte marker keys and failed KV writes surface as a rejected updateTag rather than silent stale-serve. Document caching: createDocumentCacheMiddleware tags full-page entries with the request's resolved tags, snapshotted after the render stream drains so Suspense-streamed cacheTag()/"use cache" tags are captured, so updateTag/revalidateTag invalidate cached pages. Docs and skills updated. e2e covers cacheTag / cache(tags) / updateTag / revalidateTag and document invalidation in dev and production on both the memory and the CF + KV stores. Known limitation: combining whole-page document caching with segment cache({ tags }) DSL tags can miss the document-level tag (resolved in the deferred cacheRoute waitUntil); the segment entry itself still invalidates. * perf+docs(cache): parallelize L1 marker write-through; document revalidateTag/keyGenerator contracts From a 4-dimension (correctness/performance/consistency/security) review of the branch: - perf: invalidateTags wrote the per-colo L1 tag markers sequentially (await per tag inside the loop, only when tagCacheTtl>0). The memo write stays synchronous (read-your-own-writes); the independent L1 Cache API writes now fan out via Promise.all instead of stalling on each other. - docs (security): revalidateTag JSDoc now states that, being fire-and-forget, a failed durable marker write is NOT surfaced to the caller (logged only) - use await updateTag() when the invalidation must be confirmed/retried. - docs (security): CFCacheStore.keyGenerator now documents the reserved __tag__/ and __tagmarker__/ prefixes a returned key must not collide with (tag markers share the data KV namespace). The review's other confirmed items were verified as deliberate, documented trade-offs and intentionally not changed: read-side isGloballyInvalidated fail-open (availability over a KV-outage stampede), unbounded tag cardinality and the marker/data key-collision (documented; key-prefix hard-namespacing would invalidate existing caches), and the segment-DSL document-tag race (documented caveat). Consistency + most security probes verified clean (naming, normalization symmetry, header encode/decode, no injection/poisoning). * fix(cache): report cache errors via onError + self-heal corrupt entries Audited every cache read/write/parse path (67 sites). The resilience contract is now: report loudly via the router's onError hook, degrade (never throw), and evict corrupt/partial entries. - onError routing: a new reportCacheError(error, category) helper logs (loud, works in background tasks) AND routes through the request context's _reportBackgroundError -> router onError (deduped). Both stores used only console.error before, so a consumer's onError never saw cache read/write failures. All store + document-cache + cache-runtime/scope sites now route through it with a category (cache-read/-write/-delete/-corrupt/-invalidate). The non-blocking L1 write (waitUntil) previously swallowed its rejection entirely; it now reports too. - Self-heal on corrupt/partial: CF reads distinguish a PARSE failure (truncated Cache API body, malformed KV envelope, bad base64) from a transient infra error. On corruption the faulty entry is EVICTED (cache.delete / kv.delete) and reported as cache-corrupt, so subsequent reads miss + re-render instead of re-failing until TTL; a transient infra error degrades to a miss WITHOUT deleting a still-good entry. New parseOrEvict / kvGetOrEvict helpers + envelope shape validation. cache-runtime + cache-scope report corrupt deserializes (and cache-scope evicts; use-cache items self-heal via the miss-path overwrite). - Degrade hardening: memory putResponse's un-guarded arrayBuffer() now degrades to a no-op instead of throwing. The deliberate exception is unchanged: updateTag -> invalidateTags still REJECTS on a failed durable marker write (read-your-own-writes honesty), not swallowed. Tests: infra read error -> reports cache-read + null; corrupt L1 entry -> evicted + cache-corrupt; corrupt KV envelope -> kv.delete + cache-corrupt; write error -> cache-write. * fix(cache): harden cache-error resilience (transient vs corrupt, observability) Review-driven hardening of the cache-tag error path. Three substantive fixes plus consistency/typing nits, all backed by regression tests. Correctness / availability: - kvGetOrEvict no longer deletes a still-good cross-colo KV entry on a transient outage. It read via kv.get(key, {type:"json"}), which fused the network read and the JSON parse, so a 5xx/429/network blip was treated as corruption and evicted the entry (self-inflicted cross-colo miss storm). Now reads as text: a transient error propagates to the caller's outer catch (reported cache-read, entry intact); only a JSON.parse/validate failure is true corruption -> evict. - kvGetResponse: the full Response reconstruction (base64 + headers + status) is now inside one corrupt-guard, so malformed `hd`/out-of-range `st` self-heals too; eviction goes through evictKvKey (reports cache-delete instead of silently swallowing the delete failure). - Reserved-namespace guard: a misconfigured store keyGenerator returning a key under a reserved tag-marker prefix - `__tag__/` (KV marker key) or `__tagmarker__/` (L1 Cache API marker request) - can no longer clobber (on set) or evict (on get/delete) a live tag marker. The segment op is a reported no-op/miss, naming which prefix collided. Observability: - revalidateTag now routes a failed background invalidation through reportingAsync (loud log + onError, category cache-invalidate) instead of swallowing it; the JSDoc claim is now true. Passes the captured ctx since the run is detached. - The stale-while-revalidate ("use cache") failure goes through reportCacheError (loud) with the captured ctx, and `stale-revalidation` is now a typed category. - reportCacheError/reportingAsync accept an explicit ctx for detached waitUntil tasks where the ALS context is gone, so onError still fires. The CF store's own background writes deliberately omit it (request-agnostic singleton -> console loud only); a comment documents the asymmetry. - CacheErrorCategory is exported from `@rangojs/router/cache`, the _reportBackgroundError signature is typed to it, and onError's metadata.category is documented. - cache-scope segment-write failure now uses reportCacheError, not console.error. #2 (L1 document body served verbatim): documented as a deliberate asymmetry - hashing the body would require buffering the whole document on every hit, defeating streaming; integrity relies on the Cache API, and the KV tier (the real partial-read vector) is structurally validated. Tests: KV transient-read -> miss WITHOUT eviction (#1 guard) + L1 transient non-eviction assertion; per-tag L1 marker write-through fan-out; reserved-prefix get/set/delete for both __tag__/ and __tagmarker__/; revalidateTag failure -> onError; non-CF (CacheScope) corrupt segment self-heal. * fix(cache): address tag-invalidation review findings - record cached entry tags into the request tag union on segment-cache hits, so a document re-cached over a segment hit stays tag-invalidatable; narrow the document-cache caveat to the first-write waitUntil race - gate the invalidateTags memo/L1 marker write-through on KV (markers are never read without it) and update the stale constructor comment - warn with the real cause when updateTag/revalidateTag run outside a request context (queue consumer / cron) instead of the store-config message - reword revalidateTag as background hard-purge (not SWR) in consumer JSDoc, the export comment, and the unit test title - annotate URL-param tag e2e fixtures as test-only (untrusted-input warning) - unit tests pin hit-path tag recording, the no-KV no-op, and the no-context warnings; unique namespace for the no-KV warn fixture * fix(cache): adapt cache-tag e2e to bare-T JSON responses (#539) The cache-tag e2e helpers read (await res.json()).data.ts from path.json routes, but #539 (bare-T JSON responses) removed the { data } envelope: path.json now serializes the handler's return value verbatim. The tagged routes return a bare { ts, id }, so .data was undefined and reading .ts threw "Cannot read properties of undefined (reading 'ts')". This broke the cache-tag invalidation suites in four places (router dev + production, cloudflare-basic dev + production). On the rangojs-router shards the failing test's 8s expect.poll retries also destabilized the shard, cascading into is-action and cache-loader-null timeouts; fixing the root read clears those too. - e2e/cache.test.ts: pollTs reads .ts (dev + production helpers) - cloudflare-basic/e2e/cache-tag.test.ts: ts() reads .ts - urls/cache-tag.tsx: correct the stale { data } envelope comment - docs/internal/feature-map.md: oxfmt table padding Verified locally: router dev cache-tag (6 passed), router production cache-tag + is-action + cache-loader-null (9 passed), unit (3501), typecheck, lint, format. * harden(cache): fold post-reconcile review findings into CFCacheStore Adversarial review of the #534<->#558 reconcile surfaced ten findings; this folds the worthwhile ones in before the single CI run (cheaper than a second follow-up round). Read-your-own-writes / correctness: - fetchTagMarker no longer clobbers a concurrent invalidateTags() memo write that lands mid-read, so a parallel-slot updateTag() stays read-your-own-writes. - Per-store isolation of the tag-marker memo/inflight maps: two stores in one request (app store + a cache({ store }) override on a different KV binding) can no longer cross-pollute marker values. - kvGetOrEvict guards a KV body that parses to null/a primitive BEFORE validate(), so 'null'/'42' are evicted as corrupt instead of throwing past the validator into a transient cache-read that never self-heals. - Non-array tags fail safe (Array.isArray at the write guards and the central isGloballyInvalidated check) instead of throwing .map on every read. Fail-safe reads/writes (no perf degradation): - Corrupt-entry KV eviction is dispatched via waitUntil, never awaited on the read path (an unbounded kv.delete would re-add exactly the stall the read budgets exist to prevent). - The transient L1 tag-marker match error is reported as cache-read like the data read paths, instead of being silently discarded at the destructuring. - The L1 marker write-through failure on invalidate is surfaced (cache-invalidate) and the stale marker evicted, instead of swallowed while updateTag reports success. DX / hygiene: - tagInvalidationTtl below KV's 60s expirationTtl floor is raised (with a one-time warning) so a too-small value no longer makes EVERY invalidation throw; a non-finite tagCacheTtl is coerced to 0 (disabled) rather than silently enabling an invalid max-age=Infinity marker. - Remove the now-dead parseOrEvict (replaced during the reconcile by readJsonWithTimeout/healCorruptL1 + kvGetOrEvict). Observability + API completeness (review below-the-cut items): - New `markerMs` debug field: the tagged-read marker-resolution tail (memo -> L1 marker cache -> KV) is no longer invisible in CFCacheReadDebugEvent (measured only under debug). New `match-error` debug outcome so a match() rejection is distinct from a genuine l1-miss and agrees with the cache-read on onError. - Export CacheItemOptions / CacheItemResult from ./cache so a custom SegmentCacheStore author can name every type its getItem/setItem methods use. Docs/comments corrected: caching skill corrupt-entry self-heal table (serves from L2, evicts only when no L2 copy), healCorruptL1 cross-request timeout note, the tag-invalidated tier-divergence comment, and a tagCacheTtl same-colo race caveat. Tests: +15 (11 proven red-before-green); strengthened the corrupt-L1 heal-by-overwrite and fail-open assertions to be non-vacuous; the fail-open mock keys on the exported TAG_MARKER_PREFIX so a prefix rename can't make it pass vacuously; added real-timer teardown to both CF suites. Unit 3562 green; cache-tag e2e green dev+production against the real CFCacheStore. * chore(cache): raise CFCacheStore default kvReadTimeoutMs 50 -> 170 [skip ci] The L2/KV read budget defaulted to 50ms, right at the documented healthy read, which false-misses legitimate latency tails (larger payloads, far-from-colo regions) into a full render. Raise the default to 170ms: a few multiples above the ~50ms healthy read for headroom, still well under a degraded namespace's multi-second tail. Per-store override (kvReadTimeoutMs) and the <=0-disables contract are unchanged; the tag-marker KV reads inherit the same budget. Unit tests reference the KV_READ_TIMEOUT_MS constant (not a literal 50) so they adapt; the explicit-override tests (200, 0) are independent. SKILL default table and the option JSDoc updated to 170. * docs(cache): document markerMs/match-error debug surface + tag-marker read-latency rationale [skip ci] - skills/caching/SKILL.md: bring the debug outcome list current with the full CFCacheReadDebugEvent union; document the markerMs marker-resolution diagnostic and match-error (distinct from l1-miss). - docs/internal/feature-map.md: add match-error + markerMs to the ./cache debug-sink description. - docs/design/cache-tags-flow.md: add a Tag-marker read latency section - measured baseline (1-6ms warm / ~73ms cold), why an in-isolate marker cache is rejected, and the tagCacheTtl -> marker-parallel ladder if latency must drop. Docs-only; the markerMs field and match-error outcome shipped in bcb8477. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]>
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 statement
JSON response routes (
path.json) silently wrapped every successful payload in a{ data: <value> }envelope and every thrown error in a bespoke{ error: { message, code?, type?, stack? } }envelope — both served as plainapplication/json. A consumer callingfetch(href("/api/products/1")).then(r => r.json())never got the product back directly; they got{ data: product }and had to reach through.data, then hand-roll discrimination on a truthy.errorfield to detect failure. The error envelope was non-idiomatic — it matched no standard, so nothing downstream (browsers, API explorers, error-monitoring, codegen) could recognize it as an error body. The wire shape leaked an internal framing decision into every consumer's call site.Before vs. after
Same route, same request — only the body shape and (on error) the content-type change.
Before — success and error both
application/json, both enveloped:After — success is the bare value (
application/json); error is RFC 9457 problem+json on the non-2xx response:curl, before vs. after on the same 404:
End-to-end consumer example
A realistic typed fetch + error branch, mirroring the migrated
cloudflare-basicApiDataDemo.tsx. Success uses the route's typed payload viaRango.PathResponse<T>(now bare — no envelope); failure readsProblemDetails:ProblemDetails:Named-route type inference (preserved)
Response payload types still flow by route name, purely through TypeScript inference over the
urls()value — no codegen, no manual augmentation.path.json()infers the handler's return type onto the route's_responsesphantom, andRouteResponse<typeof patterns, "<name>">reads it back keyed by the route'sname. Only the final wrapper changed (envelope → bare); the inference chain (JsonResponsePathFn→_responses→RouteResponse/PathResponse) is untouched.Because of that, the test-app's named-route typed client (
e2e/test-app/src/api-shop-client.ts) needed zero changes — its type aliases silently flipped from enveloped to bare:The only consumer churn was at call sites that read the old envelope:
ShopPlayground.tsxdropped itsresult.data.*reads toresult.*. The path-keyed ambient form —Rango.PathResponse<"/api/products/:id">— is unchanged in mechanism (still resolves onceRango.RegisteredRoutesis augmented, as before) and likewise now yields the bare payload. The type-level tests pinning this live inreverse-types.test.ts(scopedRouteResponse/PathResponse) andaugmented.check.ts(the augmentedRango.PathResponse).Semantics
jsonsuccessapplication/json<value>(was{ data: <value> })jsonthrown errorRouterError.statuselse 500application/problem+json(wasapplication/json){ title, status, detail, code, stack? }(was{ error: { message, code?, type?, stack? } })Response(any route)text/html/xml/mdsuccessString(value)text/html/xml/mdthrown errorRouterError.statuselse 500text/plain"Internal Server Error"_rsc_partial/ RSC navigationStatus derivation (
RouterError.statuselse 500) and dev/prod message gating (RouterError messages always exposed; standardErrormessages hidden in production) are unchanged — only the success framing and the error body/content-type changed.Code-change summary
The wire body is constructed in one runtime site on
main(a second, hand-written twin exists only onfeat/consumer-testing-primitives— see Notes):src/rsc/response-route-handler.ts(handleResponseRoute). Success now emitsJSON.stringify(result)instead ofJSON.stringify({ data: result }). The error branch emitsJSON.stringify(createProblemDetails(error, status, isDev))withcontent-type: application/problem+json;charset=utf-8, wherestatusis resolved through the samectx.res.statusoverridecreateResponseWithMergedHeadersapplies — so the problem body'sstatus/titlealways match the actual HTTP status (e.g. when a handler callsctx.setStatus()before throwing). The returned-Response/ text-MIME /_rsc_partialpaths are untouched.Error builder,
src/rsc/response-error.ts:createResponseErrorPayload(error, isDev)→createProblemDetails(error, status, isDev). Now takesstatusand returns aProblemDetails. Adds a complete 4xx/5xxSTATUS_PHRASESmap (no nodehttp.STATUS_CODESon edge/worker runtimes; non-standard statuses fall back to"Error") for thetitlemember, a defaultcode: "INTERNAL"for non-RouterErrorfailures so consumers can always branch oncode, and omits thetypemember (per RFC 9457 an absenttypeis treated as"about:blank", so emitting it adds nothing this phase). dev/prod message+stack gating is preserved verbatim.Types,
src/urls/type-extraction.ts+ barrels (src/index.ts,src/index.rsc.ts,src/urls/index.ts,src/href-client.ts):ResponseError→ProblemDetails(the RFC 9457 shape above).ResponseEnvelope<T>andisResponseErrorremoved.RouteResponse<TPatterns, TName>andPathResponse<TPath>now resolve to the bareJsonSerialize<...>payload — the envelope wrapper is gone. The_responsesphantom inference chain that feeds them is unchanged; only the final wrapper was swapped.Cleanup,
src/errors.ts:RouterError.typefield (and its constructor option) — it was never passed at any call site, never read, and never surfaced on the wire.Why now / why this shape: pre-release (
0.0.0-experimental.*) with no external adopter, so per the repo's API-hygiene rule we change the wire contract outright rather than ship a transitional envelope or deprecation shim. Bare-T + problem+json is the idiomatic, standard-aligned surface (res.ok+application/problem+jsonare recognized everywhere); a{ data }/{ error }envelope is framing we own and would have to justify forever.This is a breaking wire change. Any consumer reading
body.data/body.errorfrom a JSON response route must switch to the bare value +res.ok+ProblemDetails.Test plan
pnpm run test:unit(router): 3342 pass.response-route-handler.test.tspins the bare-success / problem+json-error bytes, status, and content-type — including a regression test that a handler callingctx.setStatus()before throwing yields a problem body whosestatus/titlematch the overridden HTTP status.reverse-types.test.ts/augmented.check.tspin the bareRouteResponse/PathResponseresolution and theProblemDetailsshape (with optionaltype).@rangojs/router+e2e/test-app+cloudflare-basic+cloudflare-stress-demo.pnpm run lint: 0 warnings / 0 errors.pnpm run format: clean.semantic-matrix(incl. the protected[RC1]/[RC2]cached/uncached response-route rows),manifest-cache,reverse-fallback,global-mw-response,api-client,content-ownership— exercising bare success bodies,application/problem+jsonerror bodies/status, the inverted double-unwrap row, and the untouched raw-Responsemiddleware error rows.ResponseEnvelope/isResponseError/createResponseErrorPayload/RouterError.type: 0 remaining.(production)Playwright project across rangojs-router, cloudflare-basic, and the example apps). All touched e2e suites carry their production counterparts (dev/prod parity preserved in the migration); the dev subset above is the local pre-push gate.Notes / call-outs
maindirectly (not stacked). Onmainthe wrap has a single runtime site (response-route-handler.ts). A hand-written twin —src/testing/dispatch.ts(serializeResponseRouteResult/serializeResponseRouteError), part of thedispatch()testing harness — exists only onfeat/consumer-testing-primitives. Follow-up required there: when that branch rebases onto this, itstesting/dispatch.tsmust take the identical bare-T + problem+json change and switchcreateResponseErrorPayload→createProblemDetails, and itsdispatch.test.ts/cloudflare-basic/test/dispatch.test.ts/ testing skills must migrate to the new contract. The change is ready to copy across; until applied, that branch would reintroduce the old envelope and a broken import.RouterError.typewas dead. Dropping it is pure cleanup. (The old error envelope'stypecame fromRouterError.typeand was almost always absent; the newProblemDetails.typeis a different, RFC-defined member, currently the constant"about:blank".)typeis omitted in Phase 1. Per RFC 9457 an absenttypeis treated as"about:blank"(no semantics beyond the HTTP status), so we don't emit it (andProblemDetails.typeis optional). Per-problem-type URIs land with the declared-errors map later.title/status/detail/codeare fully populated today, so consumers branch oncode(always present).api-client,cache/cache-isolation,response-cache,response-handler,content-negotiation,content-ownership,use-cache,timeout,auth-boundary,route-types,semantic-matrix,manifest-cache,reverse-fallback,global-mw-response,loader-fetchable-guard,origin-guard,prerender-ctx,basename-hmr,route-types-hmr,on-error), three example apps (test-app/ShopPlayground,cloudflare-basicApiDataDemo/router/urls,cloudflare-stress-demo/router) + their e2e, and docs/skills (response-routes,mime-routes,typesafety,feature-map,feature-file-map) were updated to the bare + problem+json pattern so no example endorses the old envelope.