@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
@@ -29,11 +29,22 @@ import {
29
29
  createSimpleRedirectResponse,
30
30
  interceptRedirectForPartial,
31
31
  attachLocationStateIfPresent,
32
+ matchAndRecordParams,
32
33
  } from "./helpers.js";
33
- import { renderRscResponse } from "./render-pipeline.js";
34
+ import {
35
+ createRenderStageTraceBridge,
36
+ renderRscResponse,
37
+ } from "./render-pipeline.js";
38
+ import {
39
+ createRoutineTrace,
40
+ runRoutine,
41
+ step,
42
+ type RoutinePlan,
43
+ } from "./routine-plan.js";
34
44
  import { warnNonRedirectActionResponse } from "./runtime-warnings.js";
35
45
  import type { HandlerContext } from "./handler-context.js";
36
46
  import type { MatchResult } from "../types.js";
47
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
37
48
 
38
49
  /**
39
50
  * Data flowing from action execution to the revalidation phase.
@@ -350,6 +361,17 @@ export function revalidateAfterAction<TEnv>(
350
361
  );
351
362
  }
352
363
 
364
+ interface ActionRevalidationInput<TEnv> {
365
+ ctx: HandlerContext<TEnv>;
366
+ request: Request;
367
+ env: TEnv;
368
+ url: URL;
369
+ handleStore: ReturnType<typeof getRequestContext>["_handleStore"];
370
+ continuation: ActionContinuation;
371
+ renderSpan: TraceSpan;
372
+ reqCtx: ReturnType<typeof getRequestContext>;
373
+ }
374
+
353
375
  async function revalidateAfterActionInner<TEnv>(
354
376
  ctx: HandlerContext<TEnv>,
355
377
  request: Request,
@@ -359,14 +381,8 @@ async function revalidateAfterActionInner<TEnv>(
359
381
  continuation: ActionContinuation,
360
382
  renderSpan: TraceSpan,
361
383
  ): Promise<Response> {
362
- const {
363
- returnValue,
364
- actionStatus,
365
- temporaryReferences,
366
- actionContext,
367
- errorBoundary,
368
- } = continuation;
369
384
  const reqCtx = getRequestContext();
385
+ const { actionContext } = continuation;
370
386
  // Expose the action that triggered this revalidation to the transition({ when })
371
387
  // gate (covers both the error-boundary and success gate calls below). Mirrors
372
388
  // the action fields a revalidate() predicate sees.
@@ -381,76 +397,63 @@ async function revalidateAfterActionInner<TEnv>(
381
397
  // in the background. See registerCachedFunction in cache/cache-runtime.ts.
382
398
  reqCtx._inActionRevalidation = true;
383
399
 
400
+ const trace = INTERNAL_RANGO_DEBUG
401
+ ? createRoutineTrace("action-revalidation")
402
+ : undefined;
403
+ const input: ActionRevalidationInput<TEnv> = {
404
+ ctx,
405
+ request,
406
+ env,
407
+ url,
408
+ handleStore,
409
+ continuation,
410
+ renderSpan,
411
+ reqCtx,
412
+ };
413
+ try {
414
+ return await runRoutine(actionRevalidationPlan(input), {
415
+ trace,
416
+ owner: reqCtx,
417
+ });
418
+ } finally {
419
+ if (trace) {
420
+ console.log(
421
+ `[routine] ${request.method} ${url.pathname} (${trace.name})\n${trace.format()}`,
422
+ );
423
+ }
424
+ }
425
+ }
426
+
427
+ /**
428
+ * The action-revalidation plan: render the deferred error boundary if the
429
+ * action threw and one matched, otherwise match affected segments (falling
430
+ * back to a full match for redirects) and render the partial payload.
431
+ */
432
+ function* actionRevalidationPlan<TEnv>(
433
+ input: ActionRevalidationInput<TEnv>,
434
+ ): RoutinePlan<Response> {
435
+ const { ctx, request, env, url, continuation } = input;
436
+ const { actionContext, errorBoundary } = continuation;
437
+
384
438
  // Action threw and a boundary matched: render the (already-matched) error
385
439
  // boundary here so it runs inside the route-middleware wrapper, exactly like
386
- // the success branch below. setRequestContextParams + the payload mirror the
387
- // pre-deferral render that executeServerAction used to do inline.
440
+ // the success branch below.
388
441
  if (errorBoundary) {
389
- setRequestContextParams(errorBoundary.params, errorBoundary.routeName);
390
-
391
- const errorPayload: RscPayload = {
392
- metadata: {
393
- pathname: url.pathname,
394
- // routerId exposed for the frontend (current app identity); see
395
- // rsc-rendering.ts partial branch.
396
- routerId: ctx.router.id,
397
- segments: gateTransitions(
398
- errorBoundary.segments,
399
- reqCtx,
400
- ctx.router.onError,
401
- ),
402
- isPartial: true,
403
- matched: errorBoundary.matched,
404
- diff: errorBoundary.diff,
405
- resolvedIds: errorBoundary.resolvedIds,
406
- params: errorBoundary.params,
407
- isError: true,
408
- handles: handleStore.stream(),
409
- version: ctx.version,
410
- },
411
- returnValue,
412
- };
413
-
414
- // Intentionally omit attachLocationState for error payloads: location state
415
- // is a success-only semantic. Error boundary responses update the error UI
416
- // but should not mutate browser history state.
417
-
418
- return renderRscResponse({
419
- ctx,
420
- request,
421
- env,
422
- url,
423
- payload: errorPayload,
424
- temporaryReferences,
425
- init: {
426
- status: actionStatus,
427
- headers: {
428
- "content-type": "text/x-component;charset=utf-8",
429
- // Router identity for the client's pre-decode integrity check (the
430
- // action apply path has no post-decode guard). See response-adapter.
431
- "X-RSC-Router-Id": ctx.router.id,
432
- },
433
- },
434
- tracking: {
435
- mode: "action-revalidation",
436
- routeKey: reqCtx._routeName,
437
- actionId: actionContext?.actionId,
438
- span: renderSpan,
439
- },
440
- });
442
+ return yield* step("render", () =>
443
+ renderActionBoundaryResponse(input, errorBoundary),
444
+ );
441
445
  }
442
446
 
443
- const matchResult = await ctx.router.matchPartial(
444
- request,
445
- { env },
446
- actionContext,
447
+ const matchResult = yield* step("match:partial", () =>
448
+ matchPartialAndRecord(ctx, request, env, actionContext),
447
449
  );
448
450
 
449
451
  if (!matchResult) {
450
452
  // matchPartial returns null when the route is a redirect or no previous-URL
451
453
  // context could be resolved. Check for redirect first.
452
- const fullMatch = await ctx.router.match(request, { env });
453
- setRequestContextParams(fullMatch.params, fullMatch.routeName);
454
+ const fullMatch = yield* step("match:fallback", () =>
455
+ matchAndRecordParams(ctx, request, env),
456
+ );
454
457
 
455
458
  if (fullMatch.redirect) {
456
459
  // Action context is always partial — use X-RSC-Redirect header so
@@ -476,8 +479,111 @@ async function revalidateAfterActionInner<TEnv>(
476
479
  );
477
480
  }
478
481
 
479
- // Return updated segments
480
- setRequestContextParams(matchResult.params, matchResult.routeName);
482
+ return yield* step("render", () =>
483
+ renderRevalidationResponse(input, matchResult),
484
+ );
485
+ }
486
+
487
+ /** matchPartial that stamps params/routeName when a result exists. */
488
+ async function matchPartialAndRecord<TEnv>(
489
+ ctx: HandlerContext<TEnv>,
490
+ request: Request,
491
+ env: TEnv,
492
+ actionContext: ActionContinuation["actionContext"],
493
+ ): Promise<
494
+ Awaited<ReturnType<HandlerContext<TEnv>["router"]["matchPartial"]>>
495
+ > {
496
+ const matchResult = await ctx.router.matchPartial(
497
+ request,
498
+ { env },
499
+ actionContext,
500
+ );
501
+ if (matchResult) {
502
+ setRequestContextParams(matchResult.params, matchResult.routeName);
503
+ }
504
+ return matchResult;
505
+ }
506
+
507
+ /**
508
+ * Render the deferred action error boundary. setRequestContextParams + the
509
+ * payload mirror the pre-deferral render that executeServerAction used to do
510
+ * inline.
511
+ */
512
+ function renderActionBoundaryResponse<TEnv>(
513
+ input: ActionRevalidationInput<TEnv>,
514
+ errorBoundary: MatchResult,
515
+ ): Promise<Response> {
516
+ const { ctx, request, env, url, handleStore, continuation, reqCtx } = input;
517
+ const { returnValue, actionStatus, temporaryReferences, actionContext } =
518
+ continuation;
519
+
520
+ setRequestContextParams(errorBoundary.params, errorBoundary.routeName);
521
+
522
+ const errorPayload: RscPayload = {
523
+ metadata: {
524
+ pathname: url.pathname,
525
+ // routerId exposed for the frontend (current app identity); see
526
+ // rsc-rendering.ts partial branch.
527
+ routerId: ctx.router.id,
528
+ segments: gateTransitions(
529
+ errorBoundary.segments,
530
+ reqCtx,
531
+ ctx.router.onError,
532
+ ),
533
+ isPartial: true,
534
+ matched: errorBoundary.matched,
535
+ diff: errorBoundary.diff,
536
+ resolvedIds: errorBoundary.resolvedIds,
537
+ params: errorBoundary.params,
538
+ isError: true,
539
+ handles: handleStore.stream(),
540
+ version: ctx.version,
541
+ },
542
+ returnValue,
543
+ };
544
+
545
+ // Intentionally omit attachLocationState for error payloads: location state
546
+ // is a success-only semantic. Error boundary responses update the error UI
547
+ // but should not mutate browser history state.
548
+
549
+ return renderRscResponse({
550
+ ctx,
551
+ request,
552
+ env,
553
+ url,
554
+ payload: errorPayload,
555
+ temporaryReferences,
556
+ init: {
557
+ status: actionStatus,
558
+ headers: {
559
+ "content-type": "text/x-component;charset=utf-8",
560
+ // Router identity for the client's pre-decode integrity check (the
561
+ // action apply path has no post-decode guard). See response-adapter.
562
+ "X-RSC-Router-Id": ctx.router.id,
563
+ },
564
+ },
565
+ tracking: {
566
+ mode: "action-revalidation",
567
+ routeKey: input.reqCtx._routeName,
568
+ actionId: actionContext?.actionId,
569
+ span: input.renderSpan,
570
+ onEvent:
571
+ reqCtx._activeRoutine &&
572
+ createRenderStageTraceBridge(reqCtx._activeRoutine),
573
+ },
574
+ });
575
+ }
576
+
577
+ /** Render the updated segments after a successful action. */
578
+ function renderRevalidationResponse<TEnv>(
579
+ input: ActionRevalidationInput<TEnv>,
580
+ matchResult: NonNullable<
581
+ Awaited<ReturnType<HandlerContext<TEnv>["router"]["matchPartial"]>>
582
+ >,
583
+ ): Promise<Response> {
584
+ const { ctx, request, env, url, handleStore, continuation, reqCtx } = input;
585
+ const { returnValue, actionStatus, temporaryReferences, actionContext } =
586
+ continuation;
481
587
 
482
588
  const payload: RscPayload = {
483
589
  metadata: {
@@ -524,7 +630,10 @@ async function revalidateAfterActionInner<TEnv>(
524
630
  mode: "action-revalidation",
525
631
  routeKey: reqCtx._routeName,
526
632
  actionId: actionContext?.actionId,
527
- span: renderSpan,
633
+ span: input.renderSpan,
634
+ onEvent:
635
+ reqCtx._activeRoutine &&
636
+ createRenderStageTraceBridge(reqCtx._activeRoutine),
528
637
  },
529
638
  });
530
639
  }
@@ -25,3 +25,21 @@
25
25
  * docs/design/ppr-shell-resume.md (Cost model).
26
26
  */
27
27
  export const SHELL_CAPTURE_MAX_WAIT_MS = 15_000;
28
+
29
+ /**
30
+ * Hard cap on one capture TASK — runShellCapture end to end (both attempts +
31
+ * the in-place retry delay). SHELL_CAPTURE_MAX_WAIT_MS arms only inside
32
+ * captureShellHTML, AFTER the capture's router.match(); a handler wedged on a
33
+ * never-settling upstream await (autobarn pilot: a 30s+ tarpitting fetch)
34
+ * wedges the task with no deadline in force. The task's settle path releases
35
+ * the per-key stampede guard and the serialized capture-queue slot, so an
36
+ * unbounded task strands BOTH for the isolate's lifetime.
37
+ *
38
+ * 25s: above one full-budget attempt plus overhead (cutting a second attempt
39
+ * short is an acceptable loss — every terminal path backs the key off anyway),
40
+ * and below workerd's ~30s waitUntil grace so the cap timer still fires in a
41
+ * live context. When workerd kills the context BEFORE the cap fires, the timer
42
+ * dies with it — that stranding is healed by scheduleShellCapture's staleness
43
+ * check on the stampede guard, keyed off the same constant.
44
+ */
45
+ export const SHELL_CAPTURE_TASK_HARD_CAP_MS = 25_000;
@@ -26,7 +26,10 @@ import {
26
26
  captureQueueDepths,
27
27
  enqueueSerializedCapture,
28
28
  } from "./capture-queue.js";
29
- import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
29
+ import {
30
+ SHELL_CAPTURE_MAX_WAIT_MS,
31
+ SHELL_CAPTURE_TASK_HARD_CAP_MS,
32
+ } from "./shell-capture-constants.js";
30
33
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
31
34
  import { observePhase, PHASES } from "../router/instrument.js";
32
35
  import type { TraceSpan } from "../router/tracing.js";
@@ -185,15 +188,64 @@ function delay(ms: number): Promise<void> {
185
188
  }
186
189
 
187
190
  /**
188
- * Module-level in-flight key set: the stampede guard for background captures, and
191
+ * Bound one capture task at {@link SHELL_CAPTURE_TASK_HARD_CAP_MS}. Rejects on
192
+ * expiry so the caller's existing catch applies backoff + reporting and its
193
+ * settle path releases the stampede guard and queue slot; resolves/rejects
194
+ * transparently otherwise. The timer is unref'd so a pending cap never holds a
195
+ * Node dev process open.
196
+ */
197
+ function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
198
+ return new Promise<T>((resolve, reject) => {
199
+ const timer = setTimeout(() => {
200
+ reject(
201
+ new Error(
202
+ `capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
203
+ `a handler is likely wedged on a never-settling await`,
204
+ ),
205
+ );
206
+ }, SHELL_CAPTURE_TASK_HARD_CAP_MS);
207
+ (timer as { unref?: () => void }).unref?.();
208
+ task.then(
209
+ (value) => {
210
+ clearTimeout(timer);
211
+ resolve(value);
212
+ },
213
+ (error: unknown) => {
214
+ clearTimeout(timer);
215
+ reject(error);
216
+ },
217
+ );
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Module-level in-flight key map: the stampede guard for background captures, and
189
223
  * its single owner. One capture runs per key per isolate; concurrent MISS/stale
190
224
  * requests for the same key coalesce onto the first (the rest see the key present
191
225
  * in scheduleShellCapture and skip). Added when a capture is scheduled and cleared
192
- * in the task's finally once it settles, so a later request can recapture when TTL
193
- * rolls. Living here (not split across the middleware) keeps the add/clear
194
- * lifecycle in one layer.
226
+ * in the task's settle paths via a token-guarded release, so a later request can
227
+ * recapture when TTL rolls. Living here (not split across the middleware) keeps
228
+ * the add/clear lifecycle in one layer.
229
+ *
230
+ * The value is the owning task's token ({@link CaptureGuardToken}) rather than
231
+ * a bare set entry: workerd can kill a capture's waitUntil context before its
232
+ * finally runs (or the task can wedge past every deadline), stranding the key.
233
+ * scheduleShellCapture treats an entry older than
234
+ * SHELL_CAPTURE_TASK_HARD_CAP_MS as abandoned and reclaims it; the token guard
235
+ * keeps a late-settling stale task from releasing its replacement's entry.
195
236
  */
196
- const inFlightCaptures = new Set<string>();
237
+ interface CaptureGuardToken {
238
+ startedAt: number;
239
+ }
240
+
241
+ const inFlightCaptures = new Map<string, CaptureGuardToken>();
242
+
243
+ /** Token-guarded release: only the entry's owning task may clear it. */
244
+ function releaseCaptureGuard(key: string, token: CaptureGuardToken): void {
245
+ if (inFlightCaptures.get(key) === token) {
246
+ inFlightCaptures.delete(key);
247
+ }
248
+ }
197
249
 
198
250
  /**
199
251
  * Refused-capture backoff bounds. The window is EXPONENTIAL in the consecutive
@@ -918,9 +970,17 @@ export function scheduleShellCapture(
918
970
  descriptor: ShellCaptureDescriptor,
919
971
  ): void {
920
972
  const key = descriptor.key;
921
- if (inFlightCaptures.has(key)) {
922
- publishCaptureDebugEvent(descriptor, { key, outcome: "skip-in-flight" });
923
- return;
973
+ const inFlight = inFlightCaptures.get(key);
974
+ if (inFlight) {
975
+ if (Date.now() - inFlight.startedAt <= SHELL_CAPTURE_TASK_HARD_CAP_MS) {
976
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-in-flight" });
977
+ return;
978
+ }
979
+ // Older than the task hard cap: the owning task never settled (wedged
980
+ // render, or workerd killed its context before the release ran). Treat the
981
+ // key as abandoned and schedule a replacement — the set() below installs a
982
+ // new token, and the stale task's token-guarded release can no longer
983
+ // touch it.
924
984
  }
925
985
  // Refused/failed within the window → skip the doomed re-render (one probe per
926
986
  // key per window per isolate). Expired entries self-evict inside the check.
@@ -942,7 +1002,8 @@ export function scheduleShellCapture(
942
1002
  publishCaptureDebugEvent(descriptor, { key, outcome: "skip-inert-store" });
943
1003
  return;
944
1004
  }
945
- inFlightCaptures.add(key);
1005
+ const guardToken: CaptureGuardToken = { startedAt: Date.now() };
1006
+ inFlightCaptures.set(key, guardToken);
946
1007
  const captureTask = async (span: TraceSpan) => {
947
1008
  try {
948
1009
  const setupUrl = descriptor.navigationOnly
@@ -962,14 +1023,24 @@ export function scheduleShellCapture(
962
1023
  ) {
963
1024
  return;
964
1025
  }
965
- const outcome = await runShellCapture(
966
- ctx,
967
- request,
968
- env,
969
- url,
970
- reqCtx,
971
- resolvedSsrModule,
972
- descriptor,
1026
+ // Hard-capped: SHELL_CAPTURE_MAX_WAIT_MS arms only inside
1027
+ // captureShellHTML, AFTER the capture's router.match() — a handler
1028
+ // wedged on a never-settling upstream await has no deadline in force
1029
+ // and would strand the stampede guard and the queue slot (autobarn
1030
+ // pilot). The cap rejects, riding the existing catch: backoff +
1031
+ // reportCacheError + token-guarded release. The abandoned attempt keeps
1032
+ // running until its context dies; nothing awaits it.
1033
+ const outcome = await raceTaskHardCap(
1034
+ runShellCapture(
1035
+ ctx,
1036
+ request,
1037
+ env,
1038
+ url,
1039
+ reqCtx,
1040
+ resolvedSsrModule,
1041
+ descriptor,
1042
+ ),
1043
+ key,
973
1044
  );
974
1045
  span.setAttribute("rango.background.outcome", outcome);
975
1046
  // Update the negative cache off the terminal outcome. A stored shell clears
@@ -998,7 +1069,7 @@ export function scheduleShellCapture(
998
1069
  });
999
1070
  reportCacheError(error, "cache-write", "[ShellCache] capture", reqCtx);
1000
1071
  } finally {
1001
- inFlightCaptures.delete(key);
1072
+ releaseCaptureGuard(key, guardToken);
1002
1073
  }
1003
1074
  };
1004
1075
  // Serialize capture EXECUTION per isolate (capture-queue.ts): concurrent
@@ -1049,7 +1120,7 @@ export function scheduleShellCapture(
1049
1120
  );
1050
1121
  } catch (error) {
1051
1122
  if (error instanceof CaptureQueueFullError) {
1052
- inFlightCaptures.delete(key);
1123
+ releaseCaptureGuard(key, guardToken);
1053
1124
  span.setAttribute("rango.background.outcome", "skip-capacity");
1054
1125
  publishCaptureDebugEvent(descriptor, {
1055
1126
  key,
@@ -1061,7 +1132,7 @@ export function scheduleShellCapture(
1061
1132
  // Dropped unrun after waiting past the queue budget: no backoff (the
1062
1133
  // route is not doomed, the isolate was busy) — a later request
1063
1134
  // re-probes the key.
1064
- inFlightCaptures.delete(key);
1135
+ releaseCaptureGuard(key, guardToken);
1065
1136
  span.setAttribute("rango.background.outcome", "skip-queue-timeout");
1066
1137
  span.setAttribute(
1067
1138
  "rango.background.queue_wait_ms",
@@ -57,6 +57,7 @@ import {
57
57
  warnInvalidTheme,
58
58
  } from "../theme/constants.js";
59
59
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
60
+ import type { RoutineTrace } from "../rsc/routine-plan.js";
60
61
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
61
62
  import {
62
63
  assertCachedHeaderWriteAllowed,
@@ -663,6 +664,9 @@ export interface RequestContext<
663
664
  */
664
665
  _renderForeground?: RenderForegroundCursor;
665
666
 
667
+ /** @internal Request routine currently executing, used by timeout diagnostics. */
668
+ _activeRoutine?: RoutineTrace;
669
+
666
670
  /** @internal Keep the render cursor active for timeout support. */
667
671
  _renderDiagnosticsEnabled?: boolean;
668
672
 
@@ -767,6 +771,7 @@ export type PublicRequestContext<
767
771
  | "_debugPerformance"
768
772
  | "_metricsStore"
769
773
  | "_renderForeground"
774
+ | "_activeRoutine"
770
775
  | "_renderDiagnosticsEnabled"
771
776
  | "_handlerStart"
772
777
  | "_tracing"
@@ -1323,6 +1328,7 @@ export function createRequestContext<TEnv>(
1323
1328
  _reportedErrors: new WeakSet<object>(),
1324
1329
  _metricsStore: undefined,
1325
1330
  _renderForeground: undefined,
1331
+ _activeRoutine: undefined,
1326
1332
  _renderDiagnosticsEnabled: undefined,
1327
1333
  _handlerStart: undefined,
1328
1334
 
@@ -97,6 +97,7 @@ function createSsrEventController(opts: {
97
97
  }),
98
98
  subscribeToAction: () => () => {},
99
99
  subscribeToHandles: () => () => {},
100
+ flushRouteState: () => {},
100
101
  setHandleData: () => {},
101
102
  getHandleState: () => handleState,
102
103
  setRouteSegmentIds: () => {},
@@ -46,9 +46,24 @@ export interface StaticHandlerOptions {
46
46
  passthrough?: boolean;
47
47
  }
48
48
 
49
+ declare const STATIC_HANDLER_REF: unique symbol;
50
+
51
+ /**
52
+ * Opaque identity used by DSL type seams that accept Static() values.
53
+ *
54
+ * The brand is required, never optional: an optional brand makes this a weak
55
+ * type, and weak-type checking lets `{}` through — `parallel({ "@x": {} })`
56
+ * would typecheck and then die at render as an invalid React child. The
57
+ * symbol never exists at runtime; Static() casts its return instead.
58
+ */
59
+ export interface StaticHandlerRef {
60
+ /** @internal Type-only brand; see interface doc. */
61
+ readonly [STATIC_HANDLER_REF]: true;
62
+ }
63
+
49
64
  export interface StaticHandlerDefinition<
50
65
  TParams extends Record<string, any> = any,
51
- > {
66
+ > extends StaticHandlerRef {
52
67
  readonly __brand: "staticHandler";
53
68
  /** Auto-generated unique ID (injected by Vite plugin). */
54
69
  $$id: string;
@@ -103,12 +118,13 @@ export function Static<TParams extends Record<string, any>>(
103
118
  id = `__rango_runtime_static_${runtimeStaticIdCounter++}`;
104
119
  }
105
120
 
121
+ // Cast: STATIC_HANDLER_REF is a type-only brand with no runtime field.
106
122
  return {
107
123
  __brand: "staticHandler" as const,
108
124
  $$id: id,
109
125
  handler: handler as Handler<TParams>,
110
126
  ...(options ? { options } : {}),
111
- };
127
+ } as StaticHandlerDefinition<TParams>;
112
128
  }
113
129
 
114
130
  export function isStaticHandler(
@@ -40,7 +40,10 @@ import type {
40
40
  PrerenderHandlerDefinition,
41
41
  PassthroughHandlerDefinition,
42
42
  } from "../prerender.js";
43
- import type { StaticHandlerDefinition } from "../static-handler.js";
43
+ import type {
44
+ StaticHandlerDefinition,
45
+ StaticHandlerRef,
46
+ } from "../static-handler.js";
44
47
  import type {
45
48
  ResponseHandler,
46
49
  ResponseHandlerContext,
@@ -271,22 +274,18 @@ export type PathHelpers<TEnv> = {
271
274
  *
272
275
  * Not generic over the slots record: an inferred type parameter makes the
273
276
  * object literal an inference site, which suppresses contextual typing of
274
- * arrow slot handlers (`(ctx) => ...` was implicit any). Bare handlers infer
275
- * now; a descriptor's `handler:` arrow still needs an explicit ctx annotation
276
- * because StaticHandlerDefinition's own `.handler` joins the contextual union
277
- * (two callables — see parallel-slot-handler-types.test.ts).
277
+ * arrow slot handlers (`(ctx) => ...` was implicit any). Static() values use
278
+ * an opaque handler-less reference here so their own `.handler` cannot
279
+ * contribute a second call signature or expose internal fields in completion.
278
280
  */
279
281
  parallel: (
280
282
  slots: Record<
281
283
  `@${string}`,
282
284
  | Handler<any, any, TEnv>
283
285
  | ReactNode
284
- | StaticHandlerDefinition
286
+ | StaticHandlerRef
285
287
  | {
286
- handler:
287
- | Handler<any, any, TEnv>
288
- | ReactNode
289
- | StaticHandlerDefinition;
288
+ handler: Handler<any, any, TEnv> | ReactNode | StaticHandlerRef;
290
289
  use?: () => ParallelUseItem[];
291
290
  }
292
291
  >,
@@ -0,0 +1,29 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ // plugin-rsc encrypts inline-action bound args with a key read from
4
+ // `virtual:vite-rsc/encryption-key`, which defaults to a fresh random key PER
5
+ // plugin instance. The build-discovery temp server (which renders Static/
6
+ // Prerender output) is a separate plugin instance from the main build, so by
7
+ // default it encrypts prerendered bound args with a key the runtime never has --
8
+ // `decryptActionBoundArgs` then fails on invocation. We hand both the SAME key
9
+ // via plugin-rsc's `defineEncryptionKey` so build-time-encrypted bound args
10
+ // decrypt at runtime.
11
+ //
12
+ // Cached per process so the temp server (created during the main build's
13
+ // buildStart) and the main build share one key. Overridable via
14
+ // RANGO_ENCRYPTION_KEY for a stable key across builds/deploys; otherwise a fresh
15
+ // key is generated per build (matching plugin-rsc's default posture, where the
16
+ // key is emitted into the build output regardless).
17
+ let cachedKey: string | undefined;
18
+
19
+ export function buildEncryptionKey(): string {
20
+ cachedKey ??=
21
+ process.env.RANGO_ENCRYPTION_KEY ?? randomBytes(32).toString("base64");
22
+ return cachedKey;
23
+ }
24
+
25
+ // The value plugin-rsc inlines as `export default () => (<expr>)`. A JSON string
26
+ // literal, so the emitted runtime key module is a plain base64 string.
27
+ export function defineEncryptionKeyExpr(): string {
28
+ return JSON.stringify(buildEncryptionKey());
29
+ }
@@ -3,6 +3,7 @@ import MagicString from "magic-string";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs";
5
5
  import { normalizePath } from "./expose-id-utils.js";
6
+ import { registerServerReferenceRegex } from "./server-reference-pattern.js";
6
7
  import { createRangoDebugger, createCounter, NS } from "../debug.js";
7
8
 
8
9
  const debug = createRangoDebugger(NS.transform);
@@ -133,8 +134,7 @@ function applyRegisterReferenceWrapping(
133
134
  return false;
134
135
  }
135
136
 
136
- const pattern =
137
- /registerServerReference\(([^,]+),\s*"([^"]+)",\s*"([^"]+)"\)/g;
137
+ const pattern = registerServerReferenceRegex();
138
138
 
139
139
  let hasChanges = false;
140
140
  let match: RegExpExecArray | null;