@rangojs/router 0.5.0 → 0.5.2

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.
Files changed (65) hide show
  1. package/README.md +5 -1
  2. package/dist/types/browser/event-controller.d.ts +6 -0
  3. package/dist/types/browser/prefetch/cache.d.ts +31 -2
  4. package/dist/types/cache/cf/cf-cache-constants.d.ts +8 -1
  5. package/dist/types/cache/cf/cf-cache-store.d.ts +15 -1
  6. package/dist/types/cache/cf/cf-cache-types.d.ts +1 -1
  7. package/dist/types/cache/cf/cf-kv-utils.d.ts +21 -0
  8. package/dist/types/handles/is-thenable.d.ts +2 -4
  9. package/dist/types/route-definition/helpers-types.d.ts +5 -4
  10. package/dist/types/rsc/helpers.d.ts +3 -0
  11. package/dist/types/rsc/render-pipeline.d.ts +9 -0
  12. package/dist/types/rsc/routine-plan.d.ts +124 -0
  13. package/dist/types/rsc/shell-capture-constants.d.ts +17 -0
  14. package/dist/types/server/request-context.d.ts +4 -1
  15. package/dist/types/static-handler.d.ts +15 -1
  16. package/dist/types/urls/path-helper-types.d.ts +6 -7
  17. package/dist/types/vite/encryption-key.d.ts +2 -0
  18. package/dist/types/vite/plugins/expose-internal-ids.d.ts +10 -0
  19. package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
  20. package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
  21. package/dist/types/vite/utils/shared-utils.d.ts +12 -0
  22. package/dist/vite/index.js +154 -57
  23. package/package.json +3 -3
  24. package/skills/caching/SKILL.md +1 -1
  25. package/skills/mime-routes/SKILL.md +3 -1
  26. package/skills/parallel/SKILL.md +1 -1
  27. package/skills/response-routes/SKILL.md +4 -2
  28. package/skills/typesafety/generated-files-and-cli.md +16 -9
  29. package/skills/typesafety/route-types.md +5 -1
  30. package/skills/use-cache/SKILL.md +47 -0
  31. package/src/browser/event-controller.ts +40 -15
  32. package/src/browser/partial-update.ts +26 -11
  33. package/src/browser/prefetch/cache.ts +53 -9
  34. package/src/browser/prefetch/fetch.ts +83 -2
  35. package/src/browser/react/NavigationProvider.tsx +7 -0
  36. package/src/cache/cache-runtime.ts +89 -15
  37. package/src/cache/cf/cf-cache-constants.ts +8 -1
  38. package/src/cache/cf/cf-cache-store.ts +41 -64
  39. package/src/cache/cf/cf-cache-types.ts +1 -1
  40. package/src/cache/cf/cf-kv-utils.ts +38 -0
  41. package/src/cache/segment-codec.ts +21 -5
  42. package/src/handles/is-thenable.ts +2 -4
  43. package/src/route-definition/helpers-types.ts +5 -3
  44. package/src/router/match-middleware/cache-lookup.ts +24 -15
  45. package/src/rsc/handler.ts +26 -5
  46. package/src/rsc/helpers.ts +13 -0
  47. package/src/rsc/progressive-enhancement.ts +247 -70
  48. package/src/rsc/render-pipeline.ts +68 -24
  49. package/src/rsc/routine-plan.ts +359 -0
  50. package/src/rsc/rsc-rendering.ts +555 -302
  51. package/src/rsc/server-action.ts +180 -71
  52. package/src/rsc/shell-capture-constants.ts +18 -0
  53. package/src/rsc/shell-capture.ts +92 -21
  54. package/src/server/request-context.ts +6 -0
  55. package/src/ssr/ssr-root.tsx +1 -0
  56. package/src/static-handler.ts +18 -2
  57. package/src/urls/path-helper-types.ts +9 -10
  58. package/src/vite/encryption-key.ts +29 -0
  59. package/src/vite/plugins/expose-action-id.ts +2 -2
  60. package/src/vite/plugins/expose-internal-ids.ts +46 -0
  61. package/src/vite/plugins/server-ref-hashing.ts +74 -0
  62. package/src/vite/plugins/server-reference-pattern.ts +10 -0
  63. package/src/vite/rango.ts +9 -0
  64. package/src/vite/router-discovery.ts +21 -2
  65. package/src/vite/utils/shared-utils.ts +12 -7
@@ -27,8 +27,21 @@ import {
27
27
  createResponseWithMergedHeaders,
28
28
  createSimpleRedirectResponse,
29
29
  attachLocationStateIfPresent,
30
+ matchAndRecordParams,
30
31
  } from "./helpers.js";
31
- import { renderRscFlightStage, renderRscResponse } from "./render-pipeline.js";
32
+ import {
33
+ createRenderStageTraceBridge,
34
+ renderRscFlightStage,
35
+ renderRscResponse,
36
+ } from "./render-pipeline.js";
37
+ import {
38
+ createRoutineTrace,
39
+ handoff,
40
+ runRoutine,
41
+ scope,
42
+ step,
43
+ type RoutinePlan,
44
+ } from "./routine-plan.js";
32
45
  import type { HandlerContext } from "./handler-context.js";
33
46
  import { gateTransitions } from "./transition-gate.js";
