@rangojs/router 0.27.0 → 0.29.0

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 (243) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/__internal.d.ts +7 -1
  3. package/dist/types/browser/action-fence.d.ts +2 -1
  4. package/dist/types/browser/event-controller.d.ts +8 -2
  5. package/dist/types/browser/history-state.d.ts +3 -9
  6. package/dist/types/browser/navigation-store.d.ts +8 -6
  7. package/dist/types/browser/partial-update.d.ts +11 -0
  8. package/dist/types/browser/react/filter-segment-order.d.ts +2 -2
  9. package/dist/types/browser/react/use-action.d.ts +7 -2
  10. package/dist/types/browser/react/use-handle.d.ts +19 -16
  11. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  12. package/dist/types/browser/segment-reconciler.d.ts +32 -4
  13. package/dist/types/browser/types.d.ts +24 -5
  14. package/dist/types/browser/wire-protocol.d.ts +26 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +11 -5
  16. package/dist/types/cache/cache-scope.d.ts +34 -22
  17. package/dist/types/cache/cf/cf-cache-constants.d.ts +2 -2
  18. package/dist/types/cache/cf/cf-cache-store.d.ts +47 -65
  19. package/dist/types/cache/cf/cf-kv-utils.d.ts +1 -1
  20. package/dist/types/cache/handle-snapshot.d.ts +9 -4
  21. package/dist/types/cache/segment-codec.d.ts +14 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  23. package/dist/types/cache/taint.d.ts +5 -3
  24. package/dist/types/cache/types.d.ts +7 -3
  25. package/dist/types/cache/vercel/vercel-cache-store.d.ts +8 -6
  26. package/dist/types/prefetch-markers.d.ts +5 -14
  27. package/dist/types/prerender/store.d.ts +31 -0
  28. package/dist/types/prerender/warm-request.d.ts +3 -3
  29. package/dist/types/prerender.d.ts +2 -2
  30. package/dist/types/route-name.d.ts +5 -0
  31. package/dist/types/router/error-handling.d.ts +11 -5
  32. package/dist/types/router/instrument.d.ts +4 -3
  33. package/dist/types/router/intercept-resolution.d.ts +25 -9
  34. package/dist/types/router/match-context.d.ts +6 -11
  35. package/dist/types/router/match-handlers.d.ts +2 -6
  36. package/dist/types/router/match-middleware/background-revalidation.d.ts +13 -22
  37. package/dist/types/router/match-middleware/cache-lookup.d.ts +34 -86
  38. package/dist/types/router/match-middleware/segment-resolution.d.ts +8 -6
  39. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  40. package/dist/types/router/prerender-match.d.ts +21 -6
  41. package/dist/types/router/preview-match.d.ts +2 -7
  42. package/dist/types/router/request-classification.d.ts +5 -0
  43. package/dist/types/router/router-context.d.ts +7 -46
  44. package/dist/types/router/router-interfaces.d.ts +8 -14
  45. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  46. package/dist/types/router/segment-resolution/loader-cache.d.ts +3 -3
  47. package/dist/types/router/segment-resolution/loader-mask.d.ts +4 -4
  48. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +20 -18
  49. package/dist/types/router/segment-resolution/resolution-pass.d.ts +95 -0
  50. package/dist/types/router/segment-resolution/revalidation.d.ts +82 -31
  51. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  52. package/dist/types/router/segment-resolution.d.ts +1 -2
  53. package/dist/types/router/segment-wrappers.d.ts +11 -48
  54. package/dist/types/router/telemetry.d.ts +2 -0
  55. package/dist/types/router/types.d.ts +11 -1
  56. package/dist/types/rsc/full-payload.d.ts +2 -2
  57. package/dist/types/rsc/handler-context.d.ts +6 -6
  58. package/dist/types/rsc/json-route-result.d.ts +5 -9
  59. package/dist/types/rsc/request-stages.d.ts +203 -0
  60. package/dist/types/rsc/response-cache-serve.d.ts +7 -12
  61. package/dist/types/rsc/response-route-handler.d.ts +2 -2
  62. package/dist/types/rsc/routine-plan.d.ts +1 -1
  63. package/dist/types/rsc/server-action.d.ts +2 -2
  64. package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
  65. package/dist/types/rsc/shell-capture.d.ts +132 -15
  66. package/dist/types/rsc/shell-serve.d.ts +37 -12
  67. package/dist/types/server/context.d.ts +9 -8
  68. package/dist/types/server/handle-store.d.ts +16 -0
  69. package/dist/types/server/render-pass.d.ts +72 -0
  70. package/dist/types/server/request-context.d.ts +53 -104
  71. package/dist/types/server/rewrite-trail.d.ts +2 -2
  72. package/dist/types/testing/dispatch.d.ts +35 -18
  73. package/dist/types/testing/render-route.d.ts +43 -8
  74. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  75. package/dist/types/types/cache-types.d.ts +6 -3
  76. package/dist/types/types/error-types.d.ts +30 -3
  77. package/dist/types/types/handler-context.d.ts +9 -1
  78. package/dist/types/types/segments.d.ts +1 -1
  79. package/dist/types/vite/discovery/build-discovery-phase.d.ts +40 -0
  80. package/dist/types/vite/discovery/build-env.d.ts +22 -0
  81. package/dist/types/vite/discovery/bundle-scan-phase.d.ts +12 -0
  82. package/dist/types/vite/discovery/dev-prerender-cache.d.ts +3 -2
  83. package/dist/types/vite/discovery/dev-server-phase.d.ts +26 -0
  84. package/dist/types/vite/discovery/discovery-errors.d.ts +1 -1
  85. package/dist/types/vite/discovery/state.d.ts +12 -0
  86. package/dist/types/vite/plugins/version-plugin.d.ts +1 -1
  87. package/dist/types/vite/plugins/virtual-entries.d.ts +1 -1
  88. package/dist/types/vite/router-discovery.d.ts +8 -47
  89. package/dist/types/vite/temp-rsc-server.d.ts +39 -0
  90. package/dist/types/vite/utils/prerender-utils.d.ts +9 -0
  91. package/dist/types/wire-params.d.ts +39 -0
  92. package/dist/vite/index.js +3822 -3727
  93. package/package.json +1 -1
  94. package/skills/cache-guide/SKILL.md +8 -5
  95. package/skills/caching/SKILL.md +37 -18
  96. package/skills/client-urls/SKILL.md +4 -2
  97. package/skills/document-cache/SKILL.md +13 -1
  98. package/skills/handler-use/SKILL.md +1 -1
  99. package/skills/hooks/SKILL.md +1 -1
  100. package/skills/hooks/data.md +8 -1
  101. package/skills/hooks/handle-and-actions.md +33 -2
  102. package/skills/hooks/navigation.md +121 -15
  103. package/skills/hooks/state.md +6 -4
  104. package/skills/hooks/urls.md +16 -5
  105. package/skills/intercept/SKILL.md +23 -7
  106. package/skills/middleware/SKILL.md +18 -0
  107. package/skills/observability/SKILL.md +30 -0
  108. package/skills/ppr/SKILL.md +18 -3
  109. package/skills/prerender/SKILL.md +39 -15
  110. package/skills/rango/SKILL.md +5 -1
  111. package/skills/server-actions/SKILL.md +15 -1
  112. package/skills/testing/client-components.md +25 -18
  113. package/skills/testing/response-routes.md +6 -4
  114. package/skills/testing/serve-shell-request.md +7 -7
  115. package/skills/use-cache/SKILL.md +46 -8
  116. package/skills/view-transitions/SKILL.md +1 -1
  117. package/src/__internal.ts +7 -1
  118. package/src/browser/action-fence.ts +2 -1
  119. package/src/browser/connection-warmup.ts +3 -1
  120. package/src/browser/event-controller.ts +37 -13
  121. package/src/browser/history-state.ts +6 -12
  122. package/src/browser/navigation-bridge.ts +59 -26
  123. package/src/browser/navigation-client.ts +20 -25
  124. package/src/browser/navigation-store.ts +25 -27
  125. package/src/browser/navigation-transaction.ts +4 -1
  126. package/src/browser/network-error-handler.ts +4 -0
  127. package/src/browser/partial-update.ts +33 -6
  128. package/src/browser/prefetch/cache.ts +2 -1
  129. package/src/browser/prefetch/fetch.ts +8 -16
  130. package/src/browser/prefetch/queue.ts +4 -4
  131. package/src/browser/react/NavigationProvider.tsx +2 -1
  132. package/src/browser/react/filter-segment-order.ts +2 -2
  133. package/src/browser/react/use-action.ts +24 -48
  134. package/src/browser/react/use-handle.ts +22 -18
  135. package/src/browser/react/use-link-status.ts +48 -60
  136. package/src/browser/react/use-navigation.ts +92 -41
  137. package/src/browser/rsc-router.tsx +1 -0
  138. package/src/browser/segment-reconciler.ts +109 -53
  139. package/src/browser/server-action-bridge.ts +81 -61
  140. package/src/browser/types.ts +25 -5
  141. package/src/browser/wire-protocol.ts +43 -0
  142. package/src/cache/cache-key-utils.ts +11 -5
  143. package/src/cache/cache-runtime.ts +132 -75
  144. package/src/cache/cache-scope.ts +110 -64
  145. package/src/cache/cf/cf-cache-constants.ts +2 -2
  146. package/src/cache/cf/cf-cache-store.ts +486 -874
  147. package/src/cache/cf/cf-kv-utils.ts +1 -1
  148. package/src/cache/document-cache.ts +64 -46
  149. package/src/cache/handle-snapshot.ts +19 -7
  150. package/src/cache/read-through-swr.ts +20 -18
  151. package/src/cache/segment-codec.ts +55 -0
  152. package/src/cache/shell-snapshot.ts +2 -2
  153. package/src/cache/tag-invalidation.ts +6 -2
  154. package/src/cache/taint.ts +5 -3
  155. package/src/cache/types.ts +7 -3
  156. package/src/cache/vercel/vercel-cache-store.ts +243 -310
  157. package/src/handles/pushed-by.ts +1 -1
  158. package/src/prefetch-markers.ts +5 -16
  159. package/src/prerender/store.ts +76 -0
  160. package/src/prerender/warm-request.ts +8 -4
  161. package/src/prerender.ts +2 -2
  162. package/src/route-name.ts +8 -0
  163. package/src/router/error-handling.ts +22 -10
  164. package/src/router/instrument.ts +4 -3
  165. package/src/router/intercept-resolution.ts +82 -75
  166. package/src/router/loader-resolution.ts +5 -4
  167. package/src/router/match-api.ts +5 -5
  168. package/src/router/match-context.ts +23 -12
  169. package/src/router/match-handlers.ts +8 -20
  170. package/src/router/match-middleware/background-revalidation.ts +46 -57
  171. package/src/router/match-middleware/cache-lookup.ts +677 -726
  172. package/src/router/match-middleware/cache-store.ts +4 -8
  173. package/src/router/match-middleware/intercept-resolution.ts +3 -23
  174. package/src/router/match-middleware/segment-resolution.ts +34 -61
  175. package/src/router/middleware.ts +69 -11
  176. package/src/router/navigation-snapshot.ts +14 -4
  177. package/src/router/prerender-match.ts +100 -44
  178. package/src/router/preview-match.ts +6 -41
  179. package/src/router/request-classification.ts +13 -7
  180. package/src/router/route-snapshot.ts +2 -5
  181. package/src/router/router-context.ts +10 -95
  182. package/src/router/router-interfaces.ts +8 -14
  183. package/src/router/segment-resolution/helpers.ts +49 -13
  184. package/src/router/segment-resolution/loader-cache.ts +15 -9
  185. package/src/router/segment-resolution/loader-mask.ts +5 -5
  186. package/src/router/segment-resolution/prefetch-deferral.ts +30 -27
  187. package/src/router/segment-resolution/resolution-pass.ts +139 -0
  188. package/src/router/segment-resolution/revalidation.ts +939 -789
  189. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  190. package/src/router/segment-resolution.ts +4 -13
  191. package/src/router/segment-wrappers.ts +51 -269
  192. package/src/router/telemetry.ts +5 -0
  193. package/src/router/types.ts +11 -1
  194. package/src/router.ts +19 -17
  195. package/src/rsc/full-payload.ts +6 -5
  196. package/src/rsc/handler-context.ts +8 -14
  197. package/src/rsc/handler.ts +126 -507
  198. package/src/rsc/json-route-result.ts +5 -9
  199. package/src/rsc/loader-fetch.ts +6 -5
  200. package/src/rsc/progressive-enhancement.ts +5 -0
  201. package/src/rsc/request-stages.ts +759 -0
  202. package/src/rsc/response-cache-serve.ts +10 -14
  203. package/src/rsc/response-route-handler.ts +13 -5
  204. package/src/rsc/rewrite-passes.ts +2 -1
  205. package/src/rsc/routine-plan.ts +1 -1
  206. package/src/rsc/rsc-rendering.ts +164 -349
  207. package/src/rsc/server-action.ts +87 -113
  208. package/src/rsc/shell-capture-constants.ts +1 -1
  209. package/src/rsc/shell-capture.ts +275 -227
  210. package/src/rsc/shell-serve.ts +121 -18
  211. package/src/rsc/ssr-setup.ts +5 -4
  212. package/src/segment-system.tsx +3 -3
  213. package/src/server/context.ts +22 -25
  214. package/src/server/handle-store.ts +55 -0
  215. package/src/server/render-pass.ts +291 -0
  216. package/src/server/request-context.ts +75 -115
  217. package/src/server/rewrite-trail.ts +6 -4
  218. package/src/ssr/index.tsx +1 -1
  219. package/src/testing/dispatch.ts +226 -710
  220. package/src/testing/e2e/page-helpers.ts +2 -2
  221. package/src/testing/render-route.tsx +199 -13
  222. package/src/testing/serve-shell-request.ts +36 -40
  223. package/src/types/cache-types.ts +6 -3
  224. package/src/types/error-types.ts +30 -3
  225. package/src/types/handler-context.ts +12 -1
  226. package/src/types/segments.ts +1 -1
  227. package/src/vite/discovery/build-discovery-phase.ts +204 -0
  228. package/src/vite/discovery/build-env.ts +148 -0
  229. package/src/vite/discovery/bundle-scan-phase.ts +128 -0
  230. package/src/vite/discovery/dev-prerender-cache.ts +8 -3
  231. package/src/vite/discovery/dev-server-phase.ts +1776 -0
  232. package/src/vite/discovery/discover-routers.ts +1 -1
  233. package/src/vite/discovery/discovery-errors.ts +1 -1
  234. package/src/vite/discovery/prerender-collection.ts +24 -39
  235. package/src/vite/discovery/state.ts +15 -0
  236. package/src/vite/plugins/version-plugin.ts +1 -1
  237. package/src/vite/plugins/virtual-entries.ts +1 -1
  238. package/src/vite/router-discovery.ts +29 -2428
  239. package/src/vite/temp-rsc-server.ts +223 -0
  240. package/src/vite/utils/prerender-utils.ts +47 -0
  241. package/src/wire-params.ts +40 -0
  242. package/dist/types/router/segment-resolution/fresh.d.ts +0 -72
  243. package/src/router/segment-resolution/fresh.ts +0 -945
