@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.
- package/README.md +5 -1
- package/dist/types/browser/event-controller.d.ts +6 -0
- package/dist/types/browser/prefetch/cache.d.ts +31 -2
- package/dist/types/cache/cf/cf-cache-constants.d.ts +8 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +15 -1
- package/dist/types/cache/cf/cf-cache-types.d.ts +1 -1
- package/dist/types/cache/cf/cf-kv-utils.d.ts +21 -0
- package/dist/types/handles/is-thenable.d.ts +2 -4
- package/dist/types/route-definition/helpers-types.d.ts +5 -4
- package/dist/types/rsc/helpers.d.ts +3 -0
- package/dist/types/rsc/render-pipeline.d.ts +9 -0
- package/dist/types/rsc/routine-plan.d.ts +124 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +17 -0
- package/dist/types/server/request-context.d.ts +4 -1
- package/dist/types/static-handler.d.ts +15 -1
- package/dist/types/urls/path-helper-types.d.ts +6 -7
- package/dist/types/vite/encryption-key.d.ts +2 -0
- package/dist/types/vite/plugins/expose-internal-ids.d.ts +10 -0
- package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
- package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
- package/dist/types/vite/utils/shared-utils.d.ts +12 -0
- package/dist/vite/index.js +154 -57
- package/package.json +3 -3
- package/skills/caching/SKILL.md +1 -1
- package/skills/mime-routes/SKILL.md +3 -1
- package/skills/parallel/SKILL.md +1 -1
- package/skills/response-routes/SKILL.md +4 -2
- package/skills/typesafety/generated-files-and-cli.md +16 -9
- package/skills/typesafety/route-types.md +5 -1
- package/skills/use-cache/SKILL.md +47 -0
- package/src/browser/event-controller.ts +40 -15
- package/src/browser/partial-update.ts +26 -11
- package/src/browser/prefetch/cache.ts +53 -9
- package/src/browser/prefetch/fetch.ts +83 -2
- package/src/browser/react/NavigationProvider.tsx +7 -0
- package/src/cache/cache-runtime.ts +89 -15
- package/src/cache/cf/cf-cache-constants.ts +8 -1
- package/src/cache/cf/cf-cache-store.ts +41 -64
- package/src/cache/cf/cf-cache-types.ts +1 -1
- package/src/cache/cf/cf-kv-utils.ts +38 -0
- package/src/cache/segment-codec.ts +21 -5
- package/src/handles/is-thenable.ts +2 -4
- package/src/route-definition/helpers-types.ts +5 -3
- package/src/router/match-middleware/cache-lookup.ts +24 -15
- package/src/rsc/handler.ts +26 -5
- package/src/rsc/helpers.ts +13 -0
- package/src/rsc/progressive-enhancement.ts +247 -70
- package/src/rsc/render-pipeline.ts +68 -24
- package/src/rsc/routine-plan.ts +359 -0
- package/src/rsc/rsc-rendering.ts +555 -302
- package/src/rsc/server-action.ts +180 -71
- package/src/rsc/shell-capture-constants.ts +18 -0
- package/src/rsc/shell-capture.ts +92 -21
- package/src/server/request-context.ts +6 -0
- package/src/ssr/ssr-root.tsx +1 -0
- package/src/static-handler.ts +18 -2
- package/src/urls/path-helper-types.ts +9 -10
- package/src/vite/encryption-key.ts +29 -0
- package/src/vite/plugins/expose-action-id.ts +2 -2
- package/src/vite/plugins/expose-internal-ids.ts +46 -0
- package/src/vite/plugins/server-ref-hashing.ts +74 -0
- package/src/vite/plugins/server-reference-pattern.ts +10 -0
- package/src/vite/rango.ts +9 -0
- package/src/vite/router-discovery.ts +21 -2
- package/src/vite/utils/shared-utils.ts +12 -7
package/src/rsc/server-action.ts
CHANGED
|
@@ -29,11 +29,22 @@ import {
|
|
|
29
29
|
createSimpleRedirectResponse,
|
|
30
30
|
interceptRedirectForPartial,
|
|
31
31
|
attachLocationStateIfPresent,
|
|
32
|
+
matchAndRecordParams,
|
|
32
33
|
} from "./helpers.js";
|
|
33
|
-
import {
|
|
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.
|
|
387
|
-
// pre-deferral render that executeServerAction used to do inline.
|
|
440
|
+
// the success branch below.
|
|
388
441
|
if (errorBoundary) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 =
|
|
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 =
|
|
453
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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;
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -26,7 +26,10 @@ import {
|
|
|
26
26
|
captureQueueDepths,
|
|
27
27
|
enqueueSerializedCapture,
|
|
28
28
|
} from "./capture-queue.js";
|
|
29
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
193
|
-
* rolls. Living here (not split across the middleware) keeps
|
|
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
|
-
|
|
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
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/ssr/ssr-root.tsx
CHANGED
package/src/static-handler.ts
CHANGED
|
@@ -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 {
|
|
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).
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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
|
-
|
|
|
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;
|