34
47
  import { buildFullPayload } from "./full-payload.js";
@@ -281,6 +294,34 @@ export function handleRscRendering<TEnv>(
281
294
  );
282
295
  }
283
296
 
297
+ interface RequestRenderInput<TEnv> {
298
+ ctx: HandlerContext<TEnv>;
299
+ request: Request;
300
+ env: TEnv;
301
+ url: URL;
302
+ isPartial: boolean;
303
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"];
304
+ nonce: string | undefined;
305
+ renderSpan: TraceSpan;
306
+ reqCtx: ReturnType<typeof getRequestContext>;
307
+ }
308
+
309
+ type ShellServeOutcome =
310
+ | { kind: "serve"; response: Response }
311
+ | { kind: "miss"; descriptor: ShellCaptureDescriptor; ssrModule: SSRModule }
312
+ | { kind: "pass" };
313
+
314
+ type PreparedRender =
315
+ | { kind: "control"; response: Response }
316
+ | {
317
+ kind: "payload";
318
+ payload: RscPayload;
319
+ hasInterceptSlots: boolean;
320
+ pprReplayStatus?: PprReplayStatus;
321
+ partialCaptureNeeded: boolean;
322
+ partialCaptureKey?: string;
323
+ };
324
+
284
325
  async function handleRscRenderingInner<TEnv>(
285
326
  ctx: HandlerContext<TEnv>,
286
327
  request: Request,
@@ -291,13 +332,49 @@ async function handleRscRenderingInner<TEnv>(
291
332
  nonce: string | undefined,
292
333
  renderSpan: TraceSpan,
293
334
  ): Promise<Response> {
335
+ // Flow trace rides the internal debug surface (INTERNAL_RANGO_DEBUG=1): the
336
+ // Vite pipeline bakes the flag at build/dev-server start (inject-client-debug),
337
+ // so trace allocation and output fold out of ordinary production builds while
338
+ // an explicit debug build keeps them. The routine itself remains the executor.
339
+ const mode = isPartial ? "partial" : "document";
340
+ const trace = INTERNAL_RANGO_DEBUG ? createRoutineTrace(mode) : undefined;
294
341
  const reqCtx = getRequestContext();
342
+ const input: RequestRenderInput<TEnv> = {
343
+ ctx,
344
+ request,
345
+ env,
346
+ url,
347
+ isPartial,
348
+ handleStore,
349
+ nonce,
350
+ renderSpan,
351
+ reqCtx,
352
+ };
353
+ try {
354
+ return await runRoutine(requestRenderPlan(input), {
355
+ trace,
356
+ owner: reqCtx,
357
+ });
358
+ } finally {
359
+ if (trace) {
360
+ console.log(
361
+ `[routine] ${request.method} ${url.pathname} (${trace.name})\n${trace.format()}`,
362
+ );
363
+ }
364
+ }
365
+ }
295
366
 
296
- let payload: RscPayload;
297
- let hasInterceptSlots = false;
298
- let pprReplayStatus: PprReplayStatus | undefined;
299
- let partialCaptureNeeded = false;
300
- let partialCaptureKey: string | undefined;
367
+ /**
368
+ * The request-level render plan. Reads top to bottom as the request executes:
369
+ * serve a stored PPR shell if one commits, otherwise match and build the
370
+ * payload, render it through the stage driver, then hand off background
371
+ * captures. Effects run through the routine runner (yield-before-execute,
372
+ * exact result/error identity — see routine-plan.ts).
373
+ */
374
+ function* requestRenderPlan<TEnv>(
375
+ input: RequestRenderInput<TEnv>,
376
+ ): RoutinePlan<Response> {
377
+ const { isPartial, reqCtx } = input;
301
378
 
302
379
  // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
303
380
  //
@@ -305,214 +382,272 @@ async function handleRscRenderingInner<TEnv>(
305
382
  // chain (executeRender wraps it), so no shell byte can precede a guard
306
383
  // decision — that ordering is what makes a shared shell safe. Routes
307
384
  // without the `ppr` option stay pure axis 1 at zero cost.
308
- let pprMiss: {
309
- descriptor: ShellCaptureDescriptor;
310
- ssrModule: SSRModule;
311
- } | null = null;
385
+ const shell = yield* scope("shell-serve", shellServePlan(input));
386
+ if (shell.kind === "serve") return shell.response;
387
+
388
+ // Match + payload. A control response (redirect, prerender collection)
389
+ // ends the plan before any rendering.
390
+ const prepared = yield* scope(
391
+ isPartial ? "prepare:partial" : "prepare:full",
392
+ preparePayloadPlan(input),
393
+ );
394
+ if (prepared.kind === "control") return prepared.response;
395
+
396
+ // For partial requests, include any server-set location state in the payload.
397
+ // SSR (full page) requests ignore location state since there's no history.state
398
+ // to write to on a fresh page load.
399
+ if (isPartial && prepared.payload.metadata) {
400
+ attachLocationStateIfPresent(prepared.payload);
401
+ }
402
+
403
+ const response = yield* step("render", () =>
404
+ renderPreparedRscResponse(input, prepared),
405
+ );
406
+
407
+ // --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
408
+ // Capture only a 200 HTML document (a 404/error render is not a cacheable
409
+ // shell). Capture does not flow through the HTTP pipeline — middleware never
410
+ // re-runs (it already ran for this request; guarding is serve-time).
411
+ // reqCtx._dynamic is re-read here on purpose: a loader may have marked the
412
+ // request dynamic while rendering.
413
+ if (shell.kind === "miss" && !reqCtx._dynamic) {
414
+ if (
415
+ response.status === 200 &&
416
+ (response.headers.get("content-type") ?? "").includes("text/html")
417
+ ) {
418
+ const { descriptor, ssrModule } = shell;
419
+ yield* handoff("shell-capture", () =>
420
+ scheduleShellCapture(
421
+ input.ctx,
422
+ input.request,
423
+ input.env,
424
+ input.url,
425
+ reqCtx,
426
+ ssrModule,
427
+ descriptor,
428
+ ),
429
+ );
430
+ }
431
+ response.headers.set(SHELL_STATUS_HEADER, "MISS");
432
+ }
433
+
312
434
  if (
313
- !isPartial &&
314
- request.method === "GET" &&
315
- !url.searchParams.has("__prerender_collect") &&
316
- !isRscRequest(request, url, false) &&
317
- !reqCtx._dynamic
435
+ isPartial &&
436
+ prepared.partialCaptureNeeded &&
437
+ !reqCtx._dynamic &&
438
+ response.status === 200
318
439
  ) {
319
- const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
320
- if (pprConfig) {
321
- // A per-request CSP nonce pins the route to axis 1: a shared shell would
322
- // freeze the capture request's nonce and CSP would reject it for every
323
- // other visitor. BOTH nonce sources must gate — the createRouter({ nonce })
324
- // provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
325
- // the provider-only check missed the latter (issue #656).
326
- const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
327
- const store = reqCtx._cacheStore;
328
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
329
- // Dev Server-Timing mirror (issue #651): a capture completes AFTER its
330
- // triggering response committed, so its outcome can only ride a LATER
331
- // response's header. Read-and-clear keeps one capture = one report;
332
- // dev-only (see takeCaptureDebugEventForTiming).
333
- if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
334
- const lastCapture = takeCaptureDebugEventForTiming(key);
335
- if (lastCapture) {
336
- appendMetric(
337
- reqCtx._metricsStore,
338
- "ppr:capture",
339
- performance.now(),
340
- lastCapture.attemptMs ?? 0,
341
- undefined,
342
- // attemptMs already rides as this entry's dur — drop it from desc.
343
- describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
344
- );
345
- }
346
- // Same mirror for the previous HIT's tail: its per-stage numbers
347
- // (seed/match/handover/first-html/complete) finished after that
348
- // response's headers were committed, so they ride THIS request's
349
- // Server-Timing as `ppr:tail;dur=<complete ms>`.
350
- const lastTail = takeShellTailTimingForServerTiming(key);
351
- if (lastTail) {
352
- appendMetric(
353
- reqCtx._metricsStore,
354
- "ppr:tail",
355
- performance.now(),
356
- lastTail.completeMs ?? 0,
357
- undefined,
358
- // completeMs already rides as this entry's dur — drop it from desc.
359
- describeShellTailTiming({ ...lastTail, completeMs: undefined }),
360
- );
361
- }
440
+ const captureKey = prepared.partialCaptureKey;
441
+ yield* handoff("navigation-shell-capture", () =>
442
+ scheduleNavigationShellCapture(input, captureKey),
443
+ );
444
+ }
445
+
446
+ return response;
447
+ }
448
+
449
+ /**
450
+ * Axis 2 shell serve. Sync gates stay plan code; store and SSR effects run as
451
+ * named steps. Outcomes: serve a composed shell response, report a MISS (the
452
+ * top-level plan schedules the capture after the response commits), or pass
453
+ * (route not eligible — pure axis 1).
454
+ */
455
+ function* shellServePlan<TEnv>(
456
+ input: RequestRenderInput<TEnv>,
457
+ ): RoutinePlan<ShellServeOutcome> {
458
+ const { ctx, request, env, url, isPartial, handleStore, nonce, reqCtx } =
459
+ input;
460
+
461
+ if (
462
+ isPartial ||
463
+ request.method !== "GET" ||
464
+ url.searchParams.has("__prerender_collect") ||
465
+ isRscRequest(request, url, false) ||
466
+ reqCtx._dynamic
467
+ ) {
468
+ return { kind: "pass" };
469
+ }
470
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
471
+ if (!pprConfig) return { kind: "pass" };
472
+
473
+ // A per-request CSP nonce pins the route to axis 1: a shared shell would
474
+ // freeze the capture request's nonce and CSP would reject it for every
475
+ // other visitor. BOTH nonce sources must gate — the createRouter({ nonce })
476
+ // provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
477
+ // the provider-only check missed the latter (issue #656).
478
+ const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
479
+ const store = reqCtx._cacheStore;
480
+ const key = buildShellKey(url, reqCtx._searchParamsFilter);
481
+ mirrorPprServerTimingsForDev(key, reqCtx);
482
+ if (activeNonce !== undefined) {
483
+ // Declared intent that cannot be honored deserves a diagnostic (unlike an
484
+ // undeclared route, which is silent): a ppr route gated off by an active
485
+ // per-request nonce warns once per key. Axis 1 after the warning.
486
+ warnPprNonceActiveOnce(key);
487
+ return { kind: "pass" };
488
+ }
489
+ if (!hasShellFamily(store)) {
490
+ // Declared intent that cannot be honored deserves a diagnostic (unlike an
491
+ // undeclared route, which is silent). Axis 1 after the warning.
492
+ warnShellStoreMissingOnce(key);
493
+ return { kind: "pass" };
494
+ }
495
+
496
+ // SSR setup starts before route handling, so read the shell before joining it
497
+ // to overlap the remaining setup work with cache I/O.
498
+ const cached = yield* step("shell-read", () =>
499
+ readShellEntry(store, key, reqCtx),
500
+ );
501
+
502
+ // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
503
+ // streaming, so bots/SEO crawlers get one complete axis-1 document. These
504
+ // routes pay one unnecessary shell read to keep the PPR hot path concurrent.
505
+ const [ssrModule, streamMode] = yield* step("ssr-setup", () =>
506
+ getSSRSetup(ctx, request, env, url, reqCtx._metricsStore),
507
+ );
508
+ if (
509
+ streamMode === "allReady" ||
510
+ !ssrModule.resumeShellHTML ||
511
+ !ssrModule.captureShellHTML
512
+ ) {
513
+ return { kind: "pass" };
514
+ }
515
+ const descriptor = createShellCaptureDescriptor(ctx, key, pprConfig, store);
516
+
517
+ if (
518
+ cached &&
519
+ !cached.entry.navigationOnly &&
520
+ isValidShellHit(cached.entry, ctx.version)
521
+ ) {
522
+ if (!hasIntactShellPayload(cached.entry)) {
523
+ // Corrupt stored payload (undecodable prelude / unparseable
524
+ // postponed): a store-layer fault worth a diagnostic, unlike the
525
+ // silent version-mismatch lifecycle misses above. Degrade to MISS
526
+ // — the top-level plan schedules the recapture that overwrites it.
527
+ reportCacheError(
528
+ new Error(
529
+ `corrupt shell entry for "${key}": prelude/postponed failed ` +
530
+ "the integrity check; serving axis 1 and recapturing",
531
+ ),
532
+ "cache-read",
533
+ "[ShellServe] getShell",
534
+ );
535
+ } else {
536
+ // Stale (SWR) hit: serve the stale shell now, recapture in the
537
+ // background (stampede-guarded + backoff inside scheduleShellCapture).
538
+ if (cached.shouldRevalidate) {
539
+ yield* handoff("shell-recapture", () =>
540
+ scheduleShellCapture(
541
+ ctx,
542
+ request,
543
+ env,
544
+ url,
545
+ reqCtx,
546
+ ssrModule,
547
+ descriptor,
548
+ ),
549
+ );
362
550
  }
363
- if (activeNonce !== undefined) {
364
- // Declared intent that cannot be honored deserves a diagnostic (unlike an
365
- // undeclared route, which is silent): a ppr route gated off by an active
366
- // per-request nonce warns once per key. Axis 1 after the warning.
367
- warnPprNonceActiveOnce(key);
368
- } else if (!hasShellFamily(store)) {
369
- // Declared intent that cannot be honored deserves a diagnostic (unlike an
370
- // undeclared route, which is silent). Axis 1 after the warning.
371
- warnShellStoreMissingOnce(key);
372
- } else {
373
- // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
374
- // streaming, so bots/SEO crawlers get one complete axis-1 document.
375
- const [ssrModule, streamMode] = await getSSRSetup(
551
+ const entry = cached.entry;
552
+ const response = yield* step("shell-hit", () =>
553
+ serveShellHit(
376
554
  ctx,
377
555
  request,
378
556
  env,
379
557
  url,
380
- reqCtx._metricsStore,
381
- );
382
- if (
383
- streamMode !== "allReady" &&
384
- ssrModule.resumeShellHTML &&
385
- ssrModule.captureShellHTML
386
- ) {
387
- const descriptor = createShellCaptureDescriptor(
388
- ctx,
389
- key,
390
- pprConfig,
391
- store,
392
- );
393
- // One serve funnel for BOTH entry sources (runtime store hit below,
394
- // build-manifest hit further down): schedule the background
395
- // recapture when asked, then commit the composed response.
396
- const serveHit = (
397
- entry: DocumentShellCacheEntry,
398
- revalidate: boolean | undefined,
399
- ): Response => {
400
- if (revalidate) {
401
- scheduleShellCapture(
402
- ctx,
403
- request,
404
- env,
405
- url,
406
- reqCtx,
407
- ssrModule,
408
- descriptor,
409
- );
410
- }
411
- return serveShellHit(
412
- ctx,
413
- request,
414
- env,
415
- url,
416
- reqCtx,
417
- handleStore,
418
- ssrModule,
419
- entry,
420
- descriptor,
421
- );
422
- };
423
- let cached: Awaited<ReturnType<typeof store.getShell>> = null;
424
- const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
425
- try {
426
- cached = await store.getShell(key);
427
- } catch (error) {
428
- // A failing store read degrades to axis 1 (MISS), never a 500.
429
- reportCacheError(error, "cache-read", "[ShellServe] getShell");
430
- }
431
- if (reqCtx._metricsStore) {
432
- // Raw store outcome (pre-validity-gates), so a version-mismatch
433
- // lifecycle miss is still distinguishable from a store miss.
434
- appendMetric(
435
- reqCtx._metricsStore,
436
- "ppr:shell-read",
437
- shellReadStart,
438
- performance.now() - shellReadStart,
439
- undefined,
440
- cached ? "hit" : "miss",
441
- );
442
- }
443
- if (
444
- cached &&
445
- !cached.entry.navigationOnly &&
446
- isValidShellHit(cached.entry, ctx.version)
447
- ) {
448
- if (!hasIntactShellPayload(cached.entry)) {
449
- // Corrupt stored payload (undecodable prelude / unparseable
450
- // postponed): a store-layer fault worth a diagnostic, unlike the
451
- // silent version-mismatch lifecycle misses above. Degrade to MISS
452
- // — pprMiss below schedules the recapture that overwrites it.
453
- reportCacheError(
454
- new Error(
455
- `corrupt shell entry for "${key}": prelude/postponed failed ` +
456
- "the integrity check; serving axis 1 and recapturing",
457
- ),
458
- "cache-read",
459
- "[ShellServe] getShell",
460
- );
461
- } else {
462
- // Stale (SWR) hit: serve the stale shell now, recapture in the
463
- // background (stampede-guarded + backoff inside scheduleShellCapture).
464
- return serveHit(cached.entry, cached.shouldRevalidate);
465
- }
466
- }
467
- // Build-time shell read-through (producer B, #699): on a runtime
468
- // store MISS a Prerender+ppr route serves its `vite build`-baked
469
- // shell through the SAME serveShellHit. lookupBuildShell owns every
470
- // gate and fails to null (ordinary MISS path takes over); past
471
- // ppr.ttl the baked entry still serves while SWR recaptures — the
472
- // upgrade path from build entry to runtime entry.
473
- const buildHit = await lookupBuildShell(
474
- url,
475
- ctx.version,
476
- store,
477
- // Dev: no build manifest exists; producer B runs on demand via
478
- // the dev server's /__rsc_shell endpoint for PRERENDERED routes
479
- // only (production's exact candidate set). Folded away in
480
- // production builds (NODE_ENV is a compile-time constant).
481
- resolveDevShellLookup(reqCtx, pprConfig),
482
- reqCtx._searchParamsFilter,
483
- );
484
- if (buildHit) {
485
- // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
486
- return serveHit(buildHit.entry, buildHit.stale);
487
- }
488
- // MISS (no entry, invalid reactVersion, or store read failure): axis 1
489
- // + a background capture scheduled once the response is known servable.
490
- pprMiss = { descriptor, ssrModule };
491
- }
492
- }
558
+ reqCtx,
559
+ handleStore,
560
+ ssrModule,
561
+ entry,
562
+ descriptor,
563
+ ),
564
+ );
565
+ return { kind: "serve", response };
493
566
  }
494
567
  }
495
568
 
569
+ // Build-time shell read-through (producer B, #699): on a runtime
570
+ // store MISS a Prerender+ppr route serves its `vite build`-baked
571
+ // shell through the SAME serve path. lookupBuildShell owns every
572
+ // gate and fails to null (ordinary MISS path takes over); past
573
+ // ppr.ttl the baked entry still serves while SWR recaptures — the
574
+ // upgrade path from build entry to runtime entry.
575
+ const buildHit = yield* step("build-shell-lookup", () =>
576
+ lookupBuildShell(
577
+ url,
578
+ ctx.version,
579
+ store,
580
+ // Dev: no build manifest exists; producer B runs on demand via
581
+ // the dev server's /__rsc_shell endpoint for PRERENDERED routes
582
+ // only (production's exact candidate set). Folded away in
583
+ // production builds (NODE_ENV is a compile-time constant).
584
+ resolveDevShellLookup(reqCtx, pprConfig),
585
+ reqCtx._searchParamsFilter,
586
+ ),
587
+ );
588
+ if (buildHit) {
589
+ // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
590
+ if (buildHit.stale) {
591
+ yield* handoff("shell-recapture", () =>
592
+ scheduleShellCapture(
593
+ ctx,
594
+ request,
595
+ env,
596
+ url,
597
+ reqCtx,
598
+ ssrModule,
599
+ descriptor,
600
+ ),
601
+ );
602
+ }
603
+ const response = yield* step("shell-hit", () =>
604
+ serveShellHit(
605
+ ctx,
606
+ request,
607
+ env,
608
+ url,
609
+ reqCtx,
610
+ handleStore,
611
+ ssrModule,
612
+ buildHit.entry,
613
+ descriptor,
614
+ ),
615
+ );
616
+ return { kind: "serve", response };
617
+ }
618
+
619
+ // MISS (no entry, invalid reactVersion, or store read failure): axis 1
620
+ // + a background capture scheduled once the response is known servable.
621
+ return { kind: "miss", descriptor, ssrModule };
622
+ }
623
+
624
+ /**
625
+ * Match the request and assemble the RSC payload, or produce a control
626
+ * response (redirect, prerender collection) that ends the request plan.
627
+ */
628
+ function* preparePayloadPlan<TEnv>(
629
+ input: RequestRenderInput<TEnv>,
630
+ ): RoutinePlan<PreparedRender> {
631
+ const { ctx, request, env, url, isPartial, handleStore, nonce, reqCtx } =
632
+ input;
633
+
496
634
  if (isPartial) {
497
635
  // Partial render (navigation)
498
- const replay = await matchPartialWithPprReplay(
499
- ctx,
500
- request,
501
- env,
502
- url,
503
- reqCtx,
504
- nonce,
636
+ const replay = yield* step("match:ppr-replay", () =>
637
+ matchPartialAndRecordParams(ctx, request, env, url, reqCtx, nonce),
505
638
  );
506
639
  const result = replay.result;
507
- pprReplayStatus = replay.status;
508
- partialCaptureNeeded =
640
+ const pprReplayStatus = replay.status;
641
+ const partialCaptureNeeded =
509
642
  "captureNeeded" in replay && replay.captureNeeded === true;
510
- partialCaptureKey = "captureKey" in replay ? replay.captureKey : undefined;
643
+ const partialCaptureKey =
644
+ "captureKey" in replay ? replay.captureKey : undefined;
511
645
 
512
646
  if (!result) {
513
647
  // Fall back to full render
514
- const match = await ctx.router.match(request, { env });
515
- setRequestContextParams(match.params, match.routeName);
648
+ const match = yield* step("match:fallback", () =>
649
+ matchAndRecordParams(ctx, request, env),
650
+ );
516
651
 
517
652
  if (match.redirect) {
518
653
  // Partial request: use X-RSC-Redirect header so the client can
@@ -529,16 +664,22 @@ async function handleRscRenderingInner<TEnv>(
529
664
  serializePprReplayStatus(pprReplayStatus),
530
665
  );
531
666
  }
532
- return redirectResponse;
667
+ return { kind: "control", response: redirectResponse };
533
668
  }
534
669
 
535
- payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
536
- } else {
537
- setRequestContextParams(result.params, result.routeName);
538
-
539
- hasInterceptSlots = !!result.slots;
670
+ return {
671
+ kind: "payload",
672
+ payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
673
+ hasInterceptSlots: false,
674
+ pprReplayStatus,
675
+ partialCaptureNeeded,
676
+ partialCaptureKey,
677
+ };
678
+ }
540
679
 
541
- payload = {
680
+ return {
681
+ kind: "payload",
682
+ payload: {
542
683
  metadata: {
543
684
  pathname: url.pathname,
544
685
  // routerId is serialized on every payload (including within-session
@@ -566,62 +707,191 @@ async function handleRscRenderingInner<TEnv>(
566
707
  defaultPrefetch: ctx.router.defaultPrefetch,
567
708
  stateCookieName: ctx.router.resolvedStateCookieName,
568
709
  },
569
- };
570
- }
571
- } else {
572
- // Full render (initial page load)
573
- const match = await ctx.router.match(request, { env });
574
- setRequestContextParams(match.params, match.routeName);
710
+ },
711
+ hasInterceptSlots: !!result.slots,
712
+ pprReplayStatus,
713
+ partialCaptureNeeded,
714
+ partialCaptureKey,
715
+ };
716
+ }
717
+
718
+ // Full render (initial page load)
719
+ const match = yield* step("match", () =>
720
+ matchAndRecordParams(ctx, request, env),
721
+ );
575
722
 
576
- if (match.redirect) {
577
- return createResponseWithMergedHeaders(null, {
723
+ if (match.redirect) {
724
+ return {
725
+ kind: "control",
726
+ response: createResponseWithMergedHeaders(null, {
578
727
  status: 308,
579
728
  headers: { Location: match.redirect },
580
- });
581
- }
729
+ }),
730
+ };
731
+ }
732
+
733
+ // Caching is now handled in router.match() via cache provider in request context
734
+ // match.segments already contains cached or fresh segments as appropriate
582
735
 
583
- // Caching is now handled in router.match() via cache provider in request context
584
- // match.segments already contains cached or fresh segments as appropriate
736
+ if (url.searchParams.has("__prerender_collect")) {
737
+ const response = yield* step("prerender-collect", () =>
738
+ collectPrerenderArtifacts(match, handleStore),
739
+ );
740
+ return { kind: "control", response };
741
+ }
742
+
743
+ return {
744
+ kind: "payload",
745
+ payload: buildFullPayload(match, ctx, url, reqCtx, handleStore),
746
+ hasInterceptSlots: false,
747
+ partialCaptureNeeded: false,
748
+ };
749
+ }
750
+
751
+ /**
752
+ * PPR replay match that stamps params/routeName when a replay result exists.
753
+ * A null result records nothing — the fallback match records its own.
754
+ */
755
+ async function matchPartialAndRecordParams<TEnv>(
756
+ ctx: HandlerContext<TEnv>,
757
+ request: Request,
758
+ env: TEnv,
759
+ url: URL,
760
+ reqCtx: RequestContext<any>,
761
+ nonce: string | undefined,
762
+ ): Promise<Awaited<ReturnType<typeof matchPartialWithPprReplay<TEnv>>>> {
763
+ const replay = await matchPartialWithPprReplay(
764
+ ctx,
765
+ request,
766
+ env,
767
+ url,
768
+ reqCtx,
769
+ nonce,
770
+ );
771
+ if (replay.result) {
772
+ setRequestContextParams(replay.result.params, replay.result.routeName);
773
+ }
774
+ return replay;
775
+ }
776
+
777
+ /**
778
+ * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
779
+ * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
780
+ * version-mismatch lifecycle miss stays distinguishable from a store miss).
781
+ */
782
+ async function readShellEntry<
783
+ TStore extends { getShell(key: string): Promise<unknown> },
784
+ >(
785
+ store: TStore,
786
+ key: string,
787
+ reqCtx: ReturnType<typeof getRequestContext>,
788
+ ): Promise<Awaited<ReturnType<TStore["getShell"]>> | null> {
789
+ let cached: Awaited<ReturnType<TStore["getShell"]>> | null = null;
790
+ const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
791
+ try {
792
+ // The constraint types the call as Promise<unknown>; the instantiated
793
+ // TStore carries the real entry type, so the assertion restores it.
794
+ cached = (await store.getShell(key)) as Awaited<
795
+ ReturnType<TStore["getShell"]>
796
+ >;
797
+ } catch (error) {
798
+ reportCacheError(error, "cache-read", "[ShellServe] getShell");
799
+ }
800
+ if (reqCtx._metricsStore) {
801
+ appendMetric(
802
+ reqCtx._metricsStore,
803
+ "ppr:shell-read",
804
+ shellReadStart,
805
+ performance.now() - shellReadStart,
806
+ undefined,
807
+ cached ? "hit" : "miss",
808
+ );
809
+ }
810
+ return cached;
811
+ }
585
812
 
586
- if (url.searchParams.has("__prerender_collect")) {
587
- // Build-time prerender collection: serialize segments and handle data
588
- // to JSON for storage as build artifacts. At runtime the worker
589
- // deserializes these and feeds them through the normal segment pipeline.
590
- const nonLoaderSegments = match.segments.filter(
591
- (s) => s.type !== "loader",
813
+ /**
814
+ * Dev Server-Timing mirror (issue #651): a capture completes AFTER its
815
+ * triggering response committed, so its outcome can only ride a LATER
816
+ * response's header. Read-and-clear keeps one capture = one report;
817
+ * dev-only (see takeCaptureDebugEventForTiming).
818
+ */
819
+ function mirrorPprServerTimingsForDev(
820
+ key: string,
821
+ reqCtx: ReturnType<typeof getRequestContext>,
822
+ ): void {
823
+ if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
824
+ const lastCapture = takeCaptureDebugEventForTiming(key);
825
+ if (lastCapture) {
826
+ appendMetric(
827
+ reqCtx._metricsStore,
828
+ "ppr:capture",
829
+ performance.now(),
830
+ lastCapture.attemptMs ?? 0,
831
+ undefined,
832
+ // attemptMs already rides as this entry's dur — drop it from desc.
833
+ describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
592
834
  );
593
- handleStore.seal();
594
- await handleStore.settled;
595
- const { serializeSegments } = await import("../cache/segment-codec.js");
596
- const serializedSegments = await serializeSegments(nonLoaderSegments);
597
- const handles: Record<string, Record<string, unknown[]>> = {};
598
- for (const seg of nonLoaderSegments) {
599
- const segHandles = handleStore.getDataForSegment(seg.id);
600
- if (Object.keys(segHandles).length > 0) {
601
- handles[seg.id] = segHandles;
602
- }
603
- }
604
- return new Response(
605
- JSON.stringify({
606
- segments: serializedSegments,
607
- handles,
608
- routeName: match.routeName,
609
- params: match.params,
610
- }),
611
- { headers: { "Content-Type": "application/json" } },
835
+ }
836
+ // Same mirror for the previous HIT's tail: its per-stage numbers
837
+ // (seed/match/handover/first-html/complete) finished after that
838
+ // response's headers were committed, so they ride THIS request's
839
+ // Server-Timing as `ppr:tail;dur=<complete ms>`.
840
+ const lastTail = takeShellTailTimingForServerTiming(key);
841
+ if (lastTail) {
842
+ appendMetric(
843
+ reqCtx._metricsStore,
844
+ "ppr:tail",
845
+ performance.now(),
846
+ lastTail.completeMs ?? 0,
847
+ undefined,
848
+ // completeMs already rides as this entry's dur — drop it from desc.
849
+ describeShellTailTiming({ ...lastTail, completeMs: undefined }),
612
850
  );
613
- } else {
614
- payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
615
851
  }
616
852
  }
853
+ }
617
854
 
618
- // For partial requests, include any server-set location state in the payload.
619
- // SSR (full page) requests ignore location state since there's no history.state
620
- // to write to on a fresh page load.
621
- if (isPartial && payload.metadata) {
622
- attachLocationStateIfPresent(payload);
855
+ /**
856
+ * Build-time prerender collection: serialize segments and handle data
857
+ * to JSON for storage as build artifacts. At runtime the worker
858
+ * deserializes these and feeds them through the normal segment pipeline.
859
+ */
860
+ async function collectPrerenderArtifacts<TEnv>(
861
+ match: Awaited<ReturnType<HandlerContext<TEnv>["router"]["match"]>>,
862
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
863
+ ): Promise<Response> {
864
+ const nonLoaderSegments = match.segments.filter((s) => s.type !== "loader");
865
+ handleStore.seal();
866
+ await handleStore.settled;
867
+ const { serializeSegments } = await import("../cache/segment-codec.js");
868
+ const serializedSegments = await serializeSegments(nonLoaderSegments);
869
+ const handles: Record<string, Record<string, unknown[]>> = {};
870
+ for (const seg of nonLoaderSegments) {
871
+ const segHandles = handleStore.getDataForSegment(seg.id);
872
+ if (Object.keys(segHandles).length > 0) {
873
+ handles[seg.id] = segHandles;
874
+ }
623
875
  }
876
+ return new Response(
877
+ JSON.stringify({
878
+ segments: serializedSegments,
879
+ handles,
880
+ routeName: match.routeName,
881
+ params: match.params,
882
+ }),
883
+ { headers: { "Content-Type": "application/json" } },
884
+ );
885
+ }
624
886
 
887
+ /** Assemble headers + tracking and run the foreground stage-driver render. */
888
+ function renderPreparedRscResponse<TEnv>(
889
+ input: RequestRenderInput<TEnv>,
890
+ prepared: Extract<PreparedRender, { kind: "payload" }>,
891
+ ): Promise<Response> {
892
+ const { ctx, request, env, url, isPartial, nonce, reqCtx, renderSpan } =
893
+ input;
894
+ const { payload, pprReplayStatus, hasInterceptSlots } = prepared;
625
895
  const metricsStore = reqCtx._metricsStore;
626
896
 
627
897
  const rscHeaders: Record<string, string> = {
@@ -661,8 +931,11 @@ async function handleRscRenderingInner<TEnv>(
661
931
  mode: isPartial ? ("partial" as const) : ("full" as const),
662
932
  routeKey: reqCtx._routeName,
663
933
  span: renderSpan,
934
+ onEvent:
935
+ reqCtx._activeRoutine &&
936
+ createRenderStageTraceBridge(reqCtx._activeRoutine),
664
937
  };
665
- const response = await renderRscResponse(
938
+ return renderRscResponse(
666
939
  {
667
940
  ctx,
668
941
  request,
@@ -686,65 +959,45 @@ async function handleRscRenderingInner<TEnv>(
686
959
  }),
687
960
  },
688
961
  );
962
+ }
689
963
 
690
- // --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
691
- // Capture only a 200 HTML document (a 404/error render is not a cacheable
692
- // shell). Capture does not flow through the HTTP pipeline — middleware never
693
- // re-runs (it already ran for this request; guarding is serve-time).
694
- if (pprMiss && !reqCtx._dynamic) {
695
- if (
696
- response.status === 200 &&
697
- (response.headers.get("content-type") ?? "").includes("text/html")
698
- ) {
699
- scheduleShellCapture(
964
+ /**
965
+ * Background capture for a navigation-only shell (partial replay reported
966
+ * captureNeeded). SSR setup is resolved lazily inside the capture because the
967
+ * partial request itself never touched HTML rendering.
968
+ */
969
+ function scheduleNavigationShellCapture<TEnv>(
970
+ input: RequestRenderInput<TEnv>,
971
+ partialCaptureKey: string | undefined,
972
+ ): void {
973
+ const { ctx, request, env, url, reqCtx } = input;
974
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
975
+ const store = reqCtx._cacheStore!;
976
+ scheduleShellCapture(
977
+ ctx,
978
+ request,
979
+ env,
980
+ url,
981
+ reqCtx,
982
+ async (captureRequest, captureUrl) => {
983
+ const [ssrModule, streamMode] = await getSSRSetup(
700
984
  ctx,
701
- request,
985
+ captureRequest,
702
986
  env,
703
- url,
704
- reqCtx,
705
- pprMiss.ssrModule,
706
- pprMiss.descriptor,
987
+ captureUrl,
988
+ undefined,
707
989
  );
708
- }
709
- response.headers.set(SHELL_STATUS_HEADER, "MISS");
710
- }
711
-
712
- if (
713
- isPartial &&
714
- partialCaptureNeeded &&
715
- !reqCtx._dynamic &&
716
- response.status === 200
717
- ) {
718
- const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
719
- const store = reqCtx._cacheStore!;
720
- scheduleShellCapture(
990
+ return streamMode === "allReady" ? null : ssrModule;
991
+ },
992
+ createShellCaptureDescriptor(
721
993
  ctx,
722
- request,
723
- env,
724
- url,
725
- reqCtx,
726
- async (captureRequest, captureUrl) => {
727
- const [ssrModule, streamMode] = await getSSRSetup(
728
- ctx,
729
- captureRequest,
730
- env,
731
- captureUrl,
732
- undefined,
733
- );
734
- return streamMode === "allReady" ? null : ssrModule;
735
- },
736
- createShellCaptureDescriptor(
737
- ctx,
738
- partialCaptureKey ??
739
- buildNavigationShellKey(url, reqCtx._searchParamsFilter),
740
- pprConfig,
741
- store,
742
- true,
743
- ),
744
- );
745
- }
746
-
747
- return response;
994
+ partialCaptureKey ??
995
+ buildNavigationShellKey(url, reqCtx._searchParamsFilter),
996
+ pprConfig,
997
+ store,
998
+ true,
999
+ ),
1000
+ );
748
1001
  }
749
1002
 
750
1003
  /**