Skip to content

feat(ppr): operability — snapshot size cap, capture debug sink, loud inert shell family - #708

Merged
ivogt merged 1 commit into
mainfrom
feat/ppr-operability-651
Jul 6, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/ppr-operability-651

Conversation

@ivogt

@ivogt ivogt commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #651.

Problem statement

Three PPR failure/opacity shapes were invisible from the consumer's seat. A page over a large cache() segment could grow the shell entry's capture data snapshot toward the store value limit (Cloudflare KV: 25 MiB per value) and the entry silently vanished — kv.put rejects deep inside waitUntil, so the route just never flipped to HIT. The capture pipeline itself (stored / no-shell / refused / backed-off, snapshot bytes, write-barrier waits) was observable only through scattered dev console warnings. And on Cloudflare without a KV binding, getShell/putShell no-op entirely: every ppr route is a permanent MISS with zero diagnostics.

Before vs. after

Before — no cap, no sink, silent no-op:

path("/products/:id", Page, { name: "product", ppr: { ttl: 600 } });
// capture pipeline: opaque; KV-less CF store: silent permanent MISS
const router = createRouter({ cache: (env, ctx) => ({ store: new CFCacheStore({ ctx }) }) });

After — per-route cap, structured sink, loud misconfiguration:

path("/products/:id", Page, {
  name: "product",
  ppr: { ttl: 600, maxSnapshotBytes: 4 * 1024 * 1024 }, // default 8 MiB
});

const router = createRouter({
  cache: (env, ctx) => ({ store: new CFCacheStore({ ctx, kv: env.CACHE_KV }) }),
  debugShellCapture: (e) => log.info("ppr-capture", e), // or `true` for console
});
// KV-less CFCacheStore now warns once per isolate, naming the fix.

End-to-end consumer example

import { createRouter, urls, type ShellCaptureDebugEvent } from "@rangojs/router";
import { CFCacheStore } from "@rangojs/router/cache";

export const urlpatterns = urls(({ path, loader, loading }) => [
  path(
    "/products/:id",
    ProductPage,
    // Catalog pages bake a large cache()'d chrome; cap the pinned snapshot
    // well under the KV value limit. Over cap the shell still serves — only
    // the byte-parity pin is dropped (drift repairs client-side).
    { name: "product", ppr: { ttl: 600, swr: 120, maxSnapshotBytes: 4 * 1024 * 1024 } },
    () => [loader(LivePriceLoader), loading(<PriceSkeleton />)],
  ),
]);

export default createRouter<AppBindings>({
  document: Document,
  urls: urlpatterns,
  cache: (env, ctx) => ({ store: new CFCacheStore({ ctx: ctx!, kv: env.CACHE_KV }) }),
  // Validate capture behavior on a real deployment via `wrangler tail`:
  debugShellCapture: true,
});

wrangler tail then shows lines like:

[ShellCache][debug] example.com/products/1:shell stored attempt=1 812ms barrier=3ms write-settle=12ms prelude=48213b snapshot=91544b
[ShellCache][debug] example.com/products/9:shell skip-backoff backoff-failures=2 backoff-remaining=1424ms

In dev with debugPerformance: true, the last capture outcome for a key also rides the NEXT document GET's Server-Timing as ppr-capture;dur=812.00;desc="stored attempt=1 …", next to a ppr:shell-read hit/miss entry for the serve-side store read.

Semantics table

Case Before After
Snapshot ≤ cap (default 8 MiB) stored with entry unchanged — stored with entry
Snapshot > cap entry rides toward the KV 25 MiB limit; over it, kv.put rejects invisibly and the route never flips to HIT snapshot skipped, shell stored WITHOUT it; warned once per key; on later HITs pinned reads fall back to the live store (documented drift; hydration repairs a mismatch client-side — the pre-snapshot behavior)
ppr: true / no maxSnapshotBytes n/a default 8 MiB cap; single defaulting site (captureAndStoreShell), both producers
debugShellCapture unset n/a no events; INTERNAL_RANGO_DEBUG=1 lights the console sink
debugShellCapture: false n/a off, even under INTERNAL_RANGO_DEBUG
CF store without kv, no ppr routes silent unchanged — silent (warning fires only from getShell/putShell, which only ppr routes call)
CF store without kv, ppr route served silent permanent MISS permanent MISS + once-per-isolate warning naming the fix (KV-less captures still run and no-op at the write — mechanism-level gating left as a follow-up, see Notes)
Capture scheduling/serving behavior — UNCHANGED — all three surfaces are diagnostics/limits only

