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

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.
@@ -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
  /**
@@ -62,6 +62,33 @@ 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 5000). 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 when a route's shell material
82
+ * takes longer than 5s to settle. A budget that expires with pushes still
83
+ * pending REFUSES the capture (the route stays MISS with the once-per-key
84
+ * warning) — a shell with missing head material is never stored. Capture
85
+ * is background work (waitUntil), so a longer budget costs latency-to-HIT
86
+ * only, never a served response; the platform waitUntil lifetime (workerd:
87
+ * ~30s past response completion) is the physical ceiling. Build-time
88
+ * captures (Prerender+ppr, producer B) honor the same budget with no
89
+ * platform ceiling. Non-finite or sub-1ms values fall back to the default.
90
+ */
91
+ captureTimeout?: number;
65
92
  }
66
93
 
67
94
  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,