@rangojs/router 0.0.0-experimental.147 → 0.0.0-experimental.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2520,7 +2520,7 @@ import { resolve } from "node:path";
2520
2520
  // package.json
2521
2521
  var package_default = {
2522
2522
  name: "@rangojs/router",
2523
- version: "0.0.0-experimental.147",
2523
+ version: "0.0.0-experimental.149",
2524
2524
  description: "Django-inspired RSC router with composable URL patterns",
2525
2525
  keywords: [
2526
2526
  "react",
@@ -4671,6 +4671,13 @@ import { readFileSync as readFileSync7 } from "node:fs";
4671
4671
  import { createRequire as createRequire3, register } from "node:module";
4672
4672
  import { pathToFileURL as pathToFileURL2 } from "node:url";
4673
4673
 
4674
+ // src/rsc/shell-serve.ts
4675
+ import React from "react";
4676
+ var DEV_SHELL_PROBE_TIMEOUT_MS = 1e4;
4677
+ function normalizeCaptureTimeout(value) {
4678
+ return typeof value === "number" && Number.isFinite(value) && value >= 1 ? value : void 0;
4679
+ }
4680
+
4674
4681
  // src/vite/inject-client-debug.ts
4675
4682
  function isRouterInternalDebugId(id) {
4676
4683
  if (!id.includes("internal-debug")) return false;
@@ -6410,6 +6417,8 @@ async function runShellPrerenderPhase(s, builder) {
6410
6417
  ttl: policy.ttl,
6411
6418
  swr: policy.swr,
6412
6419
  tags: policy.tags,
6420
+ maxSnapshotBytes: policy.maxSnapshotBytes,
6421
+ captureTimeout: policy.captureTimeout,
6413
6422
  buildEnv: s.resolvedBuildEnv,
6414
6423
  buildVersion,
6415
6424
  captureShellHTML,
@@ -7701,6 +7710,11 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7701
7710
  const swr = swrRaw === null ? void 0 : Number(swrRaw);
7702
7711
  const tagsRaw = url.searchParams.get("tags");
7703
7712
  const tags = tagsRaw ? tagsRaw.split(",") : void 0;
7713
+ const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
7714
+ const maxSnapshotBytes = maxSnapshotBytesRaw === null ? void 0 : Number(maxSnapshotBytesRaw);
7715
+ const captureTimeout = normalizeCaptureTimeout(
7716
+ Number(url.searchParams.get("captureTimeout"))
7717
+ );
7704
7718
  const rscEnvMain = server.environments?.rsc;
7705
7719
  let rscRealm = null;
7706
7720
  let ssrRealm = null;
@@ -7750,7 +7764,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7750
7764
  res.end("Shell capture registry not available");
7751
7765
  return;
7752
7766
  }
7753
- const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|v=${version}`;
7767
+ const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
7754
7768
  for (const [, routerInstance] of registry) {
7755
7769
  if (typeof routerInstance.match !== "function") continue;
7756
7770
  const cached = devPrerenderCache.get(routerInstance, cacheKey);
@@ -7765,7 +7779,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7765
7779
  try {
7766
7780
  const probe = await fetch(
7767
7781
  `${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
7768
- { signal: AbortSignal.timeout(1e4) }
7782
+ { signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) }
7769
7783
  );
7770
7784
  if (!probe.ok) {
7771
7785
  res.statusCode = 404;
@@ -7798,6 +7812,8 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7798
7812
  ttl,
7799
7813
  swr,
7800
7814
  tags,
7815
+ maxSnapshotBytes,
7816
+ captureTimeout,
7801
7817
  buildEnv: s.resolvedBuildEnv,
7802
7818
  buildVersion: version,
7803
7819
  captureShellHTML: ssrModule.captureShellHTML,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.147",
3
+ "version": "0.0.0-experimental.149",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -41,26 +41,33 @@ When an API client requests the same URL (`Accept: application/json`), the JSON
41
41
  1. **Q-value priority** — higher `q` wins (`Accept: application/json;q=0.9, text/html;q=1.0` serves RSC)
42
42
  2. **Client order tiebreaker** — when q-values are equal, the type listed first in Accept wins (matches Express/Hono behavior)
43
43
  3. **Specific MIME match** — the variant whose MIME type appears in Accept wins
44
- 4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins)
44
+ 4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins); when the RSC route wins this way, it serves the HTML document
45
45
  5. **All responses** on a negotiated URL get `Vary: Accept` header, including the RSC side
46
46
 
47
- RSC participates as a `text/html` candidate alongside response-type variants.
48
- There is no special short-circuit RSC follows the same negotiation rules as other types.
47
+ RSC participates as a candidate alongside response-type variants under two MIME
48
+ types: `text/html` (the documentits canonical representation) and
49
+ `text/x-component` (the RSC flight wire format). There is no special
50
+ short-circuit — RSC follows the same negotiation rules as other types.
49
51
 
50
52
  The MIME mapping used for matching:
51
53
 
52
- | Tag | MIME type |
53
- | -------------------- | ------------------------------------------------------------ |
54
- | RSC (plain `path()`) | `text/html` (negotiation) / `text/x-component` (wire format) |
55
- | `json` | `application/json` |
56
- | `text` | `text/plain` |
57
- | `xml` | `application/xml` |
58
- | `html` | `text/html` |
59
- | `md` | `text/markdown` |
60
-
61
- RSC routes negotiate as `text/html` but respond with `text/x-component` (the RSC wire format).
62
- The browser's RSC runtime decodes this transparently clients requesting `text/html` get
63
- the RSC page rendered normally.
54
+ | Tag | MIME type |
55
+ | -------------------- | -------------------------------------- |
56
+ | RSC (plain `path()`) | `text/html` **and** `text/x-component` |
57
+ | `json` | `application/json` |
58
+ | `text` | `text/plain` |
59
+ | `xml` | `application/xml` |
60
+ | `html` | `text/html` |
61
+ | `md` | `text/markdown` |
62
+
63
+ Which representation an RSC win renders is decided by the same Accept header:
64
+ the flight wire format is **explicit opt-in only** (`Accept: text/x-component`,
65
+ or the internal `_rsc_*`/`__rsc` transport params the client runtime sends).
66
+ Everything else — browsers, `curl` (`*/*`), a missing Accept header, mismatched
67
+ types like `application/json` on a URL with no JSON variant — gets the HTML
68
+ document. A generic HTTP client never sees the wire format by accident, and an
69
+ explicit `Accept: text/x-component` selects the RSC flight stream even on a
70
+ route where a response variant is defined first.
64
71
 
65
72
  Tags `image`, `stream`, and `any` are pass-through and do not participate in Accept matching.
66
73
 
@@ -77,11 +84,12 @@ export const urlpatterns = urls(({ path }) => [
77
84
  ]);
78
85
  ```
79
86
 
80
- - `Accept: text/html` — RSC page
87
+ - `Accept: text/html` — RSC page (HTML document)
81
88
  - `Accept: application/json` — JSON handler
82
89
  - `Accept: text/plain` — text handler
83
90
  - `Accept: application/xml` — XML handler
84
- - `Accept: */*` — RSC page (the primary, since it was registered first)
91
+ - `Accept: */*` — RSC page as HTML (the primary, since it was registered first)
92
+ - `Accept: text/x-component` — RSC page as the flight wire format
85
93
 
86
94
  ## Wildcard Routes
87
95
 
@@ -171,13 +171,12 @@ auth middleware anywhere (global or route DSL) and it guards PPR for free.
171
171
 
172
172
  ## Verifying it works
173
173
 
174
- The header exists on DOCUMENT responses only. A bare `curl` (no `Accept`)
175
- content-negotiates a Flight payload (`text/x-component`) with NO
176
- `x-rango-shell` header at all which reads as "PPR is off" but is only the
177
- wrong request shape:
174
+ The header exists on DOCUMENT responses only. A bare `curl` gets the HTML
175
+ document (Flight is explicit-opt-in via `Accept: text/x-component`), so it
176
+ sees the header directly; only an explicit Flight request shape lacks it:
178
177
 
179
178
  ```
180
- curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/products/1 | grep -i x-rango-shell
179
+ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-shell
181
180
  ```
182
181
 
183
182
  - First document GET: `MISS`, plus a background capture.
@@ -193,6 +192,16 @@ curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/product
193
192
  - A ppr-declared route that CANNOT be honored (missing shell store family,
194
193
  per-request nonce) serves plain axis 1 with NO header and warns once per
195
194
  key — no header + a declared `ppr` means look for that warning.
195
+ - On Cloudflare, `CFCacheStore` WITHOUT a KV namespace has an inert shell
196
+ family (the shell tier is KV-only): every ppr route stays `MISS` forever.
197
+ The store warns once per isolate — bind KV
198
+ (`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
199
+ - Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
200
+ logs one line per capture attempt/skip (outcome, durations, prelude and
201
+ snapshot bytes, backoff state); pass a function to receive each
202
+ `ShellCaptureDebugEvent` instead. In dev, with `debugPerformance` on, the
203
+ last capture outcome for a key also rides the next document GET's
204
+ `Server-Timing` as `ppr-capture;desc="…"`.
196
205
 
197
206
  ## The hole doctrine (encode this in your head)
198
207
 
@@ -505,11 +514,12 @@ path(
505
514
  );
506
515
  ```
507
516
 
508
- | Field | Default | Notes |
509
- | ------ | ------- | -------------------------------------------------------------------------------------------------- |
510
- | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
511
- | `swr` | — | stale window: serve the stale shell + background recapture |
512
- | `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
517
+ | Field | Default | Notes |
518
+ | ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
519
+ | `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
520
+ | `swr` | — | stale window: serve the stale shell + background recapture |
521
+ | `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
522
+ | `maxSnapshotBytes` | 8 MiB | cap on the entry's capture data snapshot; over it the snapshot is skipped (shell still stored, warned once per key) so the entry stays under store limits |
513
523
 
514
524
  The shell store is always the app-level `createRouter({ cache })` store; the
515
525
  default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
@@ -137,6 +137,13 @@ const warnedNoKvReadInvalidation = new Set<string>();
137
137
  */
138
138
  const warnedTagInvalidationTtlFloor = new Set<string>();
139
139
 
140
+ /**
141
+ * Stores (by namespace) already warned about the shell family being inert
142
+ * (getShell/putShell no-op without a KV namespace), so a ppr route hitting the
143
+ * silent fail-open warns once per isolate instead of on every request.
144
+ */
145
+ const warnedShellFamilyInert = new Set<string>();
146
+
140
147
  /**
141
148
  * Stores (by namespace) already warned that tag invalidation is writing KV
142
149
  * markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
@@ -1642,6 +1649,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1642
1649
  // still applies: shell entries carry tags/taggedAt and are checked against the
1643
1650
  // same KV markers isGloballyInvalidated() reads for every other tier.
1644
1651
 
1652
+ /**
1653
+ * Warn once per isolate that the shell family is inert: getShell/putShell
1654
+ * are ONLY called for routes that declared the `ppr` path option, so firing
1655
+ * here (not in the constructor) scopes the warning to apps that actually
1656
+ * use PPR — a KV-less CFCacheStore is a perfectly fine config otherwise.
1657
+ * Without it, the correctness-first fail-open (issue #651) is invisible:
1658
+ * every ppr route is a permanent MISS with zero diagnostics.
1659
+ * @internal
1660
+ */
1661
+ private warnShellFamilyInertOnce(): void {
1662
+ this.warnOncePerNamespace(
1663
+ warnedShellFamilyInert,
1664
+ `[CFCacheStore] a ppr route resolved to this store, but no KV namespace ` +
1665
+ `is configured, so the shell family (getShell/putShell) is a no-op: ` +
1666
+ `every ppr route stays a permanent shell MISS (the page still serves ` +
1667
+ `via a full render). Bind a KV namespace and pass it — ` +
1668
+ `new CFCacheStore({ ctx, kv: env.CACHE_KV }) — or use a shell-capable ` +
1669
+ `store via createRouter({ cache }).`,
1670
+ );
1671
+ }
1672
+
1645
1673
  /**
1646
1674
  * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1647
1675
  * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
@@ -1652,7 +1680,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1652
1680
  async getShell(
1653
1681
  key: string,
1654
1682
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
1655
- if (!this.kv) return null;
1683
+ if (!this.kv) {
1684
+ this.warnShellFamilyInertOnce();
1685
+ return null;
1686
+ }
1656
1687
  try {
1657
1688
  const kvKey = this.toKVKey(`shell:${key}`);
1658
1689
  const { value: envelope, timedOut } =
@@ -1709,7 +1740,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1709
1740
  tags?: string[],
1710
1741
  ): Promise<void> {
1711
1742
  // KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
1712
- if (!this.kv || !this.waitUntil) return;
1743
+ if (!this.kv) {
1744
+ this.warnShellFamilyInertOnce();
1745
+ return;
1746
+ }
1747
+ if (!this.waitUntil) return;
1713
1748
  try {
1714
1749
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
1715
1750
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
package/src/index.rsc.ts CHANGED
@@ -88,6 +88,12 @@ export type {
88
88
  OriginCheckPhase,
89
89
  } from "./rsc/origin-guard.js";
90
90
 
91
+ // PPR shell-capture debug sink types (RangoOptions.debugShellCapture)
92
+ export type {
93
+ ShellCaptureDebug,
94
+ ShellCaptureDebugEvent,
95
+ } from "./rsc/shell-capture.js";
96
+
91
97
  // Server-side createLoader and redirect
92
98
  export {
93
99
  createLoader,
@@ -49,7 +49,9 @@ import {
49
49
  * drift from the serve-side one.
50
50
  */
51
51
  export function resolveBuildPprConfig(
52
- ppr: true | { ttl?: number; swr?: number; tags?: string[] },
52
+ ppr:
53
+ | true
54
+ | { ttl?: number; swr?: number; tags?: string[]; captureTimeout?: number },
53
55
  ): ResolvedPprConfig {
54
56
  const resolved = resolvePprConfig({ type: "route", ppr } as any);
55
57
  // resolvePprConfig returns null only for undefined/false ppr; the collector
@@ -76,6 +78,18 @@ export interface BuildShellCaptureOptions {
76
78
  swr?: number;
77
79
  /** The route's static ppr.tags (the capture unions render-recorded tags). */
78
80
  tags?: string[];
81
+ /**
82
+ * The route's resolved snapshot size cap (ResolvedPprConfig.maxSnapshotBytes)
83
+ * — build captures apply the same over-cap skip as runtime captures, so a
84
+ * raised per-route cap behaves identically across both producers.
85
+ */
86
+ maxSnapshotBytes?: number;
87
+ /**
88
+ * The route's `ppr.captureTimeout` (ms) — producer B honors the same settle
89
+ * budget as the runtime capture. Build has no waitUntil lifetime bound, so
90
+ * the option is the only ceiling here.
91
+ */
92
+ captureTimeout?: number;
79
93
  /** Build-time env bindings (rango plugin buildEnv), if configured. */
80
94
  buildEnv?: unknown;
81
95
  /**
@@ -183,8 +197,10 @@ async function attemptBuildCapture(
183
197
  ttl: opts.ttl,
184
198
  swr: opts.swr,
185
199
  tags: opts.tags,
200
+ captureTimeout: opts.captureTimeout,
186
201
  store: collector as any,
187
202
  debug: opts.debug,
203
+ maxSnapshotBytes: opts.maxSnapshotBytes,
188
204
  };
189
205
 
190
206
  let mismatchedRouteName: string | undefined;
@@ -76,6 +76,46 @@ export function parseAcceptTypes(accept: string): AcceptEntry[] {
76
76
 
77
77
  export const RSC_RESPONSE_TYPE = "__rsc__";
78
78
 
79
+ /** RSC wire-format MIME type; explicit-opt-in flight transport. */
80
+ export const RSC_WIRE_MIME = "text/x-component";
81
+
82
+ /**
83
+ * The two representations an RSC route serves, in canonical-first order:
84
+ * text/html (the document) and text/x-component (the flight wire format).
85
+ * Both register as negotiation candidates in pickNegotiateVariant; without
86
+ * the wire-format entry, an explicit Accept: text/x-component fell through
87
+ * to the definition-order fallback — a JSON-first route answered a
88
+ * wire-format request with JSON. Which representation an RSC win actually
89
+ * renders is decided by prefersFlightRepresentation below, from the same
90
+ * Accept header (wired in via isRscRequest, rsc/ssr-setup.ts).
91
+ */
92
+ const RSC_MIMES: readonly string[] = ["text/html", RSC_WIRE_MIME];
93
+
94
+ /**
95
+ * Rank the RSC route's two representations against a parsed Accept list:
96
+ * true when the flight wire format outranks the HTML document. Wildcard
97
+ * entries count for the HTML side — they express "anything", and the
98
+ * canonical representation of anything is the document. Co-located with
99
+ * RSC_MIMES so the candidate registration and the representation choice
100
+ * cannot drift.
101
+ */
102
+ export function prefersFlightRepresentation(
103
+ acceptEntries: AcceptEntry[],
104
+ ): boolean {
105
+ for (const entry of acceptEntries) {
106
+ if (entry.q === 0) continue;
107
+ if (entry.mime === RSC_WIRE_MIME) return true;
108
+ if (
109
+ entry.mime === "text/html" ||
110
+ entry.mime === "text/*" ||
111
+ entry.mime === "*/*"
112
+ ) {
113
+ return false;
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+
79
119
  /**
80
120
  * Pick the best negotiate variant by walking the client's sorted Accept list.
81
121
  * For each accepted MIME type (in q-value/order priority), check if any
@@ -87,12 +127,14 @@ export function pickNegotiateVariant<
87
127
  >(acceptEntries: AcceptEntry[], candidates: T[]): T {
88
128
  const byCandidateMime = new Map<string, T>();
89
129
  for (const c of candidates) {
90
- const mime =
130
+ const mimes =
91
131
  c.responseType === RSC_RESPONSE_TYPE
92
- ? "text/html"
93
- : RESPONSE_TYPE_MIME[c.responseType];
94
- if (mime && !byCandidateMime.has(mime)) {
95
- byCandidateMime.set(mime, c);
132
+ ? RSC_MIMES
133
+ : [RESPONSE_TYPE_MIME[c.responseType]];
134
+ for (const mime of mimes) {
135
+ if (mime && !byCandidateMime.has(mime)) {
136
+ byCandidateMime.set(mime, c);
137
+ }
96
138
  }
97
139
  }
98
140
 
@@ -79,6 +79,7 @@ export function appendMetric(
79
79
  start: number,
80
80
  duration: number,
81
81
  depth?: number,
82
+ desc?: string,
82
83
  ): void {
83
84
  if (!metricsStore) return;
84
85
  metricsStore.metrics.push({
@@ -86,6 +87,7 @@ export function appendMetric(
86
87
  duration,
87
88
  startTime: start - metricsStore.requestStart,
88
89
  depth,
90
+ desc,
89
91
  });
90
92
  }
91
93
 
@@ -104,6 +106,7 @@ interface DisplayRow {
104
106
  startTime: number;
105
107
  duration: number;
106
108
  depth: number | undefined;
109
+ desc: string | undefined;
107
110
  spans: Span[];
108
111
  }
109
112
 
@@ -137,6 +140,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
137
140
  startTime: m.startTime,
138
141
  duration: m.duration + post.duration,
139
142
  depth: m.depth,
143
+ desc: m.desc,
140
144
  spans: [
141
145
  { startTime: m.startTime, duration: m.duration },
142
146
  { startTime: post.startTime, duration: post.duration },
@@ -151,6 +155,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
151
155
  startTime: m.startTime,
152
156
  duration: m.duration,
153
157
  depth: m.depth,
158
+ desc: m.desc,
154
159
  spans: [{ startTime: m.startTime, duration: m.duration }],
155
160
  });
156
161
  continue;
@@ -169,6 +174,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
169
174
  startTime: m.startTime,
170
175
  duration: m.duration,
171
176
  depth: m.depth,
177
+ desc: m.desc,
172
178
  spans: [{ startTime: m.startTime, duration: m.duration }],
173
179
  });
174
180
  continue;
@@ -180,6 +186,7 @@ function buildDisplayRows(sorted: PerformanceMetric[]): DisplayRow[] {
180
186
  startTime: m.startTime,
181
187
  duration: m.duration,
182
188
  depth: m.depth,
189
+ desc: m.desc,
183
190
  spans: [{ startTime: m.startTime, duration: m.duration }],
184
191
  });
185
192
  }
@@ -199,7 +206,9 @@ export function logMetrics(
199
206
 
200
207
  const labels = displayRows.map(
201
208
  (r) =>
202
- `${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}`,
209
+ `${" ".repeat(BASE_INDENT + (r.depth ?? 0) * DEPTH_INDENT)}${r.label}${
210
+ r.desc ? ` (${r.desc})` : ""
211
+ }`,
203
212
  );
204
213
  const startValues = displayRows.map((r) => formatMs(r.startTime));
205
214
  const durationValues = displayRows.map((r) => formatMs(r.duration));
@@ -247,7 +256,13 @@ export function generateServerTiming(metricsStore: MetricsStore): string {
247
256
  .replace(/[^a-zA-Z0-9-]/g, "")
248
257
  .toLowerCase();
249
258
  const name = m.depth ? `d${m.depth}-${base}` : base;
250
- return `${name};dur=${m.duration.toFixed(2)}`;
259
+ // desc is a quoted-string: backslash-escape the two delimiters; our
260
+ // producers emit plain printable text, so nothing else needs stripping.
261
+ const desc =
262
+ m.desc !== undefined
263
+ ? `;desc="${m.desc.replace(/[\\"]/g, "\\$&")}"`
264
+ : "";
265
+ return `${name};dur=${m.duration.toFixed(2)}${desc}`;
251
266
  })
252
267
  .join(", ");
253
268
  }
@@ -6,6 +6,7 @@ import type { UrlBuilder, EnvCompatible } from "../urls/pattern-types.js";
6
6
  import type { EntryData } from "../server/context";
7
7
  import type { ErrorInfo, MatchResult } from "../types";
8
8
  import type { NonceProvider } from "../rsc/types.js";
9
+ import type { ShellCaptureDebug } from "../rsc/shell-capture.js";
9
10
  import type { ExecutionContext } from "../server/request-context.js";
10
11
  import type { SerializedSegmentData } from "../cache/types.js";
11
12
  import type { MiddlewareEntry, MiddlewareFn } from "./middleware.js";
@@ -344,6 +345,12 @@ export interface RangoInternal<
344
345
  */
345
346
  readonly debugPerformance?: boolean;
346
347
 
348
+ /**
349
+ * PPR shell-capture debug sink (createRouter({ debugShellCapture })), read
350
+ * by rsc-rendering when it builds the capture descriptor for a ppr route.
351
+ */
352
+ readonly debugShellCapture?: ShellCaptureDebug;
353
+
347
354
  /**
348
355
  * Resolved platform phase-span tracing (Cloudflare custom spans or OTel), or
349
356
  * undefined when off. Threaded onto the request context and read at each
@@ -6,6 +6,7 @@ import type {
6
6
  OnErrorCallback,
7
7
  } from "../types";
8
8
  import type { NonceProvider } from "../rsc/types.js";
9
+ import type { ShellCaptureDebug } from "../rsc/shell-capture.js";
9
10
  import type { ExecutionContext } from "../server/request-context.js";
10
11
  import type { UrlPatterns } from "../urls.js";
11
12
  import type { UrlBuilder } from "../urls/pattern-types.js";
@@ -148,6 +149,18 @@ export interface RangoOptions<TEnv = any> {
148
149
  */
149
150
  debugCacheSignal?: boolean;
150
151
 
152
+ /**
153
+ * Debug sink for the PPR shell-capture pipeline (routes with the `ppr` path
154
+ * option). `true` logs one structured line per capture attempt/skip to
155
+ * console (visible via `wrangler tail`); a function receives each
156
+ * `ShellCaptureDebugEvent` (outcome per attempt, snapshot bytes,
157
+ * write-barrier wait, backoff state) for programmatic capture. Off by
158
+ * default; the events also mirror into the dev Server-Timing surface when
159
+ * `debugPerformance` is on. Intended for validating capture behavior on a
160
+ * real deployment, not steady-state production.
161
+ */
162
+ debugShellCapture?: ShellCaptureDebug;
163
+
151
164
  /**
152
165
  * Document component that wraps the entire application.
153
166
  *
package/src/router.ts CHANGED
@@ -168,6 +168,7 @@ export function createRouter<TEnv = any>(
168
168
  originCheck: originCheckOption,
169
169
  viewTransition: viewTransitionOption = "auto",
170
170
  debugCacheSignal: debugCacheSignalOption = false,
171
+ debugShellCapture: debugShellCaptureOption,
171
172
  strictMode: strictModeOption = true,
172
173
  } = options;
173
174
 
@@ -1007,6 +1008,10 @@ export function createRouter<TEnv = any>(
1007
1008
  // Expose router-wide performance debugging for request-level metrics setup
1008
1009
  debugPerformance,
1009
1010
 
1011
+ // Expose the PPR shell-capture debug sink for the render layer
1012
+ // (rsc-rendering resolves it into the capture descriptor)
1013
+ debugShellCapture: debugShellCaptureOption,
1014
+
1010
1015
  // Expose resolved span tracing for the handler (Cloudflare custom spans)
1011
1016
  tracing: resolvedTracing,
1012
1017
 
@@ -885,8 +885,10 @@ export function createRSCHandler<
885
885
  // submissions always render HTML (handleProgressiveEnhancement renders via
886
886
  // getSSRSetup regardless of Accept). For full/partial-render and action,
887
887
  // the render-time HTML decision is exactly !isRscRequest — mayNeedSSR is
888
- // the coarse transport pre-filter, isRscRequest is the precise Accept call
889
- // (it, unlike mayNeedSSR, treats a MISSING Accept as RSC). Both must pass.
888
+ // the coarse transport pre-filter, isRscRequest adds the partial/__rsc
889
+ // flags; both share the same Accept rule (acceptsFlightExplicitly in
890
+ // ssr-setup.ts), so the Accept call cannot drift between them. Both must
891
+ // pass.
890
892
  const willRenderHtml =
891
893
  plan.mode === "pe-render" ||
892
894
  (mayNeedSSR(request, url) &&
@@ -31,6 +31,9 @@ import { gateTransitions } from "./transition-gate.js";
31
31
  import { buildFullPayload } from "./full-payload.js";
32
32
  import {
33
33
  scheduleShellCapture,
34
+ resolveShellCaptureDebugSink,
35
+ takeCaptureDebugEventForTiming,
36
+ describeShellCaptureEvent,
34
37
  type ShellCaptureDescriptor,
35
38
  } from "./shell-capture.js";
36
39
  import {
@@ -140,6 +143,29 @@ async function handleRscRenderingInner<TEnv>(
140
143
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
141
144
  const store = reqCtx._cacheStore;
142
145
  const key = buildShellKey(url);
146
+ // Dev Server-Timing mirror (issue #651): a capture runs AFTER its
147
+ // triggering response committed, so its outcome can only ride a LATER
148
+ // response's header. When the metrics surface is active
149
+ // (debugPerformance), fold the buffered terminal capture event for this
150
+ // key into THIS request's Server-Timing as `ppr-capture;dur=<attempt
151
+ // ms>;desc="<outcome + sizes + waits>"`. Consuming (read-and-clear)
152
+ // keeps one capture = one report. Dev-only: the buffer is only written
153
+ // in dev (see takeCaptureDebugEventForTiming), and production folds the
154
+ // whole branch away.
155
+ if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
156
+ const lastCapture = takeCaptureDebugEventForTiming(key);
157
+ if (lastCapture) {
158
+ appendMetric(
159
+ reqCtx._metricsStore,
160
+ "ppr:capture",
161
+ performance.now(),
162
+ lastCapture.attemptMs ?? 0,
163
+ undefined,
164
+ // attemptMs already rides as this entry's dur — drop it from desc.
165
+ describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
166
+ );
167
+ }
168
+ }
143
169
  if (activeNonce !== undefined) {
144
170
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
145
171
  // undeclared route, which is silent): a ppr route gated off by an active
@@ -170,8 +196,16 @@ async function handleRscRenderingInner<TEnv>(
170
196
  ttl: pprConfig.ttl,
171
197
  swr: pprConfig.swr,
172
198
  tags: pprConfig.tags,
199
+ captureTimeout: pprConfig.captureTimeout,
173
200
  store,
174
201
  debug: INTERNAL_RANGO_DEBUG,
202
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
203
+ // The resolver owns the whole policy: option wins, the
204
+ // INTERNAL_RANGO_DEBUG env flag lights the events up when no
205
+ // option is set, an explicit `false` stays off.
206
+ debugSink: resolveShellCaptureDebugSink(
207
+ ctx.router.debugShellCapture,
208
+ ),
175
209
  };
176
210
  // One serve funnel for BOTH entry sources (runtime store hit below,
177
211
  // build-manifest hit further down): schedule the background
@@ -204,12 +238,25 @@ async function handleRscRenderingInner<TEnv>(
204
238
  );
205
239
  };
206
240
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
241
+ const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
207
242
  try {
208
243
  cached = await store.getShell(key);
209
244
  } catch (error) {
210
245
  // A failing store read degrades to axis 1 (MISS), never a 500.
211
246
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
212
247
  }
248
+ if (reqCtx._metricsStore) {
249
+ // Raw store outcome (pre-validity-gates), so a version-mismatch
250
+ // lifecycle miss is still distinguishable from a store miss.
251
+ appendMetric(
252
+ reqCtx._metricsStore,
253
+ "ppr:shell-read",
254
+ shellReadStart,
255
+ performance.now() - shellReadStart,
256
+ undefined,
257
+ cached ? "hit" : "miss",
258
+ );
259
+ }
213
260
  if (cached && isValidShellHit(cached.entry, ctx.version)) {
214
261
  if (!hasIntactShellPayload(cached.entry)) {
215
262
  // Corrupt stored payload (undecodable prelude / unparseable
@@ -257,6 +304,8 @@ async function handleRscRenderingInner<TEnv>(
257
304
  ttl: pprConfig.ttl,
258
305
  swr: pprConfig.swr,
259
306
  tags: pprConfig.tags,
307
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
308
+ captureTimeout: pprConfig.captureTimeout,
260
309
  }
261
310
  : undefined,
262
311
  );