@rangojs/router 0.0.0-experimental.143 → 0.0.0-experimental.145
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 +24 -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 +63 -33
- package/skills/rango/SKILL.md +10 -0
- package/skills/use-cache/SKILL.md +12 -2
- package/src/browser/logging.ts +18 -0
- package/src/browser/partial-update.ts +7 -0
- package/src/browser/rsc-router.tsx +43 -0
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +41 -51
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +58 -20
- package/src/cache/document-cache.ts +17 -11
- package/src/cache/types.ts +18 -4
- package/src/cache/vercel/vercel-cache-store.ts +15 -20
- 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/segment-resolution/loader-cache.ts +19 -3
- package/src/router/segment-resolution/loader-mask.ts +4 -11
- package/src/router/segment-resolution/loader-snapshot.ts +14 -6
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- 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 +122 -18
- package/src/rsc/shell-capture.ts +125 -20
- package/src/rsc/shell-serve.ts +37 -6
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +90 -6
- 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 +160 -113
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/testing/dispatch.ts +7 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +64 -12
- package/src/vite/router-discovery.ts +9 -1
package/src/cache/types.ts
CHANGED
|
@@ -225,10 +225,13 @@ export interface CacheItemResult {
|
|
|
225
225
|
* One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
|
|
226
226
|
* and React's postponed state — because the pair is version- and
|
|
227
227
|
* generation-coupled and must never be mixed across a React upgrade or a build
|
|
228
|
-
* change. The reactVersion
|
|
229
|
-
* shell-
|
|
230
|
-
*
|
|
231
|
-
*
|
|
228
|
+
* change. The reactVersion and buildVersion fields are the read-time gates that
|
|
229
|
+
* enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
|
|
230
|
+
* whose reactVersion differs from the running React, or whose buildVersion
|
|
231
|
+
* differs from the running build, as a miss (the postponed blob encodes hole
|
|
232
|
+
* positions against one exact tree; resuming it against a different React or a
|
|
233
|
+
* different app build tree-mismatches inside resume(), AFTER the 200 + prelude
|
|
234
|
+
* are committed — an unrecoverable broken serve).
|
|
232
235
|
*/
|
|
233
236
|
export interface ShellCacheEntry {
|
|
234
237
|
/** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
|
|
@@ -240,6 +243,17 @@ export interface ShellCacheEntry {
|
|
|
240
243
|
postponed: string | null;
|
|
241
244
|
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
242
245
|
reactVersion: string;
|
|
246
|
+
/**
|
|
247
|
+
* Build version captured at prerender time (the RSC handler's `version` —
|
|
248
|
+
* the `@rangojs/router:version` build stamp by default, bumped per build and
|
|
249
|
+
* on dev RSC-module edits). The second read-time gate: a persistent shared
|
|
250
|
+
* store (KV/runtime-cache) survives deploys, and an app-code change that
|
|
251
|
+
* keeps the same React version would otherwise leave a stale-build
|
|
252
|
+
* prelude+postponed live under the same key. Optional only for entries
|
|
253
|
+
* stored before the field existed — those are treated as a miss and the
|
|
254
|
+
* recapture re-stamps them (pre-release, no compat shim).
|
|
255
|
+
*/
|
|
256
|
+
buildVersion?: string;
|
|
243
257
|
/**
|
|
244
258
|
* The initialTheme the CAPTURE render was built with (the derived context's
|
|
245
259
|
* reqCtx.theme). The resume tail must render ThemeProvider with the SAME
|
|
@@ -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()`
|
|
@@ -168,6 +177,8 @@ interface VercelShellEnvelope {
|
|
|
168
177
|
po: string | null;
|
|
169
178
|
/** React.version at capture. */
|
|
170
179
|
rv: string;
|
|
180
|
+
/** Build version at capture (ShellCacheEntry.buildVersion). */
|
|
181
|
+
bv?: string;
|
|
171
182
|
/** createdAt (ms since epoch). */
|
|
172
183
|
c: number;
|
|
173
184
|
/** staleAt (ms since epoch). */
|
|
@@ -277,25 +288,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
277
288
|
return typeof value === "object" && value !== null;
|
|
278
289
|
}
|
|
279
290
|
|
|
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
291
|
/**
|
|
300
292
|
* Vercel Runtime Cache-backed segment cache store.
|
|
301
293
|
*
|
|
@@ -786,6 +778,7 @@ export class VercelCacheStore<
|
|
|
786
778
|
prelude: env.p,
|
|
787
779
|
postponed: env.po,
|
|
788
780
|
reactVersion: env.rv,
|
|
781
|
+
buildVersion: env.bv,
|
|
789
782
|
initialTheme: env.i,
|
|
790
783
|
snapshot: env.sn,
|
|
791
784
|
createdAt: env.c,
|
|
@@ -815,6 +808,7 @@ export class VercelCacheStore<
|
|
|
815
808
|
p: entry.prelude,
|
|
816
809
|
po: entry.postponed,
|
|
817
810
|
rv: entry.reactVersion,
|
|
811
|
+
bv: entry.buildVersion,
|
|
818
812
|
c: entry.createdAt,
|
|
819
813
|
s: staleAt,
|
|
820
814
|
e: expiresAt,
|
|
@@ -1101,7 +1095,7 @@ export class VercelCacheStore<
|
|
|
1101
1095
|
|
|
1102
1096
|
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1103
1097
|
if (!isRecord(raw)) return null;
|
|
1104
|
-
const { p, po, rv, c, s, e, t, i, sn } = raw;
|
|
1098
|
+
const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
|
|
1105
1099
|
if (typeof p !== "string" || typeof rv !== "string") return null;
|
|
1106
1100
|
if (po !== null && typeof po !== "string") return null;
|
|
1107
1101
|
if (typeof c !== "number") return null;
|
|
@@ -1110,6 +1104,7 @@ export class VercelCacheStore<
|
|
|
1110
1104
|
p,
|
|
1111
1105
|
po: po as string | null,
|
|
1112
1106
|
rv,
|
|
1107
|
+
bv: typeof bv === "string" ? bv : undefined,
|
|
1113
1108
|
c,
|
|
1114
1109
|
s,
|
|
1115
1110
|
e,
|
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
|
};
|
|
@@ -36,7 +36,10 @@ import {
|
|
|
36
36
|
DEFAULT_ROUTE_TTL,
|
|
37
37
|
} from "../../cache/cache-policy.js";
|
|
38
38
|
import { readThroughItem } from "../../cache/read-through-swr.js";
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
maskNestedContainerThenables,
|
|
41
|
+
overlayLoaderContainer,
|
|
42
|
+
} from "./loader-snapshot.js";
|
|
40
43
|
import { recordRequestTags } from "../../cache/cache-tag.js";
|
|
41
44
|
import {
|
|
42
45
|
isShellCaptureActive,
|
|
@@ -162,8 +165,21 @@ export function resolveLoaderData<TEnv>(
|
|
|
162
165
|
// never as an unhandled rejection that can kill the worker before the
|
|
163
166
|
// drain probes this record.
|
|
164
167
|
containerPromise.catch(() => {});
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
// Nested-promise SHAPE is the liveness declaration: mask nested thenables
|
|
169
|
+
// in the capture's copy of the container so the consuming subtree
|
|
170
|
+
// postpones as a hole no matter when the promise settles, elide records a
|
|
171
|
+
// HOLE marker, and every HIT streams the fresh value. Without this, a
|
|
172
|
+
// nested promise that settled before the quiet window baked its value into
|
|
173
|
+
// the SHARED shell and the snapshot pinned it for every visitor
|
|
174
|
+
// (per-request basket data served cross-session, found live). The raw
|
|
175
|
+
// container is untouched: handler-side ctx.use consumption (the
|
|
176
|
+
// consumption-lane rule, semantic-matrix PPR3) keeps real values.
|
|
177
|
+
const maskedPromise = containerPromise.then((container: unknown) =>
|
|
178
|
+
maskNestedContainerThenables(container),
|
|
179
|
+
);
|
|
180
|
+
maskedPromise.catch(() => {});
|
|
181
|
+
reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
|
|
182
|
+
return maskedPromise;
|
|
167
183
|
}
|
|
168
184
|
|
|
169
185
|
if (bakeSegmentKey) {
|
|
@@ -36,17 +36,10 @@ export function isShellCaptureActive(
|
|
|
36
36
|
return reqCtx?._shellCaptureRun === true;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
* shared shell. The capture abort (`maxWaitMs` in captureShellHTML) bounds how
|
|
44
|
-
* long the prerender waits before it freezes the prelude, so this never hangs the
|
|
45
|
-
* request.
|
|
46
|
-
*/
|
|
47
|
-
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
48
|
-
return new Promise<T>(() => {});
|
|
49
|
-
}
|
|
39
|
+
// createMaskedLoaderPromise moved to the leaf module mask-nested.ts (shared
|
|
40
|
+
// with the handle-push funnel in request-context, which cannot import THIS
|
|
41
|
+
// module without a cycle). Re-exported to keep the mask API in one place.
|
|
42
|
+
export { createMaskedLoaderPromise } from "./mask-nested.js";
|
|
50
43
|
|
|
51
44
|
/**
|
|
52
45
|
* Lane decision for an entry's loaders under PPR (the loading() value decides;
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
* payload byte-identical to that frozen prelude, the capture pins the container
|
|
9
9
|
* in the shell snapshot:
|
|
10
10
|
*
|
|
11
|
-
* - elide: deep-walk the settled container; a
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
11
|
+
* - elide: deep-walk the settled container; a PENDING nested promise is a
|
|
12
|
+
* hole, replaced by {@link LOADER_HOLE_KEY} — and since
|
|
13
|
+
* loader-cache masks every nested thenable at capture
|
|
14
|
+
* ({@link maskNestedContainerThenables}), nested promises are
|
|
15
|
+
* ALWAYS pending here for new captures. The SETTLED-marker branch
|
|
16
|
+
* below is legacy: it fires only if a settled thenable reaches
|
|
17
|
+
* elide anyway, and the overlay keeps decoding SETTLED markers
|
|
18
|
+
* from pre-mask snapshots. The result is promise-free and
|
|
17
19
|
* Flight-serializable.
|
|
18
20
|
* - overlay: on a HIT the loader runs fresh (only the loader body can mint
|
|
19
21
|
* the live nested promises), then the recorded container is laid
|
|
@@ -28,6 +30,12 @@
|
|
|
28
30
|
|
|
29
31
|
import { isThenable } from "../../handles/is-thenable.js";
|
|
30
32
|
|
|
33
|
+
// Capture-side nested-thenable masking lives in the LEAF module mask-nested.ts
|
|
34
|
+
// (request-context also needs it for handle pushes and cannot import through
|
|
35
|
+
// loader-mask without a cycle). Re-exported here so loader-cache and the unit
|
|
36
|
+
// tests keep one import site for the snapshot family.
|
|
37
|
+
export { maskNestedContainerThenables } from "./mask-nested.js";
|
|
38
|
+
|
|
31
39
|
/**
|
|
32
40
|
* Marker object standing in for a pending nested promise in a recorded loader
|
|
33
41
|
* container. Shape-checked (not identity-checked) because the record round-trips
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-side nested-thenable masking — the mechanism behind "nested-promise
|
|
3
|
+
* shape is the liveness declaration" for BOTH bake-lane loader containers
|
|
4
|
+
* (loader-cache.ts) and pushed handle containers (request-context.ts
|
|
5
|
+
* createUseFunction).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a LEAF module: request-context needs the mask for handle
|
|
8
|
+
* pushes, and loader-mask (the other natural home) imports request-context —
|
|
9
|
+
* importing from there would cycle. This module imports only is-thenable.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isThenable } from "../../handles/is-thenable.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A promise that never settles — the masked stand-in for a per-request value
|
|
16
|
+
* during shell capture. The consuming Suspense subtree suspends forever, so
|
|
17
|
+
* the static prerender postpones it as a hole instead of baking a per-request
|
|
18
|
+
* value into the shared shell. The capture abort (`maxWaitMs` in
|
|
19
|
+
* captureShellHTML) bounds how long the prerender waits before it freezes the
|
|
20
|
+
* prelude, so this never hangs the request.
|
|
21
|
+
*/
|
|
22
|
+
export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
|
|
23
|
+
return new Promise<T>(() => {});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
if (typeof value !== "object" || value === null) return false;
|
|
28
|
+
const proto = Object.getPrototypeOf(value);
|
|
29
|
+
return proto === Object.prototype || proto === null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deep-copy a container with every NESTED thenable replaced by a masked
|
|
34
|
+
* (never-resolving) promise. Applied during shell capture to (a) bake-lane
|
|
35
|
+
* loader containers (loader-cache.ts) and (b) pushed handle containers
|
|
36
|
+
* (createUseFunction) — the two rango-owned funnels where consumers declare
|
|
37
|
+
* per-request data by promise SHAPE.
|
|
38
|
+
*
|
|
39
|
+
* Why: a nested promise that happened to SETTLE before the capture's quiet
|
|
40
|
+
* window closed used to bake its value into the SHARED shell (and, for
|
|
41
|
+
* loaders, the snapshot pinned it for every HIT) — per-request data frozen
|
|
42
|
+
* and served cross-session (found live: a storefront basket, carrying the
|
|
43
|
+
* capturing session's basketId/customer identifiers, served to anonymous
|
|
44
|
+
* visitors). The window waits for the slowest shared material on the page, so
|
|
45
|
+
* any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
|
|
46
|
+
* Masking makes the consuming subtree postpone as a hole no matter when the
|
|
47
|
+
* promise settles: liveness by declaration, not by racing the window.
|
|
48
|
+
*
|
|
49
|
+
* Only plain objects/arrays are traversed; other values are leaves. The INPUT
|
|
50
|
+
* IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
|
|
51
|
+
* rule, semantic-matrix PPR3) shares the raw container and must keep real
|
|
52
|
+
* values. Cycles are preserved as cycles in the copy.
|
|
53
|
+
*/
|
|
54
|
+
export function maskNestedContainerThenables(
|
|
55
|
+
value: unknown,
|
|
56
|
+
seen: Map<object, unknown> = new Map(),
|
|
57
|
+
): unknown {
|
|
58
|
+
if (isThenable(value)) return createMaskedLoaderPromise();
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(value)) {
|
|
61
|
+
const cached = seen.get(value);
|
|
62
|
+
if (cached !== undefined) return cached;
|
|
63
|
+
const out: unknown[] = new Array(value.length);
|
|
64
|
+
seen.set(value, out);
|
|
65
|
+
for (let i = 0; i < value.length; i++) {
|
|
66
|
+
out[i] = maskNestedContainerThenables(value[i], seen);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isPlainObject(value)) {
|
|
72
|
+
const cached = seen.get(value);
|
|
73
|
+
if (cached !== undefined) return cached;
|
|
74
|
+
const out: Record<string, unknown> = {};
|
|
75
|
+
seen.set(value, out);
|
|
76
|
+
for (const key of Object.keys(value)) {
|
|
77
|
+
out[key] = maskNestedContainerThenables(value[key], seen);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return value;
|
|
83
|
+
}
|
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 ` +
|