Code-change summary

  • src/rsc/shell-capture.ts — the bulk: ShellCaptureDebugEvent/ShellCaptureDebug types + resolveShellCaptureDebugSink (owns the INTERNAL_RANGO_DEBUG fold; explicit false wins), per-attempt event publishing in runShellCapture (a CaptureAttemptStats bag rides through attemptCapture/captureAndStoreShell collecting barrier/write-settle/prelude/snapshot fields), skip/backoff events in scheduleShellCapture, the snapshot size guard in captureAndStoreShell (measure after the loader family is appended; over cap → drop snapshot, warn once per key), and the FIFO-capped dev buffer backing the Server-Timing mirror (takeCaptureDebugEventForTiming, consumed on read).
  • src/rsc/rsc-rendering.ts — descriptor threading (maxSnapshotBytes, debugSink), the dev ppr-capture Server-Timing mirror, and a metrics-gated ppr:shell-read metric. The mirror lives here because a capture completes only after its triggering response commits, so its outcome can only ride a later response's header.
  • src/cache/cf/cf-cache-store.ts — warnShellFamilyInertOnce via the existing warnOncePerNamespace pattern, fired from getShell/putShell when !this.kv (only ppr routes call the shell family, so a KV-less store in a non-PPR app stays silent — the reason this is not a constructor warning).
  • src/router/metrics.ts + src/server/context.ts — PerformanceMetric.desc, emitted as a Server-Timing quoted-string (delimiters escaped) and shown in the [RSC Perf] dev table.
  • src/urls/pattern-types.ts, src/rsc/shell-serve.ts — PartialPrerenderProps.maxSnapshotBytes, passed through resolvePprConfig undefaulted (like swr/tags); the single defaulting site is captureAndStoreShell (DEFAULT_PPR_MAX_SNAPSHOT_BYTES).
  • src/prerender/build-shell-capture.ts, src/vite/discovery/shell-prerender-phase.ts, src/vite/router-discovery.ts, src/rsc/shell-build-manifest.ts — producer B threading (build phase + dev /__rsc_shell maxSnapshotBytes param) so both producers apply one cap policy.
  • src/router/router-options.ts, src/router.ts, src/router/router-interfaces.ts, src/index.rsc.ts — the debugShellCapture option (joins the debug* family) and public type exports.
  • Docs: docs/design/ppr-shell-resume.md ("Operability" section), docs/internal/feature-map.md, docs/internal/feature-file-map.md, skills/ppr/SKILL.md (options table + verification bullets).

Test plan

  • Unit (src/rsc/__tests__/shell-capture.test.ts): over-cap snapshot skipped + shell still stored + exactly one report across recaptures (pins the issue's core contract); under-cap snapshot intact with the default cap; one debug event per attempt with sizes/waits (stored, and no-shell×2 on the retry path); a throwing sink never fails the capture; the dev Server-Timing buffer is consumed on read; skip-backoff event carries failure count + remaining window.
  • Unit (src/cache/cf/__tests__/cf-cache-store-shell.test.ts): KV-less getShell/putShell still no-op AND warn exactly once per isolate (second store instance does not re-warn) — pins the missing-KV contract.
  • Unit (src/router/__tests__/metrics.test.ts): desc emitted as an escaped quoted-string; desc-less metrics byte-identical to the pre-change output.
  • E2e, dev + production (e2e/shell-cache.test.ts via the shared runShellCacheSpec): new /shell-cache/snapshot-cap route (maxSnapshotBytes: 64) — the size-cap fallback still flips MISS→HIT and the HIT hydrates with zero hydration errors while the live hole advances (the issue's e2e ask). Full shell-cache suite green in both modes (27 dev + 1 known-flaky fixture dep-check retry, 28 production).
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, pnpm check:docs-api, pnpm check:e2e-bucketing — all green.
  • router.named-routes.gen.ts regenerated and committed (only the test-app's routes changed).

Notes

  • The cap measures the snapshot's serialized UTF-8 bytes (the JSON that rides in the envelope), not the whole envelope — prelude/postponed sizes are bounded by the page itself; the snapshot is the only unbounded rider.
  • A /simplify pass ran before this PR; notable applied cleanups: single defaulting site for the cap, INTERNAL_RANGO_DEBUG folded into the sink resolver, FIFO cap on the dev event buffer, metrics-gated timing reads on the request path.
  • Deliberate non-goals / follow-up candidates: (1) a store capability probe (hasShellFamily cannot see KV-lessness — methods exist on the prototype), which would also stop the doomed background captures a KV-less CF store still runs; (2) single-stringify snapshot measurement shared with the store envelope write (the VercelCacheStore precedent) — today the snapshot is serialized once for the cap check and once inside putShell, background-path only.
  • The debug events are diagnostics-only: no capture scheduling, storing, or serving decision reads them.

…inert shell family (closes #651)

Three diagnostics/limits surfaces for the PPR capture pipeline:

- ppr.maxSnapshotBytes (default 8 MiB, single defaulting site in
  captureAndStoreShell): an over-cap capture data snapshot is skipped —
  the shell still stores and serves (documented drift on later HITs) —
  reported once per key. Threaded through producer B and the dev
  /__rsc_shell endpoint so both producers apply one policy.
- createRouter({ debugShellCapture }) structured sink mirroring
  CFCacheDebug: one ShellCaptureDebugEvent per capture attempt/skip
  (outcome, attempt/barrier/write-settle ms, prelude/snapshot bytes,
  backoff state); INTERNAL_RANGO_DEBUG folds in inside the resolver.
  In dev the terminal event per key mirrors into the next ppr GET's
  Server-Timing (ppr-capture;desc=..., plus a ppr:shell-read metric)
  via the new PerformanceMetric.desc field.
- CFCacheStore warns once per isolate when a ppr route reads/writes
  its KV-less (inert) shell family, naming the fix — previously a
  silent permanent MISS.
@ivogt
ivogt force-pushed the feat/ppr-operability-651 branch from f6a1b9c to 2608a94 Compare July 6, 2026 19:37
@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@708

commit: 2608a94

@ivogt
ivogt merged commit 7446cd8 into main Jul 6, 2026
34 checks passed
@ivogt
ivogt deleted the feat/ppr-operability-651 branch July 6, 2026 19:46
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.

PPR operability: snapshot size guard, capture diagnostics, and the silent missing-KV no-op

1 participant