@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.
@@ -30,11 +30,47 @@ export const SHELL_STATUS_HEADER = "x-rango-shell";
30
30
  */
31
31
  export const DEFAULT_PPR_TTL_SECONDS = 300;
32
32
 
33
+ /**
34
+ * Timeout for the dev /__rsc_shell endpoint's sequential /__rsc_prerender
35
+ * pre-flight probe (vite/router-discovery.ts). Hoisted here so the client-side
36
+ * fetch bound (shell-build-manifest.ts devShellFetchTimeoutMs) enumerates the
37
+ * SAME term of the endpoint's worst-case envelope — the two cannot drift.
38
+ */
39
+ export const DEV_SHELL_PROBE_TIMEOUT_MS: number = 10_000;
40
+
33
41
  /** The route's ppr option normalized to a concrete policy. */
34
42
  export interface ResolvedPprConfig {
35
43
  ttl: number;
36
44
  swr?: number;
37
45
  tags?: string[];
46
+ /**
47
+ * Snapshot size cap, passed through undefaulted (like swr/tags): the single
48
+ * defaulting site is captureAndStoreShell (DEFAULT_PPR_MAX_SNAPSHOT_BYTES in
49
+ * shell-capture.ts), so direct descriptor callers and resolved configs
50
+ * cannot drift.
51
+ */
52
+ maxSnapshotBytes?: number;
53
+ /**
54
+ * Capture settle budget in ms (`ppr.captureTimeout`). Undefined = the
55
+ * capture default (SHELL_CAPTURE_MAX_WAIT_MS, 15_000) — the default's single
56
+ * owner stays shell-capture.ts so build/runtime producers cannot drift.
57
+ */
58
+ captureTimeout?: number;
59
+ }
60
+
61
+ /**
62
+ * Validate the raw `ppr.captureTimeout` option: a finite number >= 1ms passes
63
+ * through; anything else (including 0/negative/NaN/Infinity/non-number)
64
+ * resolves to undefined, which means "use the capture default" downstream.
65
+ * Mirrors the prefetch-limit option policy: invalid values silently fall back
66
+ * to the default rather than throwing at request time. Also the boundary
67
+ * re-normalizer for the dev /__rsc_shell endpoint (vite/router-discovery.ts),
68
+ * whose param crossed an HTTP query string.
69
+ */
70
+ export function normalizeCaptureTimeout(value: unknown): number | undefined {
71
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
72
+ ? value
73
+ : undefined;
38
74
  }
39
75
 
40
76
  /**
@@ -42,6 +78,12 @@ export interface ResolvedPprConfig {
42
78
  * route does not declare `ppr` (or declares `ppr: false`) — the caller then does
43
79
  * NOTHING: no store read, no capture, no logs. Pure axis 1, zero cost.
44
80
  *
81
+ * The route's NAME is irrelevant here (and everywhere on the shell lane):
82
+ * nameless `path()` routes register their EntryData under a synthesized
83
+ * `$path_*` manifest key with the `ppr` option intact (urls/path-helper.ts),
84
+ * so a nameless entry resolves exactly like a named one — pinned by the
85
+ * nameless-ppr e2e in both apps (issue #714).
86
+ *
45
87
  * PPR is a DOCUMENT-level property of the page route; there is no subtree
46
88
  * inheritance (declaring it on a layout is not supported — a follow-up).
47
89
  */
@@ -56,6 +98,8 @@ export function resolvePprConfig(
56
98
  ttl: ppr.ttl ?? DEFAULT_PPR_TTL_SECONDS,
57
99
  swr: ppr.swr,
58
100
  tags: ppr.tags,
101
+ maxSnapshotBytes: ppr.maxSnapshotBytes,
102
+ captureTimeout: normalizeCaptureTimeout(ppr.captureTimeout),
59
103
  };
60
104
  }
61
105
 
@@ -11,6 +11,11 @@ import type { SSRModule } from "./types.js";
11
11
  import type { SSRStreamMode } from "../router/router-options.js";
12
12
  import type { MetricsStore } from "../server/context.js";
13
13
  import { appendMetric } from "../router/metrics.js";
14
+ import {
15
+ parseAcceptTypes,
16
+ prefersFlightRepresentation,
17
+ RSC_WIRE_MIME,
18
+ } from "../router/content-negotiation.js";
14
19
  import { _getRequestContext } from "../server/request-context.js";
15
20
 
