@rangojs/router 0.17.0 → 0.18.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.
- package/README.md +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Runs in the RSC realm of the build's temp server, AFTER all bundles are
|
|
5
5
|
* written (the prelude embeds built client asset URLs — bootstrap module,
|
|
6
6
|
* chunk preloads — that only exist post-client-build). The capture core is
|
|
7
|
-
* producer A's, verbatim: deriveShellCaptureContext (
|
|
8
|
-
*
|
|
9
|
-
* quiesce, tags union, putShell barrier). Build capture first replays global
|
|
7
|
+
* producer A's, verbatim: deriveShellCaptureContext (push funnel, snapshot
|
|
8
|
+
* recording, implicit doc-cache scope) + settleCaptureRecord (the record-first
|
|
9
|
+
* step) + captureAndStoreShell (gates, quiesce, tags union, putShell barrier). Build capture first replays global
|
|
10
10
|
* and route middleware with a synthetic build request context
|
|
11
11
|
* (`ctx.build === true`, inert `ctx.waitUntil()`); middleware can seed vars or
|
|
12
12
|
* call `ctx.dynamic()` to skip this URL. The sink is an entry collector instead
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { ShellCacheEntry } from "../cache/types.js";
|
|
24
|
+
import type { MatchResult } from "../types.js";
|
|
24
25
|
import { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
|
|
25
26
|
import {
|
|
26
27
|
createRequestContext,
|
|
@@ -31,7 +32,10 @@ import {
|
|
|
31
32
|
import {
|
|
32
33
|
deriveShellCaptureContext,
|
|
33
34
|
captureAndStoreShell,
|
|
35
|
+
settleCaptureRecord,
|
|
36
|
+
withinDeadline,
|
|
34
37
|
delay,
|
|
38
|
+
SHELL_CAPTURE_MAX_WAIT_MS,
|
|
35
39
|
SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
36
40
|
type ShellCaptureDescriptor,
|
|
37
41
|
} from "../rsc/shell-capture.js";
|
|
@@ -134,18 +138,25 @@ export interface BuildShellCaptureResult {
|
|
|
134
138
|
tags?: string[];
|
|
135
139
|
/** On route-mismatch: what this router's match actually landed on. */
|
|
136
140
|
matchedRouteName?: string;
|
|
141
|
+
/**
|
|
142
|
+
* On no-shell: the attempt ran out of ppr.captureTimeout. It is not
|
|
143
|
+
* retried: its match may still be running, and the retry would start the
|
|
144
|
+
* same work beside it (runtime twin: shell-capture.ts runShellCapture).
|
|
145
|
+
*/
|
|
146
|
+
timedOut?: true;
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
150
|
* Capture the PPR shell for one prerendered URL at build time. Retries once
|
|
141
151
|
* in place on `no-shell` (the first attempt warms the temp server's SSR/Flight
|
|
142
|
-
* transform graph, mirroring producer A's cold-start retry — same delay)
|
|
152
|
+
* transform graph, mirroring producer A's cold-start retry — same delay),
|
|
153
|
+
* unless the attempt ran out of ppr.captureTimeout (`timedOut`).
|
|
143
154
|
*/
|
|
144
155
|
export async function captureShellForBuild(
|
|
145
156
|
opts: BuildShellCaptureOptions,
|
|
146
157
|
): Promise<BuildShellCaptureResult> {
|
|
147
158
|
const first = await attemptBuildCapture(opts);
|
|
148
|
-
if (first.outcome !== "no-shell") return first;
|
|
159
|
+
if (first.outcome !== "no-shell" || first.timedOut) return first;
|
|
149
160
|
if (opts.debug) {
|
|
150
161
|
console.log(
|
|
151
162
|
`[rango] shell capture attempt 1/2 for ${opts.urlPath} produced no shell (cold graph?) — retrying`,
|
|
@@ -215,6 +226,7 @@ async function attemptBuildCapture(
|
|
|
215
226
|
};
|
|
216
227
|
|
|
217
228
|
let mismatchedRouteName: string | undefined;
|
|
229
|
+
let timedOut = false;
|
|
218
230
|
const result = await runWithRequestContext(baseCtx, async () => {
|
|
219
231
|
const preview =
|
|
220
232
|
typeof router.previewMatch === "function"
|
|
@@ -252,6 +264,9 @@ async function attemptBuildCapture(
|
|
|
252
264
|
setMismatchedRouteName: (routeName) => {
|
|
253
265
|
mismatchedRouteName = routeName;
|
|
254
266
|
},
|
|
267
|
+
markTimedOut: () => {
|
|
268
|
+
timedOut = true;
|
|
269
|
+
},
|
|
255
270
|
});
|
|
256
271
|
|
|
257
272
|
const routeMiddleware =
|
|
@@ -291,6 +306,7 @@ async function attemptBuildCapture(
|
|
|
291
306
|
if (outcome === "route-mismatch") {
|
|
292
307
|
return { outcome, matchedRouteName: mismatchedRouteName };
|
|
293
308
|
}
|
|
309
|
+
if (outcome === "no-shell" && timedOut) return { outcome, timedOut: true };
|
|
294
310
|
return { outcome };
|
|
295
311
|
}
|
|
296
312
|
|
|
@@ -310,6 +326,8 @@ interface BuildCaptureFinalOptions {
|
|
|
310
326
|
router: any;
|
|
311
327
|
url: URL;
|
|
312
328
|
setMismatchedRouteName(routeName: string | undefined): void;
|
|
329
|
+
/** The attempt ran out of ppr.captureTimeout (BuildShellCaptureResult.timedOut). */
|
|
330
|
+
markTimedOut(): void;
|
|
313
331
|
}
|
|
314
332
|
|
|
315
333
|
async function runBuildMiddlewareEnvelope<TEnv>(
|
|
@@ -369,15 +387,28 @@ async function runBuildCaptureFinal(
|
|
|
369
387
|
// invoking this when baseCtx._dynamic is set. A loader/handler opting out
|
|
370
388
|
// DURING the capture render is caught by the derivedCtx._dynamic check below.
|
|
371
389
|
|
|
372
|
-
const
|
|
390
|
+
const derivation = deriveShellCaptureContext(baseCtx, {
|
|
373
391
|
ttl: opts.ttl,
|
|
374
392
|
swr: opts.swr,
|
|
375
393
|
});
|
|
394
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
376
395
|
|
|
377
396
|
const outcome = await runWithRequestContext(derivedCtx, async () => {
|
|
378
|
-
|
|
397
|
+
// Same one deadline as the runtime capture: the match, the record-first
|
|
398
|
+
// step, then the prerender with what is left.
|
|
399
|
+
const deadline =
|
|
400
|
+
Date.now() + (opts.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
401
|
+
let match: MatchResult;
|
|
379
402
|
try {
|
|
380
|
-
|
|
403
|
+
const matched = await withinDeadline<MatchResult>(
|
|
404
|
+
router.match(request, { env }),
|
|
405
|
+
deadline,
|
|
406
|
+
);
|
|
407
|
+
if (!matched.done) {
|
|
408
|
+
options.markTimedOut();
|
|
409
|
+
return "no-shell" as const;
|
|
410
|
+
}
|
|
411
|
+
match = matched.value;
|
|
381
412
|
} catch (error) {
|
|
382
413
|
if (isPlainPathMiss(error, opts.urlPath)) {
|
|
383
414
|
return "route-mismatch" as const;
|
|
@@ -392,8 +423,25 @@ async function runBuildCaptureFinal(
|
|
|
392
423
|
|
|
393
424
|
setRequestContextParams(match.params, match.routeName);
|
|
394
425
|
|
|
395
|
-
|
|
426
|
+
// Same record-first step as the runtime capture. A prerendered URL's
|
|
427
|
+
// match comes from the prerender store (no handler runs, no doc record),
|
|
428
|
+
// which settleCaptureRecord reports as `prerender`.
|
|
429
|
+
const settled = await settleCaptureRecord(
|
|
396
430
|
match,
|
|
431
|
+
derivation,
|
|
432
|
+
descriptor,
|
|
433
|
+
deadline,
|
|
434
|
+
);
|
|
435
|
+
if (settled.kind === "refused") {
|
|
436
|
+
return derivedCtx._dynamic ? ("dynamic" as const) : ("refused" as const);
|
|
437
|
+
}
|
|
438
|
+
if (settled.kind === "timeout") {
|
|
439
|
+
options.markTimedOut();
|
|
440
|
+
return "no-shell" as const;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const payload = buildFullPayload(
|
|
444
|
+
settled.match,
|
|
397
445
|
// buildFullPayload reads only ctx.router.* and ctx.version.
|
|
398
446
|
{ router, version: opts.buildVersion } as unknown as HandlerContext<any>,
|
|
399
447
|
url,
|
|
@@ -416,9 +464,15 @@ async function runBuildCaptureFinal(
|
|
|
416
464
|
rscStream,
|
|
417
465
|
freshHandleStore,
|
|
418
466
|
derivedCtx,
|
|
419
|
-
descriptor,
|
|
467
|
+
{ ...descriptor, captureTimeout: Math.max(1, deadline - Date.now()) },
|
|
420
468
|
);
|
|
421
|
-
|
|
469
|
+
if (captureOutcome === "no-shell" && Date.now() >= deadline) {
|
|
470
|
+
options.markTimedOut();
|
|
471
|
+
}
|
|
472
|
+
if (derivedCtx._dynamic) return "dynamic";
|
|
473
|
+
// A route cache() record that ran out mid-capture: the in-place retry
|
|
474
|
+
// (captureShellForBuild) reads or renders a newer one.
|
|
475
|
+
return captureOutcome === "expired" ? "no-shell" : captureOutcome;
|
|
422
476
|
});
|
|
423
477
|
|
|
424
478
|
return {
|
|
@@ -909,12 +909,13 @@ const loader: RouteHelpers<any, any>["loader"] = (
|
|
|
909
909
|
// resolved here, at DSL-evaluation time, for the same reason
|
|
910
910
|
// loading({ ssr: false }) is (below) — per-isSSR entry caching makes the
|
|
911
911
|
// flag request-mode-correct with no isSSR threading; see LoaderEntry.
|
|
912
|
+
// `bake` is the lane, the same on both evaluations.
|
|
912
913
|
const loaderEntry: LoaderEntry = {
|
|
913
914
|
loader: loaderDef,
|
|
914
915
|
revalidate: [] as ShouldRevalidateFn<any, any>[],
|
|
915
|
-
...(optionsGiven?.ssr === false &&
|
|
916
|
-
|
|
917
|
-
:
|
|
916
|
+
...(optionsGiven?.ssr === false && { bake: true as const }),
|
|
917
|
+
...(optionsGiven?.ssr === false &&
|
|
918
|
+
ctx.isSSR && { awaitBeforeFlush: true as const }),
|
|
918
919
|
};
|
|
919
920
|
|
|
920
921
|
// Merge handler.use defaults (attached to the loader definition) with explicit use
|
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { HandlerContext, InternalHandlerContext } from "../types";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
_getRequestContext,
|
|
10
|
+
type RequestContext,
|
|
11
|
+
} from "../server/request-context.js";
|
|
9
12
|
import { getSearchSchema, isRouteRootScoped } from "../route-map-builder.js";
|
|
10
13
|
import { parseSearchParams, serializeSearchParams } from "../search-params.js";
|
|
11
14
|
import {
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
assertCachedHeaderWriteAllowed,
|
|
19
22
|
assertNonCacheableReadAllowed,
|
|
20
23
|
} from "../server/context.js";
|
|
24
|
+
import { assertThemeReadAllowed } from "../server/cookie-store.js";
|
|
21
25
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
22
26
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
23
27
|
import { PRERENDER_PASSTHROUGH } from "../prerender.js";
|
|
@@ -29,6 +33,34 @@ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
|
29
33
|
// once at module load instead of per createHandlerContext (per-request) call.
|
|
30
34
|
const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
|
|
31
35
|
|
|
36
|
+
/** The request context a handler ctx's `theme` getter reads (THEME_DESCRIPTOR). */
|
|
37
|
+
const THEME_REQUEST_CONTEXT = Symbol("rango:theme-request-context");
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ctx.theme is the visitor's theme cookie, guarded like cookies() (#971,
|
|
41
|
+
* assertThemeReadAllowed): a getter, so creating the ctx reads nothing and
|
|
42
|
+
* only a handler that reads the theme refuses a ppr capture or throws on a
|
|
43
|
+
* cache() miss. Non-enumerable, so serializing the ctx does not read it:
|
|
44
|
+
* React's dev Flight debug info JSON-stringifies server component props and
|
|
45
|
+
* replayed console.log args, and an enumerable getter read there refused the
|
|
46
|
+
* capture of a route that never read the theme. The trade: a spread or
|
|
47
|
+
* Object.assign copy of the ctx does not carry `theme`. One shared getter
|
|
48
|
+
* with the request context on a symbol key: a getter closure per ctx gives
|
|
49
|
+
* every ctx its own property shape (V8 dictionary mode). The guard reads the
|
|
50
|
+
* ambient context, as cookies() does, and falls back to the ctx's own when
|
|
51
|
+
* there is none.
|
|
52
|
+
*/
|
|
53
|
+
const THEME_DESCRIPTOR: PropertyDescriptor = {
|
|
54
|
+
get(this: { [THEME_REQUEST_CONTEXT]?: RequestContext }) {
|
|
55
|
+
const requestContext = this[THEME_REQUEST_CONTEXT];
|
|
56
|
+
if (!requestContext?._themeConfig) return undefined;
|
|
57
|
+
assertThemeReadAllowed(_getRequestContext() ?? requestContext, "ctx.theme");
|
|
58
|
+
return requestContext._readTheme();
|
|
59
|
+
},
|
|
60
|
+
enumerable: false,
|
|
61
|
+
configurable: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
32
64
|
/**
|
|
33
65
|
* `headers` whose mutating methods run `assertWrite(method)` first. Shared by
|
|
34
66
|
* the handler ctx (createHandlerContext) and the middleware ctx
|
|
@@ -279,7 +311,7 @@ export function createHandlerContext<TEnv>(
|
|
|
279
311
|
// Read-time guard: non-cacheable var inside "use cache" or cache() →
|
|
280
312
|
// throw. Works for both ContextVar tokens and string keys.
|
|
281
313
|
if (isNonCacheable(variables, keyOrVar)) {
|
|
282
|
-
assertNonCacheableReadAllowed(keyOrVar);
|
|
314
|
+
assertNonCacheableReadAllowed(keyOrVar, _getRequestContext());
|
|
283
315
|
}
|
|
284
316
|
return contextGet(variables, keyOrVar);
|
|
285
317
|
}) as HandlerContext<any, TEnv>["get"],
|
|
@@ -295,8 +327,7 @@ export function createHandlerContext<TEnv>(
|
|
|
295
327
|
use: () => {
|
|
296
328
|
throw new Error("ctx.use() called before loaders were initialized");
|
|
297
329
|
},
|
|
298
|
-
//
|
|
299
|
-
theme: requestContext?.theme,
|
|
330
|
+
// theme: THEME_DESCRIPTOR, defined after this literal.
|
|
300
331
|
setTheme: requestContext?.setTheme,
|
|
301
332
|
// Location state support (delegates to request context)
|
|
302
333
|
setLocationState(entries) {
|
|
@@ -325,6 +356,9 @@ export function createHandlerContext<TEnv>(
|
|
|
325
356
|
_responseType: responseType,
|
|
326
357
|
_routeName: routeName,
|
|
327
358
|
};
|
|
359
|
+
(ctx as { [THEME_REQUEST_CONTEXT]?: unknown })[THEME_REQUEST_CONTEXT] =
|
|
360
|
+
requestContext;
|
|
361
|
+
Object.defineProperty(ctx, "theme", THEME_DESCRIPTOR);
|
|
328
362
|
// Brand with taint symbol so "use cache" excludes ctx from cache keys
|
|
329
363
|
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
330
364
|
return ctx;
|
package/src/router/instrument.ts
CHANGED
|
@@ -28,7 +28,14 @@
|
|
|
28
28
|
* is a direct call — no wrapper, no timestamp, no allocation.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
_getRequestContext,
|
|
33
|
+
type RequestContext,
|
|
34
|
+
} from "../server/request-context.js";
|
|
35
|
+
import {
|
|
36
|
+
recordTagOwnersArmed,
|
|
37
|
+
runInSegmentTagScope,
|
|
38
|
+
} from "../cache/cache-tag.js";
|
|
32
39
|
import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
33
40
|
import { getRouterContext } from "./router-context.js";
|
|
34
41
|
import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
|
|
@@ -352,11 +359,56 @@ export function observeHandler<C, R>(
|
|
|
352
359
|
handler: (ctx: C) => R,
|
|
353
360
|
ctx: C,
|
|
354
361
|
): R {
|
|
355
|
-
|
|
362
|
+
return observeHandlerIn(_getRequestContext(), id, handler, ctx);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function observeHandlerIn<C, R>(
|
|
366
|
+
reqCtx: RequestContext | undefined,
|
|
367
|
+
id: string,
|
|
368
|
+
handler: (ctx: C) => R,
|
|
369
|
+
ctx: C,
|
|
370
|
+
): R {
|
|
371
|
+
const tracing = reqCtx?._tracing;
|
|
356
372
|
if (!tracing || tracing.phases.handler === false) return handler(ctx);
|
|
357
373
|
return observePhase(PHASES.handler(id), () => handler(ctx));
|
|
358
374
|
}
|
|
359
375
|
|
|
376
|
+
/**
|
|
377
|
+
* observeHandler, inside segment `segmentId`'s tag scope, so the tags the
|
|
378
|
+
* handler records reach the route's cache() record (#957, cache-tag.ts
|
|
379
|
+
* runInSegmentTagScope). `spanId` is observeHandler's `id`. A request that
|
|
380
|
+
* cannot write a record takes observeHandler's path as is, with the same one
|
|
381
|
+
* request-context read: no scope, no closure.
|
|
382
|
+
*/
|
|
383
|
+
export function observeSegmentHandler<C, R>(
|
|
384
|
+
segmentId: string,
|
|
385
|
+
spanId: string,
|
|
386
|
+
handler: (ctx: C) => R,
|
|
387
|
+
ctx: C,
|
|
388
|
+
): R {
|
|
389
|
+
const reqCtx = _getRequestContext();
|
|
390
|
+
if (!recordTagOwnersArmed(reqCtx)) {
|
|
391
|
+
return observeHandlerIn(reqCtx, spanId, handler, ctx);
|
|
392
|
+
}
|
|
393
|
+
return observeInSegmentScope(segmentId, reqCtx, spanId, handler, ctx);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Separate from observeSegmentHandler: a closure there would make V8
|
|
397
|
+
// allocate its captured-variable context on every call, armed or not.
|
|
398
|
+
function observeInSegmentScope<C, R>(
|
|
399
|
+
segmentId: string,
|
|
400
|
+
reqCtx: RequestContext | undefined,
|
|
401
|
+
spanId: string,
|
|
402
|
+
handler: (ctx: C) => R,
|
|
403
|
+
ctx: C,
|
|
404
|
+
): R {
|
|
405
|
+
return runInSegmentTagScope(
|
|
406
|
+
segmentId,
|
|
407
|
+
(c) => observeHandlerIn(reqCtx, spanId, handler, c),
|
|
408
|
+
ctx,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
360
412
|
/**
|
|
361
413
|
* Emit one discrete telemetry event (the event-shaped counterpart to
|
|
362
414
|
* observePhase). Resolves the sink from the active router context and stamps the
|
|
@@ -32,6 +32,7 @@ import { resolveLoaderData } from "./segment-resolution/loader-cache.js";
|
|
|
32
32
|
import type { SegmentResolutionDeps } from "./types.js";
|
|
33
33
|
import { debugLog } from "./logging.js";
|
|
34
34
|
import { getRouterContext } from "./router-context.js";
|
|
35
|
+
import { runInSegmentTagScope } from "../cache/cache-tag.js";
|
|
35
36
|
import {
|
|
36
37
|
RangoContext,
|
|
37
38
|
latchPprHeaderScopeForEntries,
|
|
@@ -332,12 +333,18 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
332
333
|
);
|
|
333
334
|
}
|
|
334
335
|
|
|
336
|
+
// The intercept segment's id, and the tag scope its handler and layout
|
|
337
|
+
// record in (#957, cache-tag.ts runInSegmentTagScope).
|
|
338
|
+
const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
|
|
335
339
|
let handlerResult: ReactNode;
|
|
336
340
|
try {
|
|
341
|
+
const handler = interceptEntry.handler;
|
|
337
342
|
handlerResult =
|
|
338
|
-
typeof
|
|
339
|
-
? handleHandlerResult(
|
|
340
|
-
|
|
343
|
+
typeof handler === "function"
|
|
344
|
+
? handleHandlerResult(
|
|
345
|
+
runInSegmentTagScope(slotSegmentId, handler, context),
|
|
346
|
+
)
|
|
347
|
+
: handler;
|
|
341
348
|
} catch (error) {
|
|
342
349
|
handlerResult = renderInterceptHandlerError(
|
|
343
350
|
error,
|
|
@@ -355,14 +362,19 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
355
362
|
|
|
356
363
|
let layoutElement: ReactNode | undefined;
|
|
357
364
|
if (interceptEntry.layout) {
|
|
358
|
-
|
|
359
|
-
|
|
365
|
+
const layout = interceptEntry.layout;
|
|
366
|
+
if (typeof layout === "function") {
|
|
367
|
+
const layoutResult = await runInSegmentTagScope(
|
|
368
|
+
slotSegmentId,
|
|
369
|
+
layout,
|
|
370
|
+
context,
|
|
371
|
+
);
|
|
360
372
|
if (layoutResult instanceof Response) {
|
|
361
373
|
throw layoutResult;
|
|
362
374
|
}
|
|
363
375
|
layoutElement = layoutResult;
|
|
364
376
|
} else {
|
|
365
|
-
layoutElement =
|
|
377
|
+
layoutElement = layout;
|
|
366
378
|
}
|
|
367
379
|
}
|
|
368
380
|
|
|
@@ -413,7 +425,7 @@ export async function resolveInterceptEntry<TEnv>(
|
|
|
413
425
|
}
|
|
414
426
|
|
|
415
427
|
const interceptSegment = {
|
|
416
|
-
id:
|
|
428
|
+
id: slotSegmentId,
|
|
417
429
|
namespace: `intercept:${interceptEntry.routeName}`,
|
|
418
430
|
type: "parallel" as const,
|
|
419
431
|
index: 0,
|
|
@@ -9,6 +9,14 @@ import type { EntryData } from "../server/context";
|
|
|
9
9
|
import { observePhase, PHASES } from "./instrument.js";
|
|
10
10
|
import { contextGet, isNonCacheable } from "../context-var.js";
|
|
11
11
|
import { NOCACHE_SYMBOL } from "../cache/taint.js";
|
|
12
|
+
import {
|
|
13
|
+
linkLoaderTags,
|
|
14
|
+
linkRecordedTags,
|
|
15
|
+
linkValueTags,
|
|
16
|
+
loaderTagSetsArmed,
|
|
17
|
+
readValueTags,
|
|
18
|
+
tagLoaderValue,
|
|
19
|
+
} from "../cache/cache-tag.js";
|
|
12
20
|
import type {
|
|
13
21
|
ResolvedSegment,
|
|
14
22
|
HandlerContext,
|
|
@@ -388,6 +396,12 @@ function createLoaderExecutor<TEnv>(
|
|
|
388
396
|
* payload's handle-stream lifetime.
|
|
389
397
|
*/
|
|
390
398
|
silentHandles?: boolean;
|
|
399
|
+
/**
|
|
400
|
+
* loaderId -> the cache() tags of that loader's binding in this request
|
|
401
|
+
* (setupLoaderAccess _bindLoaderCacheTags): an execution of it outside
|
|
402
|
+
* the binding answers for them too (#964).
|
|
403
|
+
*/
|
|
404
|
+
bindingTags?: ReadonlyMap<string, Set<string>>;
|
|
391
405
|
},
|
|
392
406
|
): (
|
|
393
407
|
loader: LoaderDefinition<any, any>,
|
|
@@ -438,8 +452,24 @@ function createLoaderExecutor<TEnv>(
|
|
|
438
452
|
// loader's cache() read-through lives in loader-cache.ts
|
|
439
453
|
// (executeLoaderData), whose ctx.use override serves a later handler read
|
|
440
454
|
// the binding's result.
|
|
441
|
-
|
|
442
|
-
|
|
455
|
+
//
|
|
456
|
+
// A read is a consumption: the value bakes into the reader's output, so
|
|
457
|
+
// the loader's tags follow the reader onto a cache() record (#957) and
|
|
458
|
+
// into the reader's own loader-cache entry (#964, readValueTags; armed
|
|
459
|
+
// only where a loader cache() can read them).
|
|
460
|
+
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
461
|
+
linkLoaderTags(loader.$$id, reqCtx);
|
|
462
|
+
const recordTags = loaderTagSetsArmed(reqCtx);
|
|
463
|
+
// A loader body reads a cache()-bound loader through its binding too, so
|
|
464
|
+
// every reader in the request gets the one value a HIT served. The
|
|
465
|
+
// binding's own MISS (callerLoaderId null) runs the loader below.
|
|
466
|
+
const bound =
|
|
467
|
+
callerLoaderId !== null &&
|
|
468
|
+
internal._loaderCacheOverrides?.get(loader.$$id);
|
|
469
|
+
const memo = bound || loaderPromises.get(loader.$$id);
|
|
470
|
+
if (memo) {
|
|
471
|
+
if (recordTags) readValueTags(memo);
|
|
472
|
+
return memo;
|
|
443
473
|
}
|
|
444
474
|
|
|
445
475
|
// Get loader function - either from loader object or fetchable registry
|
|
@@ -534,7 +564,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
534
564
|
return collectHandleData(keyOrVar, snapshot, segmentOrder);
|
|
535
565
|
}
|
|
536
566
|
if (isNonCacheable(variables, keyOrVar)) {
|
|
537
|
-
assertNonCacheableReadAllowed(keyOrVar);
|
|
567
|
+
assertNonCacheableReadAllowed(keyOrVar, _getRequestContext());
|
|
538
568
|
}
|
|
539
569
|
return contextGet(variables, keyOrVar);
|
|
540
570
|
}) as typeof ctx.get,
|
|
@@ -680,42 +710,29 @@ function createLoaderExecutor<TEnv>(
|
|
|
680
710
|
// does not grant rendered() to handler-invoked loaders. Uses a body-only
|
|
681
711
|
// scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
|
|
682
712
|
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
// cache-purity guards give it. DSL segment loaders keep their lane
|
|
688
|
-
// machinery (live = masked at capture, bake = guarded). A DSL loader's
|
|
689
|
-
// nested deps inherit isDslLoader=false only when the CHAIN started in a
|
|
690
|
-
// handler; a chain started by the segment funnel stays DSL (the loader
|
|
691
|
-
// scope ALS survives the body's awaits).
|
|
692
|
-
// Shell fast path eligibility: a HANDLER-invoked loader executing during a
|
|
693
|
-
// capture is handler-layer dynamism — on a handler-free (replayed) HIT it
|
|
694
|
-
// would never re-run, freezing its consumption-lane value (#672's "fresh
|
|
695
|
-
// per serve" slot shape). Mark the capture; the entry declines the fast
|
|
696
|
-
// path and keeps the full tail. DSL loaders re-run on every HIT and never
|
|
697
|
-
// set this.
|
|
698
|
-
if (!isDslLoader) {
|
|
699
|
-
const captureCtx = _getRequestContext();
|
|
700
|
-
if (
|
|
701
|
-
captureCtx?._shellCaptureRun &&
|
|
702
|
-
captureCtx._shellCaptureHandleLiveness
|
|
703
|
-
) {
|
|
704
|
-
captureCtx._shellCaptureHandleLiveness.handlerInvokedLoader = true;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
713
|
+
// The CONSUMPTION-LANE RULE: a handler-consumed loader's value is a
|
|
714
|
+
// BAKED copy in every shared artifact (cache(), "use cache", the PPR
|
|
715
|
+
// shell). The cache-purity guards permit its identity reads; the PPR
|
|
716
|
+
// capture guard does not (server/context.ts assertNotInsideShellCapture).
|
|
707
717
|
|
|
718
|
+
const recordedTags = recordTags ? new Set<string>() : undefined;
|
|
708
719
|
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
709
720
|
Promise.resolve(
|
|
710
721
|
runInsideLoaderBodyScope(
|
|
711
722
|
() => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
712
723
|
loader.$$id,
|
|
713
|
-
|
|
724
|
+
recordedTags,
|
|
714
725
|
),
|
|
715
726
|
).finally(() => {
|
|
716
727
|
pendingLoaders.delete(loader.$$id);
|
|
717
728
|
}),
|
|
718
729
|
);
|
|
730
|
+
if (recordedTags) {
|
|
731
|
+
tagLoaderValue(promise, recordedTags);
|
|
732
|
+
readValueTags(promise);
|
|
733
|
+
const bindingTags = executorOptions?.bindingTags?.get(loader.$$id);
|
|
734
|
+
if (bindingTags) linkRecordedTags(recordedTags, bindingTags);
|
|
735
|
+
}
|
|
719
736
|
|
|
720
737
|
// Auxiliary-lane tracking: keeps the handle store open for this body's
|
|
721
738
|
// ctx.handle() pushes without joining `settled` (the handler barrier that
|
|
@@ -759,8 +776,6 @@ export function setupLoaderAccess<TEnv>(
|
|
|
759
776
|
const reqCtxRef = _getRequestContext();
|
|
760
777
|
const handleStoreRef = reqCtxRef?._handleStore;
|
|
761
778
|
|
|
762
|
-
const useLoader = createLoaderExecutor(ctx, loaderPromises);
|
|
763
|
-
|
|
764
779
|
// Loader-cache HIT replay (loader-cache.ts replayLoaderHandles): a cached
|
|
765
780
|
// entry records its ctx.use dependencies' pushes too, and a dependency is
|
|
766
781
|
// memoized here and shared with live readers. A loader that already ran
|
|
@@ -773,12 +788,28 @@ export function setupLoaderAccess<TEnv>(
|
|
|
773
788
|
claimed.add(loaderId);
|
|
774
789
|
return true;
|
|
775
790
|
};
|
|
791
|
+
// A loader cache() binding's tags reach every execution of that loader
|
|
792
|
+
// outside the binding: one a reader started before the binding (linked
|
|
793
|
+
// here), a later one, or a refresh's (bindingTags).
|
|
794
|
+
const executor: { bindingTags?: Map<string, Set<string>> } = {};
|
|
795
|
+
internal._bindLoaderCacheTags = (loaderId, tags) => {
|
|
796
|
+
(executor.bindingTags ??= new Map()).set(loaderId, tags);
|
|
797
|
+
const started = loaderPromises.get(loaderId);
|
|
798
|
+
if (started) linkValueTags(started, tags);
|
|
799
|
+
};
|
|
800
|
+
const useLoader = createLoaderExecutor(ctx, loaderPromises, executor);
|
|
776
801
|
// A refresh's pushes are diverted, so its loader ctxs never claim
|
|
777
|
-
// (cache-runtime.ts refreshView).
|
|
802
|
+
// (cache-runtime.ts refreshView). It reads each loader fresh, not through
|
|
803
|
+
// the page's bindings: a stale refresh must not rebuild its entry from
|
|
804
|
+
// another stale entry.
|
|
778
805
|
internal._runLoaderIsolated = (loader) =>
|
|
779
806
|
createLoaderExecutor(
|
|
780
|
-
Object.create(ctx, {
|
|
807
|
+
Object.create(ctx, {
|
|
808
|
+
_claimLoaderPushes: { value: undefined },
|
|
809
|
+
_loaderCacheOverrides: { value: undefined },
|
|
810
|
+
}),
|
|
781
811
|
new Map(),
|
|
812
|
+
executor,
|
|
782
813
|
)(loader, null);
|
|
783
814
|
|
|
784
815
|
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
package/src/router/match-api.ts
CHANGED
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
createCacheScope,
|
|
4
4
|
resolveShellImplicitCacheScope,
|
|
5
5
|
} from "../cache/cache-scope.js";
|
|
6
|
+
import { armLoaderTagSets, armRecordTagOwners } from "../cache/cache-tag.js";
|
|
7
|
+
import { bindsLoaderCache } from "./segment-resolution/loader-cache.js";
|
|
6
8
|
import { RouteNotFoundError } from "../errors";
|
|
7
9
|
import {
|
|
8
10
|
createErrorInfo,
|
|
@@ -150,6 +152,20 @@ export async function createMatchContextForFull<TEnv>(
|
|
|
150
152
|
Store.metrics = metricsStore;
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// A shell capture's implicit doc scope, or a document HIT tail's (which
|
|
156
|
+
// replaces any route-derived scope: the tail replays the shell's doc
|
|
157
|
+
// record), or else the route's own scope.
|
|
158
|
+
const cacheScope = resolveShellImplicitCacheScope(snapshot.cacheScope);
|
|
159
|
+
// A scope means a route cache() record may be written: arm its tag owners
|
|
160
|
+
// before any handler runs (#957). A document HIT tail writes no record and
|
|
161
|
+
// runs no handler, so it arms nothing. A loader bound with its own cache()
|
|
162
|
+
// needs the per-execution loader tag sets before any loader runs (#964):
|
|
163
|
+
// loaders still run on a HIT.
|
|
164
|
+
if (cacheScope && !_getRequestContext()?._shellImplicitCache?.docTail) {
|
|
165
|
+
armRecordTagOwners();
|
|
166
|
+
}
|
|
167
|
+
if (bindsLoaderCache(snapshot.entries)) armLoaderTagSets();
|
|
168
|
+
|
|
153
169
|
return {
|
|
154
170
|
request,
|
|
155
171
|
url: cleanUrl,
|
|
@@ -186,9 +202,7 @@ export async function createMatchContextForFull<TEnv>(
|
|
|
186
202
|
},
|
|
187
203
|
isSameRouteNavigation: false,
|
|
188
204
|
interceptResult: null,
|
|
189
|
-
|
|
190
|
-
// substitute an implicit doc-level scope (a route-derived scope wins).
|
|
191
|
-
cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
|
|
205
|
+
cacheScope,
|
|
192
206
|
isIntercept: false,
|
|
193
207
|
actionContext: undefined,
|
|
194
208
|
isAction: false,
|
|
@@ -401,6 +415,16 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
401
415
|
}
|
|
402
416
|
|
|
403
417
|
const isIntercept = !!interceptResult;
|
|
418
|
+
// A normal-route navigation may replay a PPR shell's canonical segment
|
|
419
|
+
// snapshot. Intercepts remain source-dependent and always use their normal
|
|
420
|
+
// cache path.
|
|
421
|
+
const cacheScope = isIntercept
|
|
422
|
+
? snapshot.cacheScope
|
|
423
|
+
: resolveShellImplicitCacheScope(snapshot.cacheScope);
|
|
424
|
+
// A scope means a route cache() record may be written: arm its tag owners
|
|
425
|
+
// before any handler runs (#957); see the full-match site for #964's arm.
|
|
426
|
+
if (cacheScope) armRecordTagOwners();
|
|
427
|
+
if (bindsLoaderCache(snapshot.entries)) armLoaderTagSets();
|
|
404
428
|
|
|
405
429
|
return {
|
|
406
430
|
request,
|
|
@@ -427,12 +451,7 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
427
451
|
interceptSelectorContext,
|
|
428
452
|
isSameRouteNavigation: nav.isSameRouteNavigation,
|
|
429
453
|
interceptResult,
|
|
430
|
-
|
|
431
|
-
// snapshot. Intercepts remain source-dependent and always use their normal
|
|
432
|
-
// cache path.
|
|
433
|
-
cacheScope: isIntercept
|
|
434
|
-
? snapshot.cacheScope
|
|
435
|
-
: resolveShellImplicitCacheScope(snapshot.cacheScope),
|
|
454
|
+
cacheScope,
|
|
436
455
|
isIntercept,
|
|
437
456
|
actionContext,
|
|
438
457
|
isAction,
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
buildCacheSignalSegments,
|
|
48
48
|
} from "./telemetry.js";
|
|
49
49
|
import { _getRequestContext } from "../server/request-context.js";
|
|
50
|
+
import { ShellRecordUnavailableError } from "../cache/shell-snapshot.js";
|
|
50
51
|
import { evaluatePprTransitionWhen } from "./transition-when.js";
|
|
51
52
|
|
|
52
53
|
/**
|
|
@@ -380,6 +381,10 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
380
381
|
emitter.end(0, false, error.status);
|
|
381
382
|
throw error;
|
|
382
383
|
}
|
|
384
|
+
// A document shell HIT tail's doc record could not supply the
|
|
385
|
+
// handler layer: a serve-path signal serveShellHit degrades, not a
|
|
386
|
+
// routing error (the record's decode failure was already reported).
|
|
387
|
+
if (error instanceof ShellRecordUnavailableError) throw error;
|
|
383
388
|
emitter.error(
|
|
384
389
|
error instanceof Error ? error : new Error(String(error)),
|
|
385
390
|
"routing",
|