@@ -10,8 +10,8 @@ import {
10
10
  getRequestContext,
11
11
  setRequestContextParams,
12
12
  runWithRequestContext,
13
- wireRenderBarrier,
14
13
  } from "../server/request-context.js";
14
+ import { deriveRenderPass } from "../server/render-pass.js";
15
15
  import {
16
16
  SeededShellStore,
17
17
  ShellRecordUnavailableError,
@@ -58,11 +58,11 @@ import {
58
58
  capShellWindow,
59
59
  lastStoredCaptureSeq,
60
60
  markCaptureBackoff,
61
- scheduleShellCapture,
61
+ planShellCapture,
62
62
  resolveShellCaptureDebugSink,
63
63
  takeCaptureDebugEventForTiming,
64
64
  describeShellCaptureEvent,
65
- type ShellCaptureDescriptor,
65
+ type ShellCaptureInput,
66
66
  } from "./shell-capture.js";
67
67
  import {
68
68
  PPR_REPLAY_STATUS_HEADER,
@@ -87,8 +87,10 @@ import {
87
87
  describeShellTailTiming,
88
88
  publishShellTailTiming,
89
89
  takeShellTailTimingForServerTiming,
90
+ readShell,
90
91
  type ResolvedPprConfig,
91
92
  type ShellDocument,
93
+ type ShellRead,
92
94
  type ShellTailTiming,
93
95
  } from "./shell-serve.js";
94
96
  import {
@@ -99,7 +101,8 @@ import {
99
101
  import { contextGet } from "../context-var.js";
100
102
  import { rewriteShellAudience, rewriteTrail } from "../server/rewrite-trail.js";
101
103
  import { visitorShellKey } from "./shell-capture-constants.js";
102
- import { PREFETCH_HEADER, STALE_PARAM } from "../prefetch-markers.js";
104
+ import { PREFETCH_HEADER } from "../prefetch-markers.js";
105
+ import { WIRE } from "../wire-params.js";
103
106
  import {
104
107
  isWarmReplace,
105
108
  noteWarmShellEvent,
@@ -130,9 +133,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
130
133
  import type {
131
134
  SegmentCacheStore,
132
135
  ShellCacheEntry,
133
- ShellDocumentRead,
134
136
  ShellEntryHead,
135
- ShellReadStats,
136
137
  ShellSnapshotRecord,
137
138
  } from "../cache/types.js";
138
139
  import {
@@ -202,33 +203,41 @@ function requestShellBase(
202
203
  };
203
204
  }
204
205
 
205
- function createShellCaptureDescriptor(
206
- ctx: HandlerContext<any>,
206
+ /** The shell capture of `key` this request schedules. */
207
+ function createShellCapture(
208
+ input: RequestRenderInput<any>,
207
209
  key: string,
208
210
  base: RequestShellBase,
209
- originSeed: string,
210
211
  pprConfig: ResolvedPprConfig,
211
- store: SegmentCacheStore<any>,
212
+ ssr: ShellCaptureInput["ssr"],
212
213
  navigationOnly?: true,
213
- ): ShellCaptureDescriptor {
214
+ ): ShellCaptureInput {
215
+ const { ctx, request, env, url, reqCtx } = input;
214
216
  return {
215
- key,
216
- searchSeed: base.searchSeed,
217
- pathname: base.pathname,
218
- // Derived from the same request URL as the key (the callers compute
219
- // both next to each other, so they cannot drift): seeding the origin
220
- // keeps origin-dependent static markup (Link's data-external)
221
- // identical across capture, resume, and browser hydration.
222
- originSeed,
223
- buildVersion: ctx.version,
224
- ttl: pprConfig.ttl,
225
- swr: pprConfig.swr,
226
- tags: pprConfig.tags,
227
- captureTimeout: pprConfig.captureTimeout,
228
- store,
229
- maxSnapshotBytes: pprConfig.maxSnapshotBytes,
230
- debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
231
- navigationOnly,
217
+ ctx,
218
+ request,
219
+ env,
220
+ url,
221
+ reqCtx,
222
+ ssr,
223
+ descriptor: {
224
+ key,
225
+ searchSeed: base.searchSeed,
226
+ pathname: base.pathname,
227
+ // Derived from the same request URL as the key: seeding the origin
228
+ // keeps origin-dependent static markup (Link's data-external)
229
+ // identical across capture, resume, and browser hydration.
230
+ originSeed: url.origin,
231
+ buildVersion: ctx.version,
232
+ ttl: pprConfig.ttl,
233
+ swr: pprConfig.swr,
234
+ tags: pprConfig.tags,
235
+ captureTimeout: pprConfig.captureTimeout,
236
+ store: reqCtx._cacheStore,
237
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
238
+ debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
239
+ navigationOnly,
240
+ },
232
241
  };
233
242
  }
234
243
 
@@ -343,7 +352,7 @@ interface RequestRenderInput<TEnv> {
343
352
 
344
353
  type ShellServeOutcome =
345
354
  | { kind: "serve"; response: Response }
346
- | { kind: "miss"; descriptor: ShellCaptureDescriptor; ssrModule: SSRModule }
355
+ | { kind: "miss"; capture: ShellCaptureInput }
347
356
  | { kind: "pass" };
348
357
 
349
358
  type PreparedRender =
@@ -454,18 +463,8 @@ function* requestRenderPlan<TEnv>(
454
463
  response.status === 200 &&
455
464
  (response.headers.get("content-type") ?? "").includes("text/html")
456
465
  ) {
457
- const { descriptor, ssrModule } = shell;
458
- yield* handoff("shell-capture", () =>
459
- scheduleShellCapture(
460
- input.ctx,
461
- input.request,
462
- input.env,
463
- input.url,
464
- reqCtx,
465
- ssrModule,
466
- descriptor,
467
- ),
468
- );
466
+ const { capture } = shell;
467
+ yield* handoff("shell-capture", () => planShellCapture(capture));
469
468
  }
470
469
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
471
470
  }
@@ -520,8 +519,7 @@ function warnIfPprWindowReduced(
520
519
  function* shellServePlan<TEnv>(
521
520
  input: RequestRenderInput<TEnv>,
522
521
  ): RoutinePlan<ShellServeOutcome> {
523
- const { ctx, request, env, url, isPartial, handleStore, nonce, reqCtx } =
524
- input;
522
+ const { ctx, request, env, url, isPartial, nonce, reqCtx } = input;
525
523
  const manifestEntry = reqCtx._classifiedRoute?.manifestEntry;
526
524
 
527
525
  // A router.prerender() warm (prerender/warm-request.ts) reports the shell of
@@ -627,7 +625,10 @@ function* shellServePlan<TEnv>(
627
625
  const cached = warmReplace
628
626
  ? null
629
627
  : yield* step("shell-read", () =>
630
- readShellEntry(store, key, reqCtx, pprConfig.tags),
628
+ readShell(store, key, {
629
+ tagHints: pprConfig.tags,
630
+ metrics: reqCtx._metricsStore,
631
+ }),
631
632
  );
632
633
 
633
634
  // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
@@ -643,14 +644,16 @@ function* shellServePlan<TEnv>(
643
644
  ) {
644
645
  return { kind: "pass" };
645
646
  }
646
- const descriptor = createShellCaptureDescriptor(
647
- ctx,
647
+ // A MISS's capture after the response, or a HIT's recapture (stale, or a
648
+ // tail that could not finish).
649
+ const capture = createShellCapture(
650
+ input,
648
651
  key,
649
652
  base,
650
- url.origin,
651
653
  pprConfig,
652
- store,
654
+ () => ssrModule,
653
655
  );
656
+ const { descriptor } = capture;
654
657
  if (warm) {
655
658
  // The warm learns its capture's outcome from the capture's own events,
656
659
  // ahead of the router's debugShellCapture sink.
@@ -660,24 +663,27 @@ function* shellServePlan<TEnv>(
660
663
  routerSink?.(event);
661
664
  };
662
665
  }
666
+ // Neither the stored shell nor the build shell is read: a warm renders.
667
+ if (warmReplace) {
668
+ return {
669
+ kind: "miss",
670
+ capture: { ...capture, descriptor: { ...descriptor, force: true } },
671
+ };
672
+ }
663
673
  // MISS (no entry, invalid reactVersion, a tombstone, or store read
664
674
  // failure): axis 1 + a background capture scheduled once the response is
665
675
  // known servable.
666
676
  const miss: ShellServeOutcome = {
667
677
  kind: "miss",
668
- descriptor: { ...descriptor, storedSeqAtRead },
669
- ssrModule,
678
+ capture: { ...capture, descriptor: { ...descriptor, storedSeqAtRead } },
670
679
  };
671
- // Neither the stored shell nor the build shell is read: a warm renders.
672
- if (warmReplace) {
673
- return { ...miss, descriptor: { ...descriptor, force: true } };
674
- }
675
680
 
676
681
  // A HIT replays the handler layer from the entry's doc record (`docKey`),
677
682
  // or, for a Prerender route, from the prerender store. An entry that has
678
683
  // neither is a MISS: serving it would run handlers after the commit.
679
684
  // The entry must have rendered this request's location: the browser
680
685
  // hydrates against the address bar.
686
+ let hit: { document: ShellDocument; recapture: boolean } | undefined;
681
687
  if (
682
688
  cached &&
683
689
  !cached.entry.navigationOnly &&
@@ -700,42 +706,15 @@ function* shellServePlan<TEnv>(
700
706
  "[ShellServe] getShell",
701
707
  );
702
708
  } else {
703
- // An onlyIfStale warm found a servable shell; a recapture scheduled
704
- // below overwrites this through the capture's events.
705
- if (warm) warm.shell = "fresh";
706
709
  // Stale (SWR) hit: serve the stale shell now, recapture in the
707
- // background (stampede-guarded + backoff inside scheduleShellCapture),
710
+ // background (stampede-guarded + backoff inside planShellCapture),
708
711
  // at most once per SHELL_MIN_RECAPTURE_INTERVAL_MS of the shell's age.
709
- if (
710
- cached.shouldRevalidate &&
711
- staleShellRecaptureDue(cached.entry, Date.now())
712
- ) {
713
- yield* handoff("shell-recapture", () =>
714
- scheduleShellCapture(
715
- ctx,
716
- request,
717
- env,
718
- url,
719
- reqCtx,
720
- ssrModule,
721
- descriptor,
722
- ),
723
- );
724
- }
725
- const response = yield* step("shell-hit", () =>
726
- serveShellHit(
727
- ctx,
728
- request,
729
- env,
730
- url,
731
- reqCtx,
732
- handleStore,
733
- ssrModule,
734
- document,
735
- descriptor,
736
- ),
737
- );
738
- return { kind: "serve", response };
712
+ hit = {
713
+ document,
714
+ recapture:
715
+ !!cached.shouldRevalidate &&
716
+ staleShellRecaptureDue(cached.entry, Date.now()),
717
+ };
739
718
  }
740
719
  }
741
720
 
@@ -764,78 +743,59 @@ function* shellServePlan<TEnv>(
764
743
  // server's /__rsc_shell endpoint for PRERENDERED routes only (production's
765
744
  // exact candidate set). Folded away in production builds (NODE_ENV is a
766
745
  // compile-time constant).
767
- const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
768
- let buildHit: Awaited<ReturnType<typeof lookupBuildShell>> = null;
769
- if (base.pathname !== undefined) {
746
+ if (!hit && base.pathname === undefined) {
770
747
  // A build shell is the target's: never served under a visitor key.
771
- } else if (key !== baseKey) {
772
- // Probed once per path without a build shell, and not again once the
773
- // route warned. A search-bearing URL never reads one: nothing to probe.
774
- const routeName = reqCtx._classifiedRoute?.routeKey;
775
- if (
776
- !partitionBuildShellCheckDone(ctx.router.id, url.pathname, routeName) &&
777
- shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
778
- ) {
779
- const found = yield* step("build-shell-check", () =>
780
- hasBuildShell(ctx.router.id, url.pathname, devShellLookup),
781
- );
782
- notePartitionBuildShellCheck(
783
- ctx.router.id,
784
- url.pathname,
785
- routeName,
786
- found,
787
- );
788
- }
789
- } else {
790
- buildHit = yield* step("build-shell-lookup", () =>
791
- lookupBuildShell(
792
- ctx.router.id,
793
- url,
794
- ctx.version,
795
- store,
796
- devShellLookup,
797
- reqCtx._searchParamsFilter,
798
- ),
799
- );
800
- }
801
- // The document serve decodes the baked prelude once here; an undecodable
802
- // one is a MISS like a corrupt runtime entry.
803
- const buildDocument = buildHit
804
- ? openShellDocumentMetered(reqCtx, { entry: buildHit.entry })
805
- : null;
806
- if (buildHit && buildDocument) {
807
- if (warm) warm.shell = "fresh";
808
- // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
809
- if (buildHit.stale) {
810
- yield* handoff("shell-recapture", () =>
811
- scheduleShellCapture(
812
- ctx,
813
- request,
814
- env,
748
+ const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
749
+ if (key !== baseKey) {
750
+ // Probed once per path without a build shell, and not again once the
751
+ // route warned. A search-bearing URL never reads one: nothing to probe.
752
+ const routeName = reqCtx._classifiedRoute?.routeKey;
753
+ if (
754
+ !partitionBuildShellCheckDone(ctx.router.id, url.pathname, routeName) &&
755
+ shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
756
+ ) {
757
+ const found = yield* step("build-shell-check", () =>
758
+ hasBuildShell(ctx.router.id, url.pathname, devShellLookup),
759
+ );
760
+ notePartitionBuildShellCheck(
761
+ ctx.router.id,
762
+ url.pathname,
763
+ routeName,
764
+ found,
765
+ );
766
+ }
767
+ } else {
768
+ const built = yield* step("build-shell-lookup", () =>
769
+ lookupBuildShell(
770
+ ctx.router.id,
815
771
  url,
816
- reqCtx,
817
- ssrModule,
818
- descriptor,
772
+ ctx.version,
773
+ store,
774
+ devShellLookup,
775
+ reqCtx._searchParamsFilter,
819
776
  ),
820
777
  );
778
+ // The document serve decodes the baked prelude once here; an
779
+ // undecodable one is a MISS like a corrupt runtime entry.
780
+ const document =
781
+ built && openShellDocumentMetered(reqCtx, { entry: built.entry });
782
+ // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
783
+ if (document) hit = { document, recapture: built.stale };
821
784
  }
822
- const response = yield* step("shell-hit", () =>
823
- serveShellHit(
824
- ctx,
825
- request,
826
- env,
827
- url,
828
- reqCtx,
829
- handleStore,
830
- ssrModule,
831
- buildDocument,
832
- descriptor,
833
- ),
834
- );
835
- return { kind: "serve", response };
836
785
  }
786
+ if (!hit) return miss;
837
787
 
838
- return miss;
788
+ // An onlyIfStale warm found a servable shell; a recapture scheduled below
789
+ // overwrites this through the capture's events.
790
+ if (warm) warm.shell = "fresh";
791
+ if (hit.recapture) {
792
+ yield* handoff("shell-recapture", () => planShellCapture(capture));
793
+ }
794
+ const { document } = hit;
795
+ const response = yield* step("shell-hit", () =>
796
+ serveShellHit(input, ssrModule, document, capture),
797
+ );
798
+ return { kind: "serve", response };
839
799
  }
840
800
 
841
801
  /**
@@ -979,117 +939,13 @@ async function matchPartialAndRecordParams<TEnv>(
979
939
  return replay;
980
940
  }
981
941
 
982
- /**
983
- * A document serve's store read: a getShell entry, or a prelude-first read
984
- * (readShellDocument) whose entry is the head and whose snapshot arrives on
985
- * its own promise.
986
- */
987
- type ShellStoreRead =
988
- | { entry: ShellCacheEntry; shouldRevalidate?: boolean; read?: undefined }
989
- | {
990
- entry: ShellEntryHead;
991
- shouldRevalidate?: boolean;
992
- read: ShellDocumentRead;
993
- };
994
-
995
- /**
996
- * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
997
- * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
998
- * version-mismatch lifecycle miss stays distinguishable from a store miss).
999
- * A store with a prelude-first read (readShellDocument) resolves before the
1000
- * snapshot is read, so the HIT commits without waiting for it.
1001
- */
1002
- async function readShellEntry(
1003
- store: SegmentCacheStore & {
1004
- getShell: NonNullable<SegmentCacheStore["getShell"]>;
1005
- },
1006
- key: string,
1007
- reqCtx: ReturnType<typeof getRequestContext>,
1008
- tagHints: readonly string[] | undefined,
1009
- ): Promise<ShellStoreRead | null> {
1010
- let cached: ShellStoreRead | null = null;
1011
- const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
1012
- try {
1013
- if (store.readShellDocument) {
1014
- const read = await store.readShellDocument(
1015
- key,
1016
- tagHints ? { tagHints } : undefined,
1017
- );
1018
- cached = read
1019
- ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate, read }
1020
- : null;
1021
- } else {
1022
- cached = await store.getShell(key);
1023
- }
1024
- } catch (error) {
1025
- reportCacheError(error, "cache-read", "[ShellServe] getShell");
1026
- }
1027
- if (reqCtx._metricsStore) {
1028
- const stats = cached?.read?.stats;
1029
- appendMetric(
1030
- reqCtx._metricsStore,
1031
- "ppr:shell-read",
1032
- shellReadStart,
1033
- performance.now() - shellReadStart,
1034
- undefined,
1035
- cached ? (stats ? `hit ${stats.tier}` : "hit") : "miss",
1036
- );
1037
- if (stats)
1038
- recordShellReadStats(reqCtx._metricsStore, shellReadStart, stats);
1039
- }
1040
- return cached;
1041
- }
1042
-
1043
- /**
1044
- * A prelude-first read's sub-steps as `debugPerformance` rows under
1045
- * `ppr:shell-read`. The marker row starts with the prelude row because the
1046
- * two run in parallel; its desc says how long the read still waited on the
1047
- * marker once the prelude was in. On a deployed worker the clock only
1048
- * advances on I/O, so the byte counts are the cost signal for the CPU parts.
1049
- */
1050
- function recordShellReadStats(
1051
- store: MetricsStore,
1052
- readStart: number,
1053
- stats: ShellReadStats,
1054
- ): void {
1055
- let at = readStart;
1056
- const row = (label: string, ms: number | undefined, desc: string): void => {
1057
- if (ms !== undefined) appendMetric(store, label, at, ms, 1, desc);
1058
- };
1059
- if (stats.memo) {
1060
- row(
1061
- "ppr:shell-memo",
1062
- 0,
1063
- `${stats.memo.hit ? "hit" : "miss"} size=${stats.memo.bytes}b`,
1064
- );
1065
- }
1066
- // A KV hit after an L1 miss: the L1 attempt first, then the KV read.
1067
- row("ppr:shell-l1-miss", stats.l1MissMs, stats.l1MissReason ?? "miss");
1068
- at += stats.l1MissMs ?? 0;
1069
- row("ppr:shell-match", stats.matchMs, stats.tier);
1070
- at += stats.matchMs ?? 0;
1071
- row("ppr:shell-head", stats.headMs, `bytes=${stats.headBytes ?? 0}`);
1072
- at += stats.headMs ?? 0;
1073
- row("ppr:shell-prelude", stats.preludeMs, `bytes=${stats.preludeBytes ?? 0}`);
1074
- let marker = `tags=${stats.tags ?? 0} ${stats.markerSerial ? "serial" : "parallel"} commit-wait=${(stats.markerWaitMs ?? 0).toFixed(2)}ms`;
1075
- if (stats.markerMemo) marker += ` memo=${stats.markerMemo}`;
1076
- if (stats.markerHinted) {
1077
- marker += ` hint=${stats.markerHintHits ?? 0}/${stats.markerHinted.length}`;
1078
- if (stats.markerLeadMs !== undefined) {
1079
- marker += ` lead=${stats.markerLeadMs.toFixed(2)}ms`;
1080
- }
1081
- }
1082
- if (stats.freshReads) marker += " fresh-reads";
1083
- row("ppr:shell-marker", stats.markerMs, marker);
1084
- }
1085
-
1086
942
  /**
1087
943
  * openShellDocument (the HIT's integrity gate and only prelude decode) as a
1088
944
  * `ppr:shell-open` row: CPU only, so it reads 0 on a deployed worker.
1089
945
  */
1090
946
  function openShellDocumentMetered(
1091
947
  reqCtx: RequestContext<any>,
1092
- stored: ShellStoreRead,
948
+ stored: ShellRead,
1093
949
  ): ShellDocument | null {
1094
950
  const read = stored.read;
1095
951
  const open = (): ShellDocument | null =>
@@ -1275,7 +1131,7 @@ function renderPreparedRscResponse<TEnv>(
1275
1131
  reqCtx._payloadDeferred ||
1276
1132
  (isPartial &&
1277
1133
  (reqCtx._requestKind === "fill" ||
1278
- url.searchParams.get(STALE_PARAM) === "true"))
1134
+ url.searchParams.get(WIRE.stale) === "true"))
1279
1135
  ) {
1280
1136
  rscHeaders["cache-control"] = NOT_REUSED;
1281
1137
  }
@@ -1336,36 +1192,22 @@ function scheduleNavigationShellCapture<TEnv>(
1336
1192
  input: RequestRenderInput<TEnv>,
1337
1193
  captureKey: string,
1338
1194
  ): void {
1339
- const { ctx, request, env, url, reqCtx } = input;
1195
+ const { ctx, env, url, reqCtx } = input;
1340
1196
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
1341
- const store = reqCtx._cacheStore!;
1342
1197
  const base = requestShellBase(ctx, reqCtx, url);
1343
1198
  if (!base) return;
1344
- scheduleShellCapture(
1345
- ctx,
1346
- request,
1347
- env,
1348
- url,
1349
- reqCtx,
1350
- async (captureRequest, captureUrl) => {
1351
- const [ssrModule, streamMode] = await getSSRSetup(
1352
- ctx,
1353
- captureRequest,
1354
- env,
1355
- captureUrl,
1356
- undefined,
1357
- );
1358
- return streamMode === "allReady" ? null : ssrModule;
1359
- },
1360
- createShellCaptureDescriptor(
1199
+ const ssr = async (captureRequest: Request, captureUrl: URL) => {
1200
+ const [ssrModule, streamMode] = await getSSRSetup(
1361
1201
  ctx,
1362
- captureKey,
1363
- base,
1364
- url.origin,
1365
- pprConfig,
1366
- store,
1367
- true,
1368
- ),
1202
+ captureRequest,
1203
+ env,
1204
+ captureUrl,
1205
+ undefined,
1206
+ );
1207
+ return streamMode === "allReady" ? null : ssrModule;
1208
+ };
1209
+ planShellCapture(
1210
+ createShellCapture(input, captureKey, base, pprConfig, ssr, true),
1369
1211
  );
1370
1212
  }
1371
1213
 
@@ -1465,7 +1307,7 @@ async function matchPartialWithPprReplay<TEnv>(
1465
1307
  }
1466
1308
  // A stale partial renders live, as an action does (rendersLive,
1467
1309
  // router/match-context.ts): the record may predate the action's write.
1468
- if (url.searchParams.get(STALE_PARAM) === "true") {
1310
+ if (url.searchParams.get(WIRE.stale) === "true") {
1469
1311
  return runMatch({ outcome: "BYPASS", reason: "stale" });
1470
1312
  }
1471
1313
  // Fragment passthrough (#700), extended to partial replay. Navigation
@@ -1494,7 +1336,7 @@ async function matchPartialWithPprReplay<TEnv>(
1494
1336
  //
1495
1337
  // The probe is a PRE-match fast path, not the truth: it reads only the
1496
1338
  // non-intercept artifact, and whether the navigation IS an intercept (and
1497
- // therefore whether tryPrerenderLookup reads `paramHash` or
1339
+ // therefore whether readPrerenderSource reads `paramHash` or
1498
1340
  // `paramHash + "/i"`) is resolved during the match by match-api's
1499
1341
  // findInterceptForRoute — an intercept-source header proves nothing in
1500
1342
  // either direction. runMatch reclassifies every BYPASS from the stamped
@@ -1824,10 +1666,10 @@ async function isSettled(value: unknown): Promise<boolean> {
1824
1666
  * leaves the entry alone.
1825
1667
  */
1826
1668
  async function degradeUnreplayableShell(
1827
- descriptor: ShellCaptureDescriptor,
1669
+ capture: ShellCaptureInput,
1828
1670
  entry: ShellEntryHead,
1829
- scheduleRecapture: () => void,
1830
1671
  ): Promise<void> {
1672
+ const { descriptor } = capture;
1831
1673
  const store = descriptor.store;
1832
1674
  try {
1833
1675
  await store?.putShell?.(
@@ -1846,7 +1688,7 @@ async function degradeUnreplayableShell(
1846
1688
  reportCacheError(error, "cache-write", "[ShellServe] tombstone");
1847
1689
  }
1848
1690
  store?.dropShellMemo?.(descriptor.key);
1849
- scheduleRecapture();
1691
+ planShellCapture(capture);
1850
1692
  }
1851
1693
 
1852
1694
  /**
@@ -1859,16 +1701,13 @@ async function degradeUnreplayableShell(
1859
1701
  * frame; the adapter may pull the stream outside it.
1860
1702
  */
1861
1703
  function serveShellHit(
1862
- ctx: HandlerContext<any>,
1863
- request: Request,
1864
- env: any,
1865
- url: URL,
1866
- reqCtx: RequestContext<any>,
1867
- handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1704
+ input: RequestRenderInput<any>,
1868
1705
  ssrModule: SSRModule,
1869
1706
  document: ShellDocument,
1870
- descriptor: ShellCaptureDescriptor,
1707
+ capture: ShellCaptureInput,
1871
1708
  ): Response {
1709
+ const { ctx, request, env, url, reqCtx, handleStore } = input;
1710
+ const { descriptor } = capture;
1872
1711
  const { entry, prelude: preludeBytes } = document;
1873
1712
  // A visitor-keyed shell shows the visitor's URL: the document cache keys
1874
1713
  // this response by the target.
@@ -1887,8 +1726,6 @@ function serveShellHit(
1887
1726
  }
1888
1727
  : null;
1889
1728
  const tailT0 = tailTiming ? performance.now() : 0;
1890
- const scheduleRecapture = (): void =>
1891
- scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1892
1729
  /** Report a broken entry, then tombstone and recapture it. Never throws. */
1893
1730
  const retireEntry = async (reason: string): Promise<void> => {
1894
1731
  reportCacheError(
@@ -1897,7 +1734,7 @@ function serveShellHit(
1897
1734
  "[ShellServe] retire",
1898
1735
  );
1899
1736
  try {
1900
- await degradeUnreplayableShell(descriptor, entry, scheduleRecapture);
1737
+ await degradeUnreplayableShell(capture, entry);
1901
1738
  } catch (error) {
1902
1739
  reportCacheError(error, "cache-write", "[ShellServe] retire");
1903
1740
  }
@@ -1905,26 +1742,6 @@ function serveShellHit(
1905
1742
  /** The first loader pin of the entry that failed to decode (dc-t89). */
1906
1743
  let undecodablePin: string | undefined;
1907
1744
 
1908
- const createTailContext = (): RequestContext<any> => {
1909
- const tailCtx: RequestContext<any> = Object.create(reqCtx);
1910
- // Matching writes render state onto the derived context. Its barrier must
1911
- // close over that same context or a streaming tail inherits the base
1912
- // context's premature non-streaming handle snapshot.
1913
- wireRenderBarrier(tailCtx, handleStore);
1914
- // The tail's render barrier resolves once its record is replayed and
1915
- // before a loader runs (cache-lookup.ts withCacheLookup, yieldFromStore).
1916
- // The store then holds what the prelude was rendered from, so that is
1917
- // the snapshot the document hydrates with; what this request's loaders
1918
- // do to it afterwards reaches the client after hydration
1919
- // (HandleStore.freezeDocumentSnapshot, issue #1035).
1920
- const resolveRenderBarrier = tailCtx._resolveRenderBarrier;
1921
- tailCtx._resolveRenderBarrier = (segments) => {
1922
- handleStore.freezeDocumentSnapshot();
1923
- resolveRenderBarrier(segments);
1924
- };
1925
- return tailCtx;
1926
- };
1927
-
1928
1745
  const renderTail = async (
1929
1746
  activeCtx: RequestContext<any>,
1930
1747
  ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
@@ -2036,7 +1853,6 @@ function serveShellHit(
2036
1853
  tailTiming.snapshotParseMs = Math.round(snapshotStats.parseMs);
2037
1854
  }
2038
1855
  }
2039
- const seededCtx = createTailContext();
2040
1856
  const records = snapshot ?? [];
2041
1857
  // Loader-family records (bake-lane containers, loader-container-bake):
2042
1858
  // decode into a seed Map for the resolveLoaderData overlay, so the
@@ -2060,32 +1876,31 @@ function serveShellHit(
2060
1876
  `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
2061
1877
  );
2062
1878
  }
2063
- if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
2064
- // The handler layer is REPLAYED from the entry's own doc record, never
2065
- // executed: the implicit doc scope replaces any route-derived scope
2066
- // (docTail), looks the record up by the key the capture wrote
2067
- // (fixedDocKey), and reads it from a segment-only overlay of the snapshot
2068
- // (the real store never holds a doc: record). A lookup that does not hit
2069
- // throws ShellRecordUnavailableError (withCacheLookup), handled by the
2070
- // degrade below. Live and promise-carrying bake-lane loaders still run
2071
- // fresh; a promise-free bake-lane loader is served from its pin
2072
- // (_shellLoaderSeed, loader-cache.ts). A Prerender route's tail takes
2073
- // the handler layer from the prerender store before this scope is
2074
- // consulted, and the loader pushes from `prerenderHandles`.
2075
- seededCtx._shellImplicitCache = {
2076
- ttl: descriptor.ttl,
2077
- swr: descriptor.swr,
2078
- store: new SeededShellStore(reqCtx._cacheStore!, records),
2079
- keyPrefix: "doc",
2080
- docTail: true,
2081
- fixedDocKey: entry.docKey,
2082
- prerenderHandles: shellPrerenderHandles(records),
2083
- };
2084
- // Fragment splice (issue #700): store hits in THIS tail emit their stored
2085
- // segment fragments verbatim (expanded by segment-fragments.ts). The flag
2086
- // lives on the derived context so it can never leak into a capture render,
2087
- // which serializes segments and must see real elements.
2088
- seededCtx._shellFragmentPayload = true;
1879
+ // What the tail owns, and why: RENDER_PASS_FIELDS["ppr-tail"].
1880
+ const seededCtx = deriveRenderPass(reqCtx, "ppr-tail", {
1881
+ handleStore,
1882
+ loaderSeed,
1883
+ // The handler layer is REPLAYED from the entry's own doc record, never
1884
+ // executed: the implicit doc scope replaces any route-derived scope
1885
+ // (docTail), looks the record up by the key the capture wrote
1886
+ // (fixedDocKey), and reads it from a segment-only overlay of the
1887
+ // snapshot (the real store never holds a doc: record). A lookup that
1888
+ // does not hit throws ShellRecordUnavailableError (withCacheLookup),
1889
+ // handled by the degrade below. Live and promise-carrying bake-lane
1890
+ // loaders still run fresh; a promise-free bake-lane loader is served
1891
+ // from its pin (_shellLoaderSeed, loader-cache.ts). A Prerender route's
1892
+ // tail takes the handler layer from the prerender store before this
1893
+ // scope is consulted, and the loader pushes from `prerenderHandles`.
1894
+ implicitCache: {
1895
+ ttl: descriptor.ttl,
1896
+ swr: descriptor.swr,
1897
+ store: new SeededShellStore(reqCtx._cacheStore!, records),
1898
+ keyPrefix: "doc",
1899
+ docTail: true,
1900
+ fixedDocKey: entry.docKey,
1901
+ prerenderHandles: shellPrerenderHandles(records),
1902
+ },
1903
+ });
2089
1904
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
2090
1905
  })();
2091
1906
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
@@ -2230,8 +2045,8 @@ function serveShellHit(
2230
2045
  // here AFTER the 200 + prelude flushed and would re-fail on every
2231
2046
  // request until the entry ages out — recapture overwrites the entry.
2232
2047
  // Client disconnects land here too; the recapture is idempotent and
2233
- // bounded by scheduleShellCapture's stampede guard + backoff.
2234
- scheduleRecapture();
2048
+ // bounded by planShellCapture's stampede guard + backoff.
2049
+ planShellCapture(capture);
2235
2050
  if (tailTiming) {
2236
2051
  tailTiming.outcome = "error";
2237
2052
  tailTiming.completeMs = Math.round(performance.now() - tailT0);