Skip to content

feat(router)!: bare-T JSON responses + RFC 9457 problem+json errors - #539

Merged
ivogt merged 1 commit into
mainfrom
feat/bare-t-json-responses
Jun 7, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/bare-t-json-responses

Conversation

@ivogt

@ivogt ivogt commented Jun 7, 2026 •

Copy link
Copy Markdown
Collaborator

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 plain application/json. A consumer calling fetch(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 .error field 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:

// success → 200 application/json  { "data": { "id": "1", "name": "Widget" } }
// error   → 404 application/json  { "error": { "message": "Product not found", "code": "NOT_FOUND" } }
const body = await fetch(href("/api/products/1")).then((r) => r.json());
if (body.error) {
  console.error(body.error.code, body.error.message); // discriminate on body shape
  return;
}
const product = body.data; // reach through the envelope

After — success is the bare value (application/json); error is RFC 9457 problem+json on the non-2xx response:

// success → 200 application/json          { "id": "1", "name": "Widget" }
// error   → 404 application/problem+json   { "title":"Not Found","status":404,"detail":"Product not found","code":"NOT_FOUND" }
const res = await fetch(href("/api/products/1"));
if (!res.ok) {
  const problem: ProblemDetails = await res.json();
  console.error(problem.code, problem.detail); // discriminate on res.ok / status
  return;
}
const product = await res.json(); // bare value, no envelope

curl, before vs. after on the same 404:

# before
$ curl -i https://app.example/api/products/999
HTTP/1.1 404 ...
content-type: application/json;charset=utf-8
{"error":{"message":"Product not found","code":"NOT_FOUND"}}

# after
$ curl -i https://app.example/api/products/999
HTTP/1.1 404 ...
content-type: application/problem+json;charset=utf-8
{"title":"Not Found","status":404,"detail":"Product not found","code":"NOT_FOUND"}

End-to-end consumer example

A realistic typed fetch + error branch, mirroring the migrated cloudflare-basic ApiDataDemo.tsx. Success uses the route's typed payload via Rango.PathResponse<T> (now bare — no envelope); failure reads ProblemDetails:

import { href } from "@rangojs/router/client";
import type { ProblemDetails } from "@rangojs/router";

const res = await fetch(href("/api/products/:id", { id }));

if (res.ok) {
  // bare value; PathResponse<"/api/products/:id"> resolves to the wire payload directly
  const product: Rango.PathResponse<"/api/products/:id"> = await res.json();
  setProduct(product); // product.name — no `.data`
} else {
  const problem: ProblemDetails = await res.json();
  // problem.code is always present: RouterError.code, else "INTERNAL"
  setError(`${problem.status} ${problem.title}: ${problem.detail}`);
}

ProblemDetails:

interface ProblemDetails {
  type?: string;   // omitted this phase (RFC 9457: an absent type ≡ "about:blank"); per-type URIs land later
  title: string;   // HTTP status reason phrase, e.g. "Not Found"
  status: number;  // RouterError.status, else 500
  detail: string;  // the error message (gated in prod for non-RouterError)
  code: string;    // RouterError.code, else "INTERNAL"
  stack?: string;  // dev only
}

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 _responses phantom, and RouteResponse<typeof patterns, "<name>"> reads it back keyed by the route's name. 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:

import type { RouteResponse } from "@rangojs/router";
import type { apiShopPatterns } from "./urls/api-shop";

// before:  ResponseEnvelope<{ products: Product[]; total: number }>
// after:   { products: Product[]; total: number }       ← bare, keyed by route name
type CatalogResponse = RouteResponse<typeof apiShopPatterns, "catalog">;
type ProductResponse = RouteResponse<typeof apiShopPatterns, "product">;

getCatalog(): Promise<CatalogResponse> {
  return fetchJson(url("apiShop.catalog")); // resolves to the bare payload — no `.data`
}

The only consumer churn was at call sites that read the old envelope: ShopPlayground.tsx dropped its result.data.* reads to result.*. The path-keyed ambient form — Rango.PathResponse<"/api/products/:id"> — is unchanged in mechanism (still resolves once Rango.RegisteredRoutes is augmented, as before) and likewise now yields the bare payload. The type-level tests pinning this live in reverse-types.test.ts (scoped RouteResponse/PathResponse) and augmented.check.ts (the augmented Rango.PathResponse).

Semantics

Case Status Content-Type Body Changed?
json success 200 application/json bare <value> (was { data: <value> }) Changed
json thrown error RouterError.status else 500 application/problem+json (was application/json) { title, status, detail, code, stack? } (was { error: { message, code?, type?, stack? } }) Changed
Returned Response (any route) as returned as returned passthrough, untouched Unchanged
text / html / xml / md success 200 mapped MIME String(value) Unchanged
text / html / xml / md thrown error RouterError.status else 500 text/plain message verbatim (RouterError), dev message (Error), else "Internal Server Error" Unchanged
_rsc_partial / RSC navigation — — Flight stream Unchanged

Status derivation (RouterError.status else 500) and dev/prod message gating (RouterError messages always exposed; standard Error messages 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 on feat/consumer-testing-primitives — see Notes):

  • src/rsc/response-route-handler.ts (handleResponseRoute). Success now emits JSON.stringify(result) instead of JSON.stringify({ data: result }). The error branch emits JSON.stringify(createProblemDetails(error, status, isDev)) with content-type: application/problem+json;charset=utf-8, where status is resolved through the same ctx.res.status override createResponseWithMergedHeaders applies — so the problem body's status/title always match the actual HTTP status (e.g. when a handler calls ctx.setStatus() before throwing). The returned-Response / text-MIME / _rsc_partial paths are untouched.

Error builder, src/rsc/response-error.ts:

  • createResponseErrorPayload(error, isDev) → createProblemDetails(error, status, isDev). Now takes status and returns a ProblemDetails. Adds a complete 4xx/5xx STATUS_PHRASES map (no node http.STATUS_CODES on edge/worker runtimes; non-standard statuses fall back to "Error") for the title member, a default code: "INTERNAL" for non-RouterError failures so consumers can always branch on code, and omits the type member (per RFC 9457 an absent type is 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> and isResponseError removed.
  • RouteResponse<TPatterns, TName> and PathResponse<TPath> now resolve to the bare JsonSerialize<...> payload — the envelope wrapper is gone. The _responses phantom inference chain that feeds them is unchanged; only the final wrapper was swapped.

Cleanup, src/errors.ts:

  • Dropped the dead RouterError.type field (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+json are 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.error from a JSON response route must switch to the bare value + res.ok + ProblemDetails.

Test plan

  • Unit — pnpm run test:unit (router): 3342 pass. response-route-handler.test.ts pins the bare-success / problem+json-error bytes, status, and content-type — including a regression test that a handler calling ctx.setStatus() before throwing yields a problem body whose status/title match the overridden HTTP status. reverse-types.test.ts / augmented.check.ts pin the bare RouteResponse/PathResponse resolution and the ProblemDetails shape (with optional type).
  • Typecheck — 0 errors across @rangojs/router + e2e/test-app + cloudflare-basic + cloudflare-stress-demo.
  • Lint — pnpm run lint: 0 warnings / 0 errors.
  • Format — pnpm run format: clean.
  • Dev e2e (representative subset) — 51 pass: 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+json error bodies/status, the inverted double-unwrap row, and the untouched raw-Response middleware error rows.
  • Stale-reference sweep for ResponseEnvelope / isResponseError / createResponseErrorPayload / RouterError.type: 0 remaining.
  • Full production e2e bracket — runs in CI (the (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

  • Targets main directly (not stacked). On main the wrap has a single runtime site (response-route-handler.ts). A hand-written twin — src/testing/dispatch.ts (serializeResponseRouteResult / serializeResponseRouteError), part of the dispatch() testing harness — exists only on feat/consumer-testing-primitives. Follow-up required there: when that branch rebases onto this, its testing/dispatch.ts must take the identical bare-T + problem+json change and switch createResponseErrorPayload → createProblemDetails, and its dispatch.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.type was dead. Dropping it is pure cleanup. (The old error envelope's type came from RouterError.type and was almost always absent; the new ProblemDetails.type is a different, RFC-defined member, currently the constant "about:blank".)
  • type is omitted in Phase 1. Per RFC 9457 an absent type is treated as "about:blank" (no semantics beyond the HTTP status), so we don't emit it (and ProblemDetails.type is optional). Per-problem-type URIs land with the declared-errors map later. title/status/detail/code are fully populated today, so consumers branch on code (always present).
  • Typed client / schemas / OpenAPI are deliberately deferred (Phase 2/3). This PR is the wire-contract foundation only; it does not add request/response schema validation or generated client types.
  • Migration scope. Beyond the runtime: router e2e suites (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-basic ApiDataDemo/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.

@ivogt
ivogt force-pushed the feat/bare-t-json-responses branch from c92a59a to ab548b5 Compare June 7, 2026 14:44
@pkg-pr-new

pkg-pr-new Bot commented Jun 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 4c79cce

@ivogt
ivogt force-pushed the feat/bare-t-json-responses branch from ab548b5 to cb0c945 Compare June 7, 2026 15:07
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
ivogt force-pushed the feat/bare-t-json-responses branch from cb0c945 to 4c79cce Compare June 7, 2026 15:18
@ivogt
ivogt merged commit 1f464cb into main Jun 7, 2026
25 checks passed
@ivogt
ivogt deleted the feat/bare-t-json-responses branch June 7, 2026 15:29
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.
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]>
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