16
21
  export type SSRSetup = readonly [SSRModule, SSRStreamMode];
@@ -90,12 +95,42 @@ export function getSSRSetup<TEnv>(
90
95
  );
91
96
  }
92
97
 
98
+ /**
99
+ * Accept-based flight opt-in: the client explicitly listed the RSC wire
100
+ * format (text/x-component) in Accept, ranked above the HTML document, and
101
+ * did not override with __html.
102
+ *
103
+ * The flight stream is an internal transport representation — it is served
104
+ * ONLY on explicit opt-in (this Accept value, or the _rsc_ / __rsc transport
105
+ * params). Everything else (missing Accept, wildcards, application/json,
106
+ * browser Accept strings) gets the HTML document, per RFC 9110: a missing
107
+ * Accept is equivalent to a full wildcard, and a wildcard gets the server's
108
+ * canonical representation. The old rule ("no text/html substring → flight")
109
+ * handed the wire format to every generic client — curl, health checks,
110
+ * link unfurlers.
111
+ *
112
+ * The includes() guard is a parse-skipping fast path: the bulk of traffic
113
+ * (browsers, curl, monitors) never mentions the wire format and pays no
114
+ * parseAcceptTypes allocation. Ranking lives in prefersFlightRepresentation
115
+ * (router/content-negotiation.ts), co-located with the candidate MIME set.
116
+ */
117
+ function acceptsFlightExplicitly(request: Request, url: URL): boolean {
118
+ if (url.searchParams.has("__html")) return false;
119
+ const accept = request.headers.get("accept");
120
+ if (accept === null || !accept.includes(RSC_WIRE_MIME)) return false;
121
+ return prefersFlightRepresentation(parseAcceptTypes(accept));
122
+ }
123
+
93
124
  /**
94
125
  * Classify whether a request may require SSR (HTML rendering).
95
126
  *
96
- * Returns false for requests that are definitively RSC-only, loader fetches,
97
- * prerender collection, or Accept-based RSC (no text/html). This mirrors
98
- * the isRscRequest decision in rsc-rendering.ts.
127
+ * Returns false for requests that are definitively RSC-only: transport
128
+ * params (partial/action/loader/__rsc), prerender collection, or an explicit
129
+ * Accept: text/x-component. Must never return false for a request whose
130
+ * render-time decision (isRscRequest) will be HTML — the two share
131
+ * acceptsFlightExplicitly so the Accept rule cannot drift. document-cache.ts
132
+ * keys its HTML/RSC response slots off this function, so any divergence from
133
+ * the render decision poisons a cache slot with the wrong representation.
99
134
  *
100
135
  * Note: response/mime routes are excluded by the caller — this function
101
136
  * runs after classifyRequest() determines the request mode.
@@ -112,24 +147,21 @@ export function mayNeedSSR(request: Request, url: URL): boolean {
112
147
  return false;
113
148
  }
114
149
 
115
- // Mirror the Accept-based RSC decision from rsc-rendering.ts:
116
- // if Accept is present and does not include text/html (and no __html override),
117
- // the response will be RSC, not HTML.
118
- const accept = request.headers.get("accept");
119
- if (
120
- accept &&
121
- !accept.includes("text/html") &&
122
- !url.searchParams.has("__html")
123
- ) {
124
- return false;
125
- }
126
-
127
- return true;
150
+ return !acceptsFlightExplicitly(request, url);
128
151
  }
129
152
 
130
- // Final render-time decision: is the response an RSC stream (vs HTML)? Distinct
131
- // from mayNeedSSR, which is a conservative pre-classifier (it treats a missing
132
- // Accept header as needing SSR; this treats it as RSC).
153
+ // Final render-time decision: is the response an RSC stream (vs HTML)?
154
+ // Flight requires explicit opt-in: the partial transport param, __rsc, or
155
+ // Accept: text/x-component. mayNeedSSR is the coarse pre-filter over the
156
+ // transport params; both delegate the Accept call to acceptsFlightExplicitly.
157
+ //
158
+ // _rsc_partial is read from the URL in addition to the plan-derived isPartial
159
+ // flag: the 404 fallback plan hardcodes mode "full-render" even for partial
160
+ // navigations (handler.ts RouteNotFoundError catch), so a partial 404 reaches
161
+ // this decision with isPartial=false. The old Accept rule masked that by
162
+ // classifying */* as flight; without the URL check a client-side navigation
163
+ // to a missing route received an HTML 404 it cannot apply, and the
164
+ // navigation never committed (multi-router soft-404, popstate not-found).
133
165
  export function isRscRequest(
134
166
  request: Request,
135
167
  url: URL,
@@ -137,8 +169,8 @@ export function isRscRequest(
137
169
  ): boolean {
138
170
  return (
139
171
  isPartial ||
140
- (!request.headers.get("accept")?.includes("text/html") &&
141
- !url.searchParams.has("__html")) ||
142
- url.searchParams.has("__rsc")
172
+ url.searchParams.has("_rsc_partial") ||
173
+ url.searchParams.has("__rsc") ||
174
+ acceptsFlightExplicitly(request, url)
143
175
  );
144
176
  }
@@ -27,6 +27,7 @@ export interface PerformanceMetric {
27
27
  duration: number; // milliseconds
28
28
  startTime: number; // relative to request start
29
29
  depth?: number; // nesting level for hierarchical display (0 = top-level)
30
+ desc?: string; // free-form outcome detail, emitted as Server-Timing desc="..."
30
31
  }
31
32
 
32
33
  /**
package/src/ssr/index.tsx CHANGED
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { createSsrRootComponent } from "./ssr-root.js";
3
3
  import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
4
4
  import { runWithPreinitNonce } from "./preinit-client-references.js";
5
+ import { SHELL_CAPTURE_MAX_WAIT_MS } from "../rsc/shell-capture-constants.js";
5
6
  import type { ErrorPhase } from "../types.js";
6
7
  import type { HeadScriptsOption } from "../vite/plugin-types.js";
7
8
 
@@ -181,15 +182,6 @@ export interface SSRDependencies<TEnv = unknown> {
181
182
  onError?: (error: Error, context: { phase: ErrorPhase }) => void;
182
183
  }
183
184
 
184
- /**
185
- * Default guard for how long capture waits on the caller's `quiesce` signal
186
- * before forcing the abort that freezes the shell. This is the ONLY wall-clock
187
- * on the capture path and it is a pathological guard — it should never fire once
188
- * the caller's `quiesce` is a task-quantized, frozen-byte signal (the capture
189
- * gate in shell-capture.ts). See docs/design/ppr-shell-resume.md.
190
- */
191
- const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
192
-
193
185
  /**
194
186
  * Fixed number of macrotask hops between `quiesce` resolving and the abort. These
195
187
  * give React's fizz worker turns to flush the settled shell into the prelude and
@@ -258,6 +250,38 @@ function createCancelableTimeout(ms: number): {
258
250
  return { promise, cancel: () => clearTimeout(id) };
259
251
  }
260
252
 
253
+ /**
254
+ * True when a debugger is attached outside a production build: a breakpoint
255
+ * pauses script execution but not wall-clock, so the capture deadline would
256
+ * expire mid-inspection and push the key into refusal/backoff while debugging.
257
+ * Next.js precedent (packages/next/src/export/worker.ts): its static-page
258
+ * timeout race is disabled when NODE_OPTIONS contains --inspect. The gate is
259
+ * the bare `process.env.NODE_ENV !== "production"` token — the build define
260
+ * folds exactly that token to a literal (same dev signal as shell-capture.ts's
261
+ * isDevMode), and it covers dev servers that never set NODE_ENV, the common
262
+ * debug case. A production worker launched with --inspect in NODE_OPTIONS
263
+ * keeps the capture's safety bound unconditionally. Every probe is guarded:
264
+ * `process` reads are optional-chained and the node:inspector import is
265
+ * try/caught, so non-Node runtimes (workerd) resolve to false instead of
266
+ * breaking.
267
+ */
268
+ export async function isDebuggerAttached(): Promise<boolean> {
269
+ if (process.env.NODE_ENV === "production") return false;
270
+ try {
271
+ // Substring match also catches --inspect-brk / --inspect=PORT.
272
+ if (globalThis.process?.env?.NODE_OPTIONS?.includes("--inspect")) {
273
+ return true;
274
+ }
275
+ if (globalThis.process?.execArgv?.some((a) => a.startsWith("--inspect"))) {
276
+ return true;
277
+ }
278
+ const inspector = await import("node:inspector");
279
+ return inspector.url() !== undefined;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
261
285
  /**
262
286
  * Drain a ReadableStream fully into a single Uint8Array. Capture buffers the
263
287
  * whole prelude so it can be stored and later prepended byte-for-byte.
@@ -321,7 +345,7 @@ function createDataVariantHtmlStream(): ReadableStream<Uint8Array> {
321
345
  interface ShellCaptureOptions {
322
346
  /** Caller-provided promise that resolves once the cached content settled. */
323
347
  quiesce: Promise<void>;
324
- /** Upper bound on how long to wait for `quiesce`. Default 5000ms. */
348
+ /** Upper bound on how long to wait for `quiesce`. Default SHELL_CAPTURE_MAX_WAIT_MS. */
325
349
  maxWaitMs?: number;
326
350
  }
327
351
 
@@ -509,7 +533,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
509
533
  rscStream: ReadableStream<Uint8Array>,
510
534
  opts: ShellCaptureOptions,
511
535
  ): Promise<ShellCaptureResult | null> {
512
- const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
536
+ const maxWaitMs = opts.maxWaitMs ?? SHELL_CAPTURE_MAX_WAIT_MS;
513
537
 
514
538
  // Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
515
539
  // capture, the bootstrap-script load included. loadBootstrapScriptContent()
@@ -517,7 +541,13 @@ export function createShellCaptureHandler<TEnv = unknown>(
517
541
  // captureShellHTML with no upper bound and held the background capture task
518
542
  // open. One deadline, shared by the bootstrap race below and the quiesce
519
543
  // race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
520
- const deadline = createCancelableTimeout(maxWaitMs);
544
+ // Debugger attached (non-production, see isDebuggerAttached): a paused process
545
+ // must not burn the budget, so the deadline becomes a never-resolving
546
+ // promise (the Next.js approach) — no timer, so it cannot hold the event
547
+ // loop open either.
548
+ const deadline = (await isDebuggerAttached())
549
+ ? { promise: new Promise<void>(() => {}), cancel: () => {} }
550
+ : createCancelableTimeout(maxWaitMs);
521
551
  try {
522
552
  // No nonce (nonce'd requests never reach capture); no formState.
523
553
  // payloadSettled: fires when the Flight payload root settles — i.e.
@@ -62,6 +62,35 @@ export interface PartialPrerenderProps {
62
62
  * capture render auto-collects (the shell's own non-loader request tags).
63
63
  */
64
64
  tags?: string[];
65
+ /**
66
+ * Upper bound (serialized UTF-8 bytes) on the capture data snapshot riding
67
+ * inside the shell entry. The snapshot duplicates every cache-store value
68
+ * the capture pinned, so a page over a large cache() segment can push the
69
+ * entry toward store value limits (Cloudflare KV caps a value at 25 MiB).
70
+ * Over the cap the snapshot is skipped: the shell is still stored and
71
+ * served, but pinned reads fall back to the live store, so drifted cached
72
+ * content can hydration-mismatch and be repaired client-side (the
73
+ * pre-snapshot behavior). Reported once per key. Defaults to 8 MiB.
74
+ */
75
+ maxSnapshotBytes?: number;
76
+ /**
77
+ * Capture settle budget in MILLISECONDS (default 15000). Bounds the whole
78
+ * background capture: the wait for deferred shell material — top-level
79
+ * pushed handle promises (`ctx.use(Meta)(promise.then(...))` and friends)
80
+ * are AWAITED and their settled values baked into the stored shell — AND
81
+ * the fizz prerender deadline. Declare it to tighten the budget below the
82
+ * default or when a route's shell material takes longer than 15s to
83
+ * settle. A budget that expires with pushes still pending REFUSES the
84
+ * capture (the route stays MISS with the once-per-key warning) — a shell
85
+ * with missing head material is never stored. Capture is background work
86
+ * (waitUntil), so a longer budget costs latency-to-HIT only, never a served
87
+ * response; the platform waitUntil lifetime (workerd: ~30s past response
88
+ * completion) is the physical ceiling — the default's derivation lives in
89
+ * docs/design/ppr-shell-resume.md (Cost model). Build-time captures
90
+ * (Prerender+ppr, producer B) honor the same budget with no platform
91
+ * ceiling. Non-finite or sub-1ms values fall back to the default.
92
+ */
93
+ captureTimeout?: number;
65
94
  }
66
95
 
67
96
  export interface PathOptions<
@@ -243,6 +243,8 @@ export async function runShellPrerenderPhase(
243
243
  ttl: policy.ttl,
244
244
  swr: policy.swr,
245
245
  tags: policy.tags,
246
+ maxSnapshotBytes: policy.maxSnapshotBytes,
247
+ captureTimeout: policy.captureTimeout,
246
248
  buildEnv: s.resolvedBuildEnv,
247
249
  buildVersion,
248
250
  captureShellHTML,
@@ -73,7 +73,9 @@ export interface ShellPrerenderCandidate {
73
73
  urlPath: string;
74
74
  routeName: string;
75
75
  paramHash: string;
76
- ppr: true | { ttl?: number; swr?: number; tags?: string[] };
76
+ ppr:
77
+ | true
78
+ | { ttl?: number; swr?: number; tags?: string[]; captureTimeout?: number };
77
79
  }
78
80
 
79
81
  export interface DiscoveryState {
@@ -19,6 +19,10 @@ import {
19
19
  createScanFilter,
20
20
  } from "../build/generate-route-types.js";
21
21
  import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
22
+ import {
23
+ DEV_SHELL_PROBE_TIMEOUT_MS,
24
+ normalizeCaptureTimeout,
25
+ } from "../rsc/shell-serve.js";
22
26
  import {
23
27
  injectClientDebugFlag,
24
28
  internalDebugNoCacheMiddleware,
@@ -1144,6 +1148,18 @@ export function createRouterDiscoveryPlugin(
1144
1148
  const swr = swrRaw === null ? undefined : Number(swrRaw);
1145
1149
  const tagsRaw = url.searchParams.get("tags");
1146
1150
  const tags = tagsRaw ? tagsRaw.split(",") : undefined;
1151
+ const maxSnapshotBytesRaw = url.searchParams.get("maxSnapshotBytes");
1152
+ const maxSnapshotBytes =
1153
+ maxSnapshotBytesRaw === null
1154
+ ? undefined
1155
+ : Number(maxSnapshotBytesRaw);
1156
+ // Boundary revalidation via the SHARED normalizer (shell-serve.ts):
1157
+ // the param crossed an HTTP query string, and a garbage value must
1158
+ // fall back to the capture default, never reach setTimeout as NaN
1159
+ // (which Node clamps to ~1ms — an instant abort).
1160
+ const captureTimeout = normalizeCaptureTimeout(
1161
+ Number(url.searchParams.get("captureTimeout")),
1162
+ );
1147
1163
 
1148
1164
  // Resolve the capture realms: main-server envs (Node preset) or the
1149
1165
  // shared temp Node server (Cloudflare preset — no main RSC runner).
@@ -1205,7 +1221,7 @@ export function createRouterDiscoveryPlugin(
1205
1221
  // (this fetch blocks a foreground document request), and the memoized
1206
1222
  // body needs neither the pre-flight round-trip nor a capture. Keyed
1207
1223
  // per router instance (= HMR generation) like the prerender memo.
1208
- const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|v=${version}`;
1224
+ const cacheKey = `shell|${pathname}|r=${routeName}|t=${ttl}|s=${swr ?? ""}|g=${(tags ?? []).join("+")}|c=${captureTimeout ?? ""}|v=${version}`;
1209
1225
  for (const [, routerInstance] of registry) {
1210
1226
  if (typeof routerInstance.match !== "function") continue;
1211
1227
  const cached = devPrerenderCache.get(routerInstance, cacheKey);
@@ -1224,7 +1240,7 @@ export function createRouterDiscoveryPlugin(
1224
1240
  try {
1225
1241
  const probe = await fetch(
1226
1242
  `${s.devServerOrigin}/__rsc_prerender?pathname=${encodeURIComponent(pathname)}&routeName=${encodeURIComponent(routeName)}`,
1227
- { signal: AbortSignal.timeout(10_000) },
1243
+ { signal: AbortSignal.timeout(DEV_SHELL_PROBE_TIMEOUT_MS) },
1228
1244
  );
1229
1245
  if (!probe.ok) {
1230
1246
  res.statusCode = 404;
@@ -1258,6 +1274,8 @@ export function createRouterDiscoveryPlugin(
1258
1274
  ttl,
1259
1275
  swr,
1260
1276
  tags,
1277
+ maxSnapshotBytes,
1278
+ captureTimeout,
1261
1279
  buildEnv: s.resolvedBuildEnv,
1262
1280
  buildVersion: version,
1263
1281
  captureShellHTML: ssrModule.captureShellHTML,