@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.144
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/dist/vite/index.js +23 -6
- package/package.json +2 -2
- package/skills/cache-guide/SKILL.md +3 -1
- package/skills/caching/SKILL.md +23 -2
- package/skills/catalog.json +6 -0
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/loader/SKILL.md +5 -0
- package/skills/migrate-nextjs/SKILL.md +4 -2
- package/skills/parallel/SKILL.md +2 -0
- package/skills/ppr/SKILL.md +34 -10
- package/skills/rango/SKILL.md +10 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/partial-update.ts +7 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +54 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/vercel/vercel-cache-store.ts +9 -19
- package/src/redirect-origin.ts +14 -0
- package/src/route-map-builder.ts +17 -3
- package/src/router/lazy-includes.ts +8 -2
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/match-handlers.ts +11 -6
- package/src/router/middleware.ts +4 -1
- package/src/router/telemetry.ts +9 -1
- package/src/router.ts +7 -8
- package/src/rsc/handler.ts +9 -2
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +35 -2
- package/src/rsc/shell-capture.ts +93 -20
- package/src/server/context.ts +47 -9
- package/src/server/cookie-store.ts +26 -5
- package/src/server/request-context.ts +22 -0
- package/src/ssr/index.tsx +145 -107
- package/src/testing/dispatch.ts +7 -0
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/src/cache/cache-scope.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
encodeHandles,
|
|
32
32
|
decodeHandles,
|
|
33
33
|
} from "./handle-snapshot.js";
|
|
34
|
-
import {
|
|
34
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
35
35
|
import {
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
isFiniteNonNegativeSeconds,
|
|
@@ -85,21 +85,6 @@ function validatedSwr(value: number | undefined): number | undefined {
|
|
|
85
85
|
return isValidCacheSeconds(value, "swr") ? value : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function getCacheKeyBase(
|
|
89
|
-
host: string,
|
|
90
|
-
pathname: string,
|
|
91
|
-
params?: Record<string, string>,
|
|
92
|
-
searchParams?: URLSearchParams,
|
|
93
|
-
): string {
|
|
94
|
-
const paramStr = sortedRouteParams(params);
|
|
95
|
-
const searchStr = searchParams ? sortedSearchString(searchParams) : "";
|
|
96
|
-
|
|
97
|
-
let key = `${host}${pathname}`;
|
|
98
|
-
if (paramStr) key += `:${paramStr}`;
|
|
99
|
-
if (searchStr) key += `?${searchStr}`;
|
|
100
|
-
return key;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
88
|
function getDefaultRouteCacheKey(
|
|
104
89
|
pathname: string,
|
|
105
90
|
params?: Record<string, string>,
|
|
@@ -113,7 +98,7 @@ function getDefaultRouteCacheKey(
|
|
|
113
98
|
// Intercept navigations get their own cache namespace
|
|
114
99
|
const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
|
|
115
100
|
|
|
116
|
-
return `${prefix}:${
|
|
101
|
+
return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
|
|
117
102
|
}
|
|
118
103
|
|
|
119
104
|
// ============================================================================
|
package/src/cache/cache-tag.ts
CHANGED
|
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Tag
|
|
42
|
-
* updateTag() / revalidateTag().
|
|
41
|
+
* Tag content for later invalidation via updateTag() / revalidateTag().
|
|
43
42
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* cacheTag() serves two forms depending on what is active when it runs:
|
|
44
|
+
*
|
|
45
|
+
* 1. Inside a "use cache" function — the DEFAULT. The tags go to the current
|
|
46
|
+
* cache entry; `revalidateTag(tag)` drops that entry. Tags are additive
|
|
47
|
+
* (multiple calls accumulate), and normalizeTag() is the single chokepoint so
|
|
48
|
+
* a padded write matches an unpadded invalidate.
|
|
49
|
+
*
|
|
50
|
+
* 2. Render-callable (#648) — no "use cache" scope active, but a request context
|
|
51
|
+
* is present. The tags record onto the request's DOCUMENT artifact
|
|
52
|
+
* (ctx._requestTags) instead of throwing. The collection layers already exist:
|
|
53
|
+
* PPR shell capture unions _requestTags into the shell entry, the document
|
|
54
|
+
* cache tags the full-page entry with it, and prerender build contexts seed
|
|
55
|
+
* their own set — so a server component that renders into a shell makes
|
|
56
|
+
* `revalidateTag("campaign:spring")` evict that shell with ZERO cache()/"use
|
|
57
|
+
* cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
|
|
58
|
+
* execution-PRESERVING (everything still runs underneath; only document bytes
|
|
59
|
+
* are shortcut), so its tags ride this existing instrument rather than a
|
|
60
|
+
* first-class ppr key/tag API. The shell-expiry invariant holds by
|
|
61
|
+
* construction — baked ⇒ evicts (bake-lane loaders execute during capture and
|
|
62
|
+
* record here), hole ⇒ fresh (masked loaders behind a renderable loading()
|
|
63
|
+
* never execute during capture, so nothing under a hole can tag the shell).
|
|
64
|
+
*
|
|
65
|
+
* Inside a cache() DSL segment the render-callable form records at the DOCUMENT
|
|
66
|
+
* level, not the segment (only the "use cache" runtime enters the tag scope) — a
|
|
67
|
+
* documented semantic, not a filtered one. An empty/whitespace-only tag is
|
|
68
|
+
* dropped in both forms (the render-callable form silently, via normalizeTags in
|
|
69
|
+
* recordRequestTags; the scope form with a dev warning).
|
|
70
|
+
*
|
|
71
|
+
* With neither a scope nor a request context, cacheTag() throws.
|
|
46
72
|
*
|
|
47
73
|
* @example
|
|
48
74
|
* ```typescript
|
|
75
|
+
* // Form 1 — inside "use cache":
|
|
49
76
|
* async function getProduct(ctx) {
|
|
50
77
|
* "use cache";
|
|
51
78
|
* cacheTag(`product:${ctx.params.id}`, "products");
|
|
52
79
|
* return db.getProduct(ctx.params.id);
|
|
53
80
|
* }
|
|
81
|
+
*
|
|
82
|
+
* // Form 2 — render-callable, tags the shell/document from a server component:
|
|
83
|
+
* function CampaignBanner() {
|
|
84
|
+
* cacheTag("campaign:spring");
|
|
85
|
+
* return <aside>Spring sale</aside>;
|
|
86
|
+
* }
|
|
54
87
|
* ```
|
|
55
88
|
*/
|
|
56
89
|
export function cacheTag(...tags: string[]): void {
|
|
57
90
|
const store = cacheTagStorage.getStore();
|
|
58
|
-
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
if (store) {
|
|
92
|
+
// Form 1: "use cache" scope wins — tag the cache entry (unchanged).
|
|
93
|
+
for (const tag of tags) {
|
|
94
|
+
const normalized = normalizeTag(tag);
|
|
95
|
+
if (normalized === null) {
|
|
96
|
+
if (process.env.NODE_ENV !== "production") {
|
|
97
|
+
console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
66
100
|
}
|
|
67
|
-
|
|
101
|
+
store.add(normalized);
|
|
68
102
|
}
|
|
69
|
-
|
|
103
|
+
return;
|
|
70
104
|
}
|
|
105
|
+
|
|
106
|
+
const reqCtx = _getRequestContext();
|
|
107
|
+
if (reqCtx?._requestTags) {
|
|
108
|
+
// Form 2: render-callable — tag the request's document artifact. See the
|
|
109
|
+
// JSDoc above for the composition doctrine and the baked/hole invariant.
|
|
110
|
+
recordRequestTags(tags, reqCtx);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(
|
|
115
|
+
'cacheTag() must be called inside a "use cache" function or during a request render.',
|
|
116
|
+
);
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
export function recordRequestTags(
|
|
@@ -137,6 +137,16 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
137
137
|
*/
|
|
138
138
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
142
|
+
* markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
|
|
143
|
+
* warning fires once per process rather than once per invalidateTags call
|
|
144
|
+
* (CFCacheStore is constructed per request; invalidateTags runs per marker
|
|
145
|
+
* batch). Distinct from the floor warning: that one only fires for a positive
|
|
146
|
+
* below-floor value, never for the unset (no-expiry) default that this bounds.
|
|
147
|
+
*/
|
|
148
|
+
const warnedNoTagInvalidationTtl = new Set<string>();
|
|
149
|
+
|
|
140
150
|
// ============================================================================
|
|
141
151
|
// Types
|
|
142
152
|
// ============================================================================
|
|
@@ -340,19 +350,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
340
350
|
// kv - yet every tagged read still serves stale data with no other signal.
|
|
341
351
|
// Surface that misconfiguration.
|
|
342
352
|
if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`{ kv } for distributed tag invalidation.`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
+
this.warnOncePerNamespace(
|
|
354
|
+
warnedNoKvReadInvalidation,
|
|
355
|
+
`[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
|
|
356
|
+
`namespace, so tag invalidation has NO read-side effect: tagged reads ` +
|
|
357
|
+
`are never treated as invalidated and serve stale data. Configure ` +
|
|
358
|
+
`{ kv } for distributed tag invalidation.`,
|
|
359
|
+
);
|
|
353
360
|
}
|
|
354
361
|
}
|
|
355
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Warn about a namespace-scoped misconfiguration once per namespace per
|
|
365
|
+
* isolate. `seen` is the module-level Set for that message family -- Sets
|
|
366
|
+
* are module-level (not instance fields) so re-constructed stores in the
|
|
367
|
+
* same isolate don't re-warn.
|
|
368
|
+
* @internal
|
|
369
|
+
*/
|
|
370
|
+
private warnOncePerNamespace(seen: Set<string>, message: string): void {
|
|
371
|
+
const id = this.namespace ?? "default";
|
|
372
|
+
if (seen.has(id)) return;
|
|
373
|
+
seen.add(id);
|
|
374
|
+
console.warn(message);
|
|
375
|
+
}
|
|
376
|
+
|
|
356
377
|
/**
|
|
357
378
|
* Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
|
|
358
379
|
* floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
|
|
@@ -368,16 +389,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
368
389
|
if (value == null) return undefined;
|
|
369
390
|
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
370
391
|
if (value < KV_MIN_EXPIRATION_TTL) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
379
|
-
);
|
|
380
|
-
}
|
|
392
|
+
this.warnOncePerNamespace(
|
|
393
|
+
warnedTagInvalidationTtlFloor,
|
|
394
|
+
`[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
|
|
395
|
+
`${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
|
|
396
|
+
`${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
|
|
397
|
+
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
398
|
+
);
|
|
381
399
|
return KV_MIN_EXPIRATION_TTL;
|
|
382
400
|
}
|
|
383
401
|
return value;
|
|
@@ -2254,6 +2272,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2254
2272
|
const failedTags = new Set<string>();
|
|
2255
2273
|
const errors: unknown[] = [];
|
|
2256
2274
|
if (this.kv) {
|
|
2275
|
+
// Markers written with no expiry (tagInvalidationTtl unset) never expire,
|
|
2276
|
+
// so high-cardinality tags accumulate KV keys unboundedly with no reaper.
|
|
2277
|
+
// Warn once per namespace at the batch entry point (not per marker write,
|
|
2278
|
+
// which would fire once per tag). Kept separate from the floor warning:
|
|
2279
|
+
// that path only fires for a positive below-floor value, never the unset
|
|
2280
|
+
// default sanitizeTagInvalidationTtl passes through as undefined.
|
|
2281
|
+
if (!this.tagInvalidationTtl) {
|
|
2282
|
+
this.warnOncePerNamespace(
|
|
2283
|
+
warnedNoTagInvalidationTtl,
|
|
2284
|
+
`[CFCacheStore] invalidateTags is writing KV markers with no expiry ` +
|
|
2285
|
+
`(tagInvalidationTtl is unset): high-cardinality tags accumulate KV ` +
|
|
2286
|
+
`keys unboundedly (storage + list-scan cost) with no reaper. Set ` +
|
|
2287
|
+
`tagInvalidationTtl above your largest entry TTL+SWR to bound marker ` +
|
|
2288
|
+
`growth; setting it too small resurrects invalidated entries.`,
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2257
2291
|
await Promise.all(
|
|
2258
2292
|
tags.map(async (tag) => {
|
|
2259
2293
|
const markerKey = this.tagMarkerKey(tag);
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RequestContext,
|
|
20
20
|
} from "../server/request-context.js";
|
|
21
21
|
import { mayNeedSSR } from "../rsc/ssr-setup.js";
|
|
22
|
-
import {
|
|
22
|
+
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
23
23
|
import { runBackground } from "./background-task.js";
|
|
24
24
|
import { reportCacheError } from "./cache-error.js";
|
|
25
25
|
|
|
@@ -188,7 +188,13 @@ export interface DocumentCacheOptions<TEnv = any> {
|
|
|
188
188
|
skipPaths?: string[];
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
* Custom cache key generator
|
|
191
|
+
* Custom cache key generator.
|
|
192
|
+
*
|
|
193
|
+
* Replaces the default `host + pathname + search` key entirely. On a
|
|
194
|
+
* multi-domain deployment served by one function you MUST include `url.host`
|
|
195
|
+
* (or an equivalent tenant discriminator) yourself — the default key is
|
|
196
|
+
* host-namespaced, but a custom generator's output is used verbatim, so
|
|
197
|
+
* omitting host bleeds one hostname's cached response to another.
|
|
192
198
|
*/
|
|
193
199
|
keyGenerator?: (url: URL) => string;
|
|
194
200
|
|
|
@@ -311,17 +317,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
311
317
|
isPartial && clientSegments ? `:${hashSegmentIds(clientSegments)}` : "";
|
|
312
318
|
const typeSuffix = isRscRequest ? ":rsc" : ":html";
|
|
313
319
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
320
|
+
// Default key rides the shared host-namespaced base (cacheKeyBase) so the
|
|
321
|
+
// segment tier (cache-scope.ts) and this document tier cannot drift on the
|
|
322
|
+
// host-namespacing rule -- see the contract on cacheKeyBase.
|
|
323
|
+
// The keyGenerator branch is left untouched: a consumer-supplied generator
|
|
324
|
+
// owns its own namespacing (auto-prefixing host would silently change their
|
|
325
|
+
// existing keys and double any host they already include).
|
|
322
326
|
const cacheKey = keyGenerator
|
|
323
327
|
? keyGenerator(url) + segmentHash + typeSuffix
|
|
324
|
-
:
|
|
328
|
+
: cacheKeyBase(url.host, url.pathname, url.searchParams) +
|
|
329
|
+
segmentHash +
|
|
330
|
+
typeSuffix;
|
|
325
331
|
// 1. Check cache
|
|
326
332
|
const cached = await store.getResponse(cacheKey);
|
|
327
333
|
|
|
@@ -51,6 +51,15 @@ import {
|
|
|
51
51
|
} from "../cache-policy.js";
|
|
52
52
|
import { reportCacheError, reportingAsync } from "../cache-error.js";
|
|
53
53
|
import type { CacheErrorCategory } from "../cache-error.js";
|
|
54
|
+
// Reuse the CF store's binary-safe base64 helpers. bufferToBase64 caps each
|
|
55
|
+
// String.fromCharCode batch at 8192 and uses .apply (never a spread), so a large
|
|
56
|
+
// Response/PPR-shell body cannot blow the JS argument-count ceiling (~65k) and
|
|
57
|
+
// throw RangeError inside putResponse/putShell - which the outer try/catch would
|
|
58
|
+
// swallow as a cache-write degrade, silently never caching the entry. Output is
|
|
59
|
+
// byte-identical to a per-byte encoder (chunk size does not affect base64), so
|
|
60
|
+
// this is a robustness fix, not a format change. Do NOT reintroduce a local
|
|
61
|
+
// spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
|
|
62
|
+
import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
|
|
54
63
|
|
|
55
64
|
/**
|
|
56
65
|
* Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
|
|
@@ -277,25 +286,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
277
286
|
return typeof value === "object" && value !== null;
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
/** Encode binary body bytes to base64 in chunks (avoids call-stack blowups). */
|
|
281
|
-
function bufferToBase64(buffer: ArrayBuffer): string {
|
|
282
|
-
const bytes = new Uint8Array(buffer);
|
|
283
|
-
let binary = "";
|
|
284
|
-
const CHUNK = 0x8000;
|
|
285
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
286
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
287
|
-
}
|
|
288
|
-
return btoa(binary);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Decode a base64 body back into bytes. */
|
|
292
|
-
function base64ToBuffer(b64: string): ArrayBuffer {
|
|
293
|
-
const binary = atob(b64);
|
|
294
|
-
const bytes = new Uint8Array(binary.length);
|
|
295
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
296
|
-
return bytes.buffer;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
289
|
/**
|
|
300
290
|
* Vercel Runtime Cache-backed segment cache store.
|
|
301
291
|
*
|
package/src/redirect-origin.ts
CHANGED
|
@@ -60,6 +60,20 @@ export function resolveExternalRedirect(
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The safe same-origin landing for a blocked redirect.
|
|
65
|
+
*
|
|
66
|
+
* Every guard that neutralizes a cross-origin/unsafe redirect target sends the
|
|
67
|
+
* browser here instead: the app's basename root, or `"/"` when unset. Kept
|
|
68
|
+
* beside the resolvers so the "where does a blocked redirect go" answer lives
|
|
69
|
+
* in ONE place -- the server 3xx guard (`rsc/redirect-guard.ts`) and the
|
|
70
|
+
* shell-HIT degradation path (`rsc/rsc-rendering.ts`) must agree, or a blocked
|
|
71
|
+
* redirect lands differently depending on which exit it took.
|
|
72
|
+
*/
|
|
73
|
+
export function safeSameOriginLanding(basename: string | undefined): string {
|
|
74
|
+
return basename && basename !== "/" ? basename : "/";
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
/**
|
|
64
78
|
* Out-of-band brand for `redirect(url, { external: true })`.
|
|
65
79
|
*
|
package/src/route-map-builder.ts
CHANGED
|
@@ -20,11 +20,25 @@ let cachedPrecomputedEntries: Array<{
|
|
|
20
20
|
/**
|
|
21
21
|
* Register routes into the global route map.
|
|
22
22
|
* Routes are merged with any existing registered routes.
|
|
23
|
-
* Called by createRouter() during module evaluation
|
|
23
|
+
* Called by createRouter() during module evaluation, and by lazy-include
|
|
24
|
+
* expansion (src/router/lazy-includes.ts) with each expansion's route delta.
|
|
25
|
+
*
|
|
26
|
+
* Merges IN PLACE — O(|map|), not O(total routes). The previous
|
|
27
|
+
* `globalRouteMap = { ...globalRouteMap, ...map }` copy made every
|
|
28
|
+
* lazy-include first hit O(total routes) on the request path: with a 26k-route
|
|
29
|
+
* manifest the spread measured 8.9ms/call (M4, node), paid once per level of a
|
|
30
|
+
* nested async-include chain (3 calls on a 3-level chain — the 464ms edge
|
|
31
|
+
* cold-hit in issue #666).
|
|
32
|
+
*
|
|
33
|
+
* In-place mutation is safe because every getGlobalRouteMap() consumer reads
|
|
34
|
+
* it fresh per call (server/request-context.ts, rsc/loader-fetch.ts,
|
|
35
|
+
* router/intercept-resolution.ts, testing/generated-routes.ts,
|
|
36
|
+
* rsc/manifest-init.ts) — none memoizes the returned reference. If you add a
|
|
37
|
+
* consumer that caches the map object, it will now observe later
|
|
38
|
+
* registrations; snapshot it yourself if you need frozen contents.
|
|
24
39
|
*/
|
|
25
40
|
export function registerRouteMap(map: Record<string, string>): void {
|
|
26
|
-
|
|
27
|
-
globalRouteMap = { ...globalRouteMap, ...map };
|
|
41
|
+
Object.assign(globalRouteMap, map);
|
|
28
42
|
}
|
|
29
43
|
|
|
30
44
|
/**
|
|
@@ -91,7 +91,11 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
91
91
|
for (const [name, pattern] of Object.entries(routes)) {
|
|
92
92
|
deps.mergedRouteMap[name] = pattern;
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
// Register only this entry's routes (the delta): the full
|
|
95
|
+
// mergedRouteMap is seeded from the generated manifest at
|
|
96
|
+
// createRouter() time and already registered there — re-passing it
|
|
97
|
+
// made this request-path call O(total routes) (issue #666).
|
|
98
|
+
registerRouteMap(routes);
|
|
95
99
|
return;
|
|
96
100
|
}
|
|
97
101
|
}
|
|
@@ -245,7 +249,9 @@ function runExpansion<TEnv = any>(
|
|
|
245
249
|
deps.routesEntries.splice(insertIndex, 0, nestedEntry);
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
|
|
252
|
+
// Delta only — see the matching comment on the precomputed branch above and
|
|
253
|
+
// the WHY block on registerRouteMap (issue #666).
|
|
254
|
+
registerRouteMap(routesObject);
|
|
249
255
|
|
|
250
256
|
// Expansion fully succeeded (handler ran, routes + nested includes spliced) —
|
|
251
257
|
// mark done now so a mid-expansion throw above leaves lazyEvaluated=false and
|
|
@@ -420,10 +420,22 @@ function createLoaderExecutor<TEnv>(
|
|
|
420
420
|
// throw. rendered() gating uses the captured isDslLoader (above), so this
|
|
421
421
|
// does not grant rendered() to handler-invoked loaders. Uses a body-only
|
|
422
422
|
// scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
|
|
423
|
+
//
|
|
424
|
+
// `handlerInvoked` (!isDslLoader) rides on the scope for the CONSUMPTION-
|
|
425
|
+
// LANE RULE: a handler-consumed loader's value is a BAKED copy in every
|
|
426
|
+
// shared artifact (cache(), "use cache", the PPR shell), so its identity
|
|
427
|
+
// reads are exempt from the shell-capture guard — same allowance the
|
|
428
|
+
// cache-purity guards give it. DSL segment loaders keep their lane
|
|
429
|
+
// machinery (live = masked at capture, bake = guarded). A DSL loader's
|
|
430
|
+
// nested deps inherit isDslLoader=false only when the CHAIN started in a
|
|
431
|
+
// handler; a chain started by the segment funnel stays DSL (the loader
|
|
432
|
+
// scope ALS survives the body's awaits).
|
|
423
433
|
const promise = observePhase(PHASES.loader(loader.$$id), () =>
|
|
424
434
|
Promise.resolve(
|
|
425
|
-
runInsideLoaderBodyScope(
|
|
426
|
-
loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
435
|
+
runInsideLoaderBodyScope(
|
|
436
|
+
() => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
|
|
437
|
+
loader.$$id,
|
|
438
|
+
!isDslLoader,
|
|
427
439
|
),
|
|
428
440
|
).finally(() => {
|
|
429
441
|
pendingLoaders.delete(loader.$$id);
|
|
@@ -52,7 +52,7 @@ import { _getRequestContext } from "../server/request-context.js";
|
|
|
52
52
|
*/
|
|
53
53
|
interface LifecycleEmitter {
|
|
54
54
|
start(): void;
|
|
55
|
-
end(segmentCount: number, cacheHit: boolean): void;
|
|
55
|
+
end(segmentCount: number, cacheHit: boolean, status?: number): void;
|
|
56
56
|
cacheDecision(
|
|
57
57
|
routeKey: string,
|
|
58
58
|
state: {
|
|
@@ -88,7 +88,7 @@ function createLifecycleEmitter(args: {
|
|
|
88
88
|
isPartial: args.isPartial,
|
|
89
89
|
});
|
|
90
90
|
},
|
|
91
|
-
end(segmentCount: number, cacheHit: boolean): void {
|
|
91
|
+
end(segmentCount: number, cacheHit: boolean, status?: number): void {
|
|
92
92
|
if (!args.enabled) return;
|
|
93
93
|
safeEmit(args.sink, {
|
|
94
94
|
type: "request.end",
|
|
@@ -100,6 +100,9 @@ function createLifecycleEmitter(args: {
|
|
|
100
100
|
durationMs: performance.now() - args.matchStart,
|
|
101
101
|
segmentCount,
|
|
102
102
|
cacheHit,
|
|
103
|
+
// Only a thrown-Response short-circuit passes a status; a normal render
|
|
104
|
+
// completion omits it (the Response is built after match()).
|
|
105
|
+
...(status !== undefined && { status }),
|
|
103
106
|
});
|
|
104
107
|
},
|
|
105
108
|
cacheDecision(
|
|
@@ -322,8 +325,9 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
322
325
|
// error: emit request.end (the same shape the non-thrown redirect
|
|
323
326
|
// result above already emits), never request.error with a
|
|
324
327
|
// synthetic "[object Response]" error. Rethrow so the caller
|
|
325
|
-
// drives the redirect.
|
|
326
|
-
|
|
328
|
+
// drives the redirect. Carry the Response's status so a sink can
|
|
329
|
+
// tell a 3xx short-circuit from a 2xx completion.
|
|
330
|
+
emitter.end(0, false, error.status);
|
|
327
331
|
throw error;
|
|
328
332
|
}
|
|
329
333
|
emitter.error(
|
|
@@ -441,8 +445,9 @@ export function createMatchHandlers<TEnv = any>(
|
|
|
441
445
|
// A thrown Response (middleware short-circuit — redirect / auth
|
|
442
446
|
// gate) is a COMPLETED request, not an error: emit request.end
|
|
443
447
|
// (parity with match()), never request.error. Rethrow so the
|
|
444
|
-
// caller drives the redirect.
|
|
445
|
-
|
|
448
|
+
// caller drives the redirect. Carry the Response's status so a
|
|
449
|
+
// sink can tell a 3xx short-circuit from a 2xx completion.
|
|
450
|
+
emitter.end(0, false, error.status);
|
|
446
451
|
throw error;
|
|
447
452
|
}
|
|
448
453
|
emitter.error(
|
package/src/router/middleware.ts
CHANGED
|
@@ -312,7 +312,10 @@ export function createMiddlewareContext<TEnv>(
|
|
|
312
312
|
const reqCtx = _getRequestContext();
|
|
313
313
|
if (reqCtx) {
|
|
314
314
|
reqCtx._debugPerformance = true;
|
|
315
|
-
reqCtx.
|
|
315
|
+
// Anchor to the true request entry (reqCtx._handlerStart) so phases that
|
|
316
|
+
// began before this opt-in report non-negative offsets; undefined falls
|
|
317
|
+
// back to performance.now() in createMetricsStore.
|
|
318
|
+
reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
|
|
316
319
|
}
|
|
317
320
|
},
|
|
318
321
|
};
|
package/src/router/telemetry.ts
CHANGED
|
@@ -38,6 +38,14 @@ export interface RequestEndEvent extends BaseEvent {
|
|
|
38
38
|
durationMs: number;
|
|
39
39
|
segmentCount: number;
|
|
40
40
|
cacheHit: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* HTTP status when a Response ended the transaction — a thrown-Response
|
|
43
|
+
* short-circuit (redirect / auth gate carries the Response's status, e.g. 302),
|
|
44
|
+
* or dispatch()'s final response status. Absent for a normal render completion:
|
|
45
|
+
* the Response is built after match(), so match()/matchPartial() have no status
|
|
46
|
+
* to stamp there. Lets a sink split 3xx short-circuits from 2xx completions.
|
|
47
|
+
*/
|
|
48
|
+
status?: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export interface RequestErrorEvent extends BaseEvent {
|
|
@@ -320,7 +328,7 @@ export function createConsoleSink(): TelemetrySink {
|
|
|
320
328
|
break;
|
|
321
329
|
case "request.end":
|
|
322
330
|
console.log(
|
|
323
|
-
`[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}`,
|
|
331
|
+
`[telemetry] ${event.type} ${event.method} ${event.pathname} ${event.durationMs.toFixed(1)}ms segments=${event.segmentCount} cache=${event.cacheHit}${event.status !== undefined ? ` status=${event.status}` : ""}`,
|
|
324
332
|
);
|
|
325
333
|
break;
|
|
326
334
|
case "request.error":
|
package/src/router.ts
CHANGED
|
@@ -427,7 +427,10 @@ export function createRouter<TEnv = any>(
|
|
|
427
427
|
const reqCtx = _getRequestContext();
|
|
428
428
|
const enabled = debugPerformance || !!reqCtx?._debugPerformance;
|
|
429
429
|
if (!enabled || !reqCtx) return undefined;
|
|
430
|
-
|
|
430
|
+
// Anchor a mid-request store to the true request entry (reqCtx._handlerStart),
|
|
431
|
+
// not this call's performance.now(); undefined falls back to now() inside
|
|
432
|
+
// createMetricsStore (metrics.ts).
|
|
433
|
+
reqCtx._metricsStore ??= createMetricsStore(true, reqCtx._handlerStart);
|
|
431
434
|
return reqCtx._metricsStore;
|
|
432
435
|
};
|
|
433
436
|
|
|
@@ -438,11 +441,6 @@ export function createRouter<TEnv = any>(
|
|
|
438
441
|
const findNearestNotFoundBoundary = (entry: EntryData | null) =>
|
|
439
442
|
findNotFoundBoundary(entry, defaultNotFoundBoundary);
|
|
440
443
|
|
|
441
|
-
// Helper to get handleStore from request context
|
|
442
|
-
const getHandleStore = (): HandleStore | undefined => {
|
|
443
|
-
return _getRequestContext()?._handleStore;
|
|
444
|
-
};
|
|
445
|
-
|
|
446
444
|
// Track a pending handler promise (non-blocking).
|
|
447
445
|
// Attaches a side-effect .catch() to report streaming handler errors to onError
|
|
448
446
|
// without altering the rejection chain (React's streaming error boundary still handles it).
|
|
@@ -453,13 +451,14 @@ export function createRouter<TEnv = any>(
|
|
|
453
451
|
segmentType?: string;
|
|
454
452
|
},
|
|
455
453
|
): Promise<T> => {
|
|
456
|
-
|
|
454
|
+
// One ALS read serves both the store lookup and the onError closure.
|
|
455
|
+
const reqCtx = _getRequestContext();
|
|
456
|
+
const store = reqCtx?._handleStore;
|
|
457
457
|
const tracked = store ? store.track(promise) : promise;
|
|
458
458
|
|
|
459
459
|
// Report streaming handler errors to onError as a side-effect.
|
|
460
460
|
// The rejection still propagates to the RSC stream for client error boundaries.
|
|
461
461
|
// Captures request context eagerly (closure) so the catch handler has full context.
|
|
462
|
-
const reqCtx = _getRequestContext();
|
|
463
462
|
if (reqCtx && onError) {
|
|
464
463
|
tracked.catch((error) => {
|
|
465
464
|
callOnError(error, "handler", {
|
package/src/rsc/handler.ts
CHANGED
|
@@ -466,6 +466,11 @@ export function createRSCHandler<
|
|
|
466
466
|
stateCookieName: router.resolvedStateCookieName,
|
|
467
467
|
version,
|
|
468
468
|
});
|
|
469
|
+
// Thread the true request entry timestamp onto the context so a metrics
|
|
470
|
+
// store created MID-request (ctx.debugPerformance() / getMetricsStore) anchors
|
|
471
|
+
// to the real start, not the opt-in moment — phases that began earlier then
|
|
472
|
+
// report non-negative offsets. Set unconditionally: debug may be enabled later.
|
|
473
|
+
requestContext._handlerStart = handlerStart;
|
|
469
474
|
if (earlyMetricsStore) {
|
|
470
475
|
requestContext._debugPerformance = true;
|
|
471
476
|
requestContext._metricsStore = earlyMetricsStore;
|
|
@@ -573,8 +578,10 @@ export function createRSCHandler<
|
|
|
573
578
|
if (metricsStore) {
|
|
574
579
|
// When the store was created at handler start (earlyMetricsStore),
|
|
575
580
|
// handler:total covers the full request. When ctx.debugPerformance()
|
|
576
|
-
// created the store mid-request
|
|
577
|
-
//
|
|
581
|
+
// created the store mid-request its requestStart is now the threaded
|
|
582
|
+
// _handlerStart (== handlerStart), so both branches yield the true
|
|
583
|
+
// request entry; reading the store's own anchor keeps this correct even
|
|
584
|
+
// if a store ever lands without the threading (falls back to its start).
|
|
578
585
|
const totalStart = earlyMetricsStore
|
|
579
586
|
? handlerStart
|
|
580
587
|
: metricsStore.requestStart;
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveSameOriginRedirect,
|
|
39
39
|
resolveExternalRedirect,
|
|
40
40
|
isExternalRedirect,
|
|
41
|
+
safeSameOriginLanding,
|
|
41
42
|
EXTERNAL_REDIRECT_MARKER,
|
|
42
43
|
} from "../redirect-origin.js";
|
|
43
44
|
import { carryOverRedirectHeaders } from "./helpers.js";
|
|
@@ -79,7 +80,7 @@ export function guardOutgoingRedirect(
|
|
|
79
80
|
|
|
80
81
|
// Cross-origin (or unsafe-scheme external): neutralize to a safe same-origin
|
|
81
82
|
// landing.
|
|
82
|
-
const safeTarget = basename
|
|
83
|
+
const safeTarget = safeSameOriginLanding(basename);
|
|
83
84
|
if (process.env.NODE_ENV !== "production") {
|
|
84
85
|
console.error(
|
|
85
86
|
`[rango] Blocked cross-origin redirect to "${location}"; sent to ` +
|