@rangojs/router 0.0.0-experimental.146 → 0.0.0-experimental.147
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/bin/rango.js +7 -0
- package/dist/vite/index.js +807 -235
- package/package.json +6 -1
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +11 -2
- package/src/cache/cf/cf-cache-store.ts +23 -0
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/types.ts +14 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/prerender/build-shell-capture.ts +237 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/prerender-match.ts +21 -0
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/rsc-rendering.ts +82 -23
- package/src/rsc/shell-build-manifest.ts +244 -0
- package/src/rsc/shell-capture.ts +100 -39
- package/src/segment-fragments.ts +124 -0
- package/src/server/request-context.ts +65 -11
- package/src/ssr/index.tsx +47 -9
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +395 -0
- package/src/vite/discovery/state.ts +42 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/rango.ts +1 -0
- package/src/vite/router-discovery.ts +292 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build shell manifest key, shared by the producer (the build's shell
|
|
3
|
+
* prerender phase, vite/discovery/shell-prerender-phase.ts) and the consumer
|
|
4
|
+
* (the runtime read-through, rsc/shell-build-manifest.ts) so the format
|
|
5
|
+
* cannot drift. Dependency-free: the producer runs node-side in the plugin,
|
|
6
|
+
* the consumer in the RSC runtime.
|
|
7
|
+
*
|
|
8
|
+
* PATHNAME-ONLY by design. Host-free because the build knows no request
|
|
9
|
+
* host. Router-free because the router id ($$id) is a hash of
|
|
10
|
+
* filePath:lineNumber of the TRANSFORMED source, which differs between the
|
|
11
|
+
* discovery temp server's dev-style transform chain and the main build's —
|
|
12
|
+
* a temp-realm router id can never be looked up by the shipped worker. The
|
|
13
|
+
* producer instead detects pathname collisions across routers at build time
|
|
14
|
+
* and declines both entries (loudly), keeping the key unambiguous. This is
|
|
15
|
+
* a manifest namespace, never a store keyspace: the runtime shell key
|
|
16
|
+
* (host + pathname + search + ":shell") stays untouched.
|
|
17
|
+
*/
|
|
18
|
+
export function buildShellManifestKey(pathname: string): string {
|
|
19
|
+
return pathname;
|
|
20
|
+
}
|
package/src/prerender/store.ts
CHANGED
|
@@ -73,7 +73,16 @@ export function createDevPrerenderStore(devUrl: string): PrerenderStore {
|
|
|
73
73
|
if (isIntercept) url += "&intercept=1";
|
|
74
74
|
if (meta.isPassthroughRoute) url += "&passthrough=1";
|
|
75
75
|
try {
|
|
76
|
-
|
|
76
|
+
// Bounded: this fetch also runs inside the PPR shell capture (a
|
|
77
|
+
// workerd waitUntil task), where an unsettled fetch does not reject —
|
|
78
|
+
// it pends until the task is torn down. Unbounded, one pending fetch
|
|
79
|
+
// wedged the per-isolate capture queue on GH runners (rotating dev
|
|
80
|
+
// capture failures; endpoint instrumentation showed 3-4ms memo HITs,
|
|
81
|
+
// so latency was never the issue — settlement was). On timeout the
|
|
82
|
+
// catch degrades to a store miss and the pipeline falls through to
|
|
83
|
+
// the live handler render (the documented dev fall-through), so the
|
|
84
|
+
// capture still lands with live content.
|
|
85
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
77
86
|
if (!res.ok) return null;
|
|
78
87
|
return res.json();
|
|
79
88
|
} catch {
|
|
@@ -112,6 +112,9 @@ let prerenderStoreInstance: PrerenderStore | null | undefined;
|
|
|
112
112
|
let _deserializeSegments:
|
|
113
113
|
| typeof import("../../cache/segment-codec.js").deserializeSegments
|
|
114
114
|
| undefined;
|
|
115
|
+
let _fragmentSegments:
|
|
116
|
+
| typeof import("../../cache/segment-codec.js").fragmentSegments
|
|
117
|
+
| undefined;
|
|
115
118
|
let _restoreHandles:
|
|
116
119
|
| typeof import("../../cache/handle-snapshot.js").restoreHandles
|
|
117
120
|
| undefined;
|
|
@@ -135,6 +138,7 @@ async function ensurePrerenderDeps() {
|
|
|
135
138
|
import("../../prerender/store.js"),
|
|
136
139
|
]);
|
|
137
140
|
_deserializeSegments = codec.deserializeSegments;
|
|
141
|
+
_fragmentSegments = codec.fragmentSegments;
|
|
138
142
|
_restoreHandles = snapshot.restoreHandles;
|
|
139
143
|
_decodeHandles = snapshot.decodeHandles;
|
|
140
144
|
_hashParams = paramHash.hashParams;
|
|
@@ -237,6 +241,7 @@ async function* yieldFromStore<TEnv>(
|
|
|
237
241
|
|
|
238
242
|
if (
|
|
239
243
|
!_deserializeSegments ||
|
|
244
|
+
!_fragmentSegments ||
|
|
240
245
|
!_restoreHandles ||
|
|
241
246
|
!_decodeHandles ||
|
|
242
247
|
!_hashParams ||
|
|
@@ -245,7 +250,13 @@ async function* yieldFromStore<TEnv>(
|
|
|
245
250
|
throw new Error("yieldFromStore called before ensurePrerenderDeps");
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
|
|
253
|
+
// Shell-HIT tail (issue #700): a Prerender+ppr route's tail serves from THIS
|
|
254
|
+
// store (the prerender lookup runs before the cache scope), so the fragment
|
|
255
|
+
// splice must apply here too — otherwise producer B entries re-serialize the
|
|
256
|
+
// whole tree per request while producer A entries do not.
|
|
257
|
+
const segments = _getRequestContext()?._shellFragmentPayload
|
|
258
|
+
? await _fragmentSegments(entry.segments)
|
|
259
|
+
: await _deserializeSegments(entry.segments);
|
|
249
260
|
|
|
250
261
|
// Replay handle data (same as runtime cache hit path). entry.handles is a
|
|
251
262
|
// Flight-encoded string ("" when none) — decode before restore so
|
|
@@ -21,6 +21,7 @@ import type { RouterContext } from "./router-context.js";
|
|
|
21
21
|
import type { ResolveSegmentOptions } from "./segment-resolution.js";
|
|
22
22
|
import { runWithRouterContext } from "./router-context.js";
|
|
23
23
|
import type { EntryData, InterceptEntry } from "../server/context";
|
|
24
|
+
import type { PartialPrerenderProps } from "../urls/pattern-types.js";
|
|
24
25
|
import type {
|
|
25
26
|
HandlerContext,
|
|
26
27
|
InternalHandlerContext,
|
|
@@ -74,6 +75,13 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
74
75
|
* the sinks store it as-is (no longer merge raw records). */
|
|
75
76
|
interceptHandles?: string;
|
|
76
77
|
passthrough?: true;
|
|
78
|
+
/**
|
|
79
|
+
* The matched route entry's `ppr` path option, surfaced so the build
|
|
80
|
+
* collection can flag Prerender+ppr routes as build-time shell candidates
|
|
81
|
+
* (issue #699 producer B). Runtime-only today; the build otherwise never
|
|
82
|
+
* sees the option (it lives on EntryData, not the trie).
|
|
83
|
+
*/
|
|
84
|
+
ppr?: boolean | PartialPrerenderProps;
|
|
77
85
|
} | null> {
|
|
78
86
|
// 1. Find the matching route entry
|
|
79
87
|
const matched = await deps.findMatch(pathname);
|
|
@@ -306,6 +314,18 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
306
314
|
// Use the trie-level route key (e.g., "docs", "docs.article")
|
|
307
315
|
const routeName = matched.routeKey;
|
|
308
316
|
|
|
317
|
+
// Surface the matched route entry's ppr option for the build collection
|
|
318
|
+
// (leaf-first: the deepest type:"route" entry in the ancestor chain is
|
|
319
|
+
// the matched page route; ancestors are layouts/includes).
|
|
320
|
+
let routePpr: boolean | PartialPrerenderProps | undefined;
|
|
321
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
322
|
+
const e = entries[i]!;
|
|
323
|
+
if (e.type === "route") {
|
|
324
|
+
routePpr = e.ppr;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
309
329
|
// 14. Resolve intercept segments for this route (if any ancestor defines
|
|
310
330
|
// an intercept targeting this route). At build time we skip when()
|
|
311
331
|
// evaluation -- we pre-render all intercepts unconditionally and let
|
|
@@ -420,6 +440,7 @@ export async function matchForPrerender<TEnv = any>(
|
|
|
420
440
|
params: matchedParams,
|
|
421
441
|
interceptSegments,
|
|
422
442
|
interceptHandles,
|
|
443
|
+
ppr: routePpr,
|
|
423
444
|
};
|
|
424
445
|
});
|
|
425
446
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-isolate shell-capture serialization.
|
|
3
|
+
*
|
|
4
|
+
* Captures are CPU-bound background renders whose quiet detection is
|
|
5
|
+
* task-quantized (FLIGHT_QUIET_HOPS macrotask hops with zero new bytes). Two
|
|
6
|
+
* captures running concurrently starve each other: one grinding capture —
|
|
7
|
+
* e.g. a prerender+ppr route whose capture round-trips the dev
|
|
8
|
+
* /__rsc_prerender endpoint, whose per-request module re-import can peg a
|
|
9
|
+
* slow CI runner for seconds — keeps the sibling's render byte-silent past
|
|
10
|
+
* its abort budget, so the sibling freezes a trivial prelude and stores
|
|
11
|
+
* nothing. Observed on GH runners as ROTATING eternal-MISS victims (the
|
|
12
|
+
* warmup on one shard, a composition probe, then /ppr-shell?probe=stream once
|
|
13
|
+
* the first was quieted) while every local run passed.
|
|
14
|
+
*
|
|
15
|
+
* Serializing capture execution removes the cross-talk: each capture's quiet
|
|
16
|
+
* window observes only its own work. Captures are TTL-scale background work,
|
|
17
|
+
* so queueing costs latency-to-HIT only — never a served response. On workerd
|
|
18
|
+
* a queued capture rides the scheduling request's waitUntil, whose lifetime
|
|
19
|
+
* is bounded; a capture killed mid-queue by that bound simply recaptures on a
|
|
20
|
+
* later request (the existing best-effort contract).
|
|
21
|
+
*
|
|
22
|
+
* The chain link resolves in `finally` and the prior link is awaited with a
|
|
23
|
+
* swallow, so one rejected capture can never wedge every later one.
|
|
24
|
+
*/
|
|
25
|
+
let captureQueue: Promise<void> = Promise.resolve();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Upper bound on how long one queue link may hold the queue. A capture task
|
|
29
|
+
* normally settles well inside this (attempt + in-place retry + writes), but
|
|
30
|
+
* a task wedged on never-settling I/O — a workerd waitUntil fetch that pends
|
|
31
|
+
* instead of rejecting (seen on GH runners with the dev prerender store
|
|
32
|
+
* before its fetch was time-bounded) — must not block every later capture in
|
|
33
|
+
* the isolate. At the cap the QUEUE is released; the wedged task itself stays
|
|
34
|
+
* detached (its own per-key guards clean up when/if it settles).
|
|
35
|
+
*/
|
|
36
|
+
const QUEUE_LINK_CAP_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `task` after every previously enqueued capture has settled. Returns a
|
|
40
|
+
* promise for THIS task's completion (rejections propagate to the caller —
|
|
41
|
+
* the queue itself is insulated).
|
|
42
|
+
*/
|
|
43
|
+
export function enqueueSerializedCapture(
|
|
44
|
+
task: () => Promise<void>,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
const prior = captureQueue;
|
|
47
|
+
let releaseQueue!: () => void;
|
|
48
|
+
captureQueue = new Promise<void>((resolve) => {
|
|
49
|
+
releaseQueue = resolve;
|
|
50
|
+
});
|
|
51
|
+
return (async () => {
|
|
52
|
+
await prior.catch(() => {});
|
|
53
|
+
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
try {
|
|
55
|
+
await Promise.race([
|
|
56
|
+
task(),
|
|
57
|
+
new Promise<void>((resolve) => {
|
|
58
|
+
capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
|
|
59
|
+
(capTimer as { unref?: () => void }).unref?.();
|
|
60
|
+
}),
|
|
61
|
+
]);
|
|
62
|
+
} finally {
|
|
63
|
+
if (capTimer) clearTimeout(capTimer);
|
|
64
|
+
releaseQueue();
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
warnShellStoreMissingOnce,
|
|
45
45
|
warnPprNonceActiveOnce,
|
|
46
46
|
} from "./shell-serve.js";
|
|
47
|
+
import { lookupBuildShell } from "./shell-build-manifest.js";
|
|
47
48
|
import { contextGet } from "../context-var.js";
|
|
48
49
|
import {
|
|
49
50
|
resolveSameOriginRedirect,
|
|
@@ -172,6 +173,36 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
172
173
|
store,
|
|
173
174
|
debug: INTERNAL_RANGO_DEBUG,
|
|
174
175
|
};
|
|
176
|
+
// One serve funnel for BOTH entry sources (runtime store hit below,
|
|
177
|
+
// build-manifest hit further down): schedule the background
|
|
178
|
+
// recapture when asked, then commit the composed response.
|
|
179
|
+
const serveHit = (
|
|
180
|
+
entry: ShellCacheEntry,
|
|
181
|
+
revalidate: boolean | undefined,
|
|
182
|
+
): Response => {
|
|
183
|
+
if (revalidate) {
|
|
184
|
+
scheduleShellCapture(
|
|
185
|
+
ctx,
|
|
186
|
+
request,
|
|
187
|
+
env,
|
|
188
|
+
url,
|
|
189
|
+
reqCtx,
|
|
190
|
+
ssrModule,
|
|
191
|
+
descriptor,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return serveShellHit(
|
|
195
|
+
ctx,
|
|
196
|
+
request,
|
|
197
|
+
env,
|
|
198
|
+
url,
|
|
199
|
+
reqCtx,
|
|
200
|
+
handleStore,
|
|
201
|
+
ssrModule,
|
|
202
|
+
entry,
|
|
203
|
+
descriptor,
|
|
204
|
+
);
|
|
205
|
+
};
|
|
175
206
|
let cached: Awaited<ReturnType<typeof store.getShell>> = null;
|
|
176
207
|
try {
|
|
177
208
|
cached = await store.getShell(key);
|
|
@@ -196,30 +227,43 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
196
227
|
} else {
|
|
197
228
|
// Stale (SWR) hit: serve the stale shell now, recapture in the
|
|
198
229
|
// background (stampede-guarded + backoff inside scheduleShellCapture).
|
|
199
|
-
|
|
200
|
-
scheduleShellCapture(
|
|
201
|
-
ctx,
|
|
202
|
-
request,
|
|
203
|
-
env,
|
|
204
|
-
url,
|
|
205
|
-
reqCtx,
|
|
206
|
-
ssrModule,
|
|
207
|
-
descriptor,
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
return serveShellHit(
|
|
211
|
-
ctx,
|
|
212
|
-
request,
|
|
213
|
-
env,
|
|
214
|
-
url,
|
|
215
|
-
reqCtx,
|
|
216
|
-
handleStore,
|
|
217
|
-
ssrModule,
|
|
218
|
-
cached.entry,
|
|
219
|
-
descriptor,
|
|
220
|
-
);
|
|
230
|
+
return serveHit(cached.entry, cached.shouldRevalidate);
|
|
221
231
|
}
|
|
222
232
|
}
|
|
233
|
+
// Build-time shell read-through (producer B, #699): on a runtime
|
|
234
|
+
// store MISS (or an invalid/corrupt runtime entry), a Prerender+ppr
|
|
235
|
+
// route's shell was already produced at `vite build` — serve it
|
|
236
|
+
// through the SAME serveShellHit, so the first-ever request after a
|
|
237
|
+
// deploy is a HIT with zero runtime capture. lookupBuildShell owns
|
|
238
|
+
// every gate (search-less request, versions, integrity, tag
|
|
239
|
+
// markers) and fails to null — the ordinary MISS path below takes
|
|
240
|
+
// over. Past ppr.ttl the baked entry still serves but a runtime
|
|
241
|
+
// recapture is scheduled: SWR is the UPGRADE path from build entry
|
|
242
|
+
// to fresher runtime entry (the runtime store read above wins once
|
|
243
|
+
// the capture lands).
|
|
244
|
+
const buildHit = await lookupBuildShell(
|
|
245
|
+
url,
|
|
246
|
+
ctx.version,
|
|
247
|
+
store,
|
|
248
|
+
// Dev: no build manifest exists; producer B runs on demand via
|
|
249
|
+
// the dev server's /__rsc_shell endpoint for PRERENDERED routes
|
|
250
|
+
// only (production's exact candidate set). Folded away in
|
|
251
|
+
// production builds (NODE_ENV is a compile-time constant).
|
|
252
|
+
process.env.NODE_ENV !== "production"
|
|
253
|
+
? {
|
|
254
|
+
isPrerenderRoute:
|
|
255
|
+
reqCtx._classifiedRoute?.matched?.pr === true,
|
|
256
|
+
routeName: reqCtx._classifiedRoute?.routeKey,
|
|
257
|
+
ttl: pprConfig.ttl,
|
|
258
|
+
swr: pprConfig.swr,
|
|
259
|
+
tags: pprConfig.tags,
|
|
260
|
+
}
|
|
261
|
+
: undefined,
|
|
262
|
+
);
|
|
263
|
+
if (buildHit) {
|
|
264
|
+
// Past ppr.ttl: still serve the baked entry, recapture upgrades it.
|
|
265
|
+
return serveHit(buildHit.entry, buildHit.stale);
|
|
266
|
+
}
|
|
223
267
|
// MISS (no entry, invalid reactVersion, or store read failure): axis 1
|
|
224
268
|
// + a background capture scheduled once the response is known servable.
|
|
225
269
|
pprMiss = { descriptor, ssrModule };
|
|
@@ -609,9 +653,24 @@ function serveShellHit(
|
|
|
609
653
|
`[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
|
|
610
654
|
);
|
|
611
655
|
}
|
|
656
|
+
// Fragment splice (issue #700): cache/prerender-store hits inside THIS
|
|
657
|
+
// tail render emit their stored segment fragments verbatim into the
|
|
658
|
+
// payload; the SSR resume pass and browser hydration expand them
|
|
659
|
+
// (segment-fragments.ts). Tail-only: the flag lives on the derived
|
|
660
|
+
// context so it can never leak into a capture render (which serializes
|
|
661
|
+
// segments and must see real elements).
|
|
662
|
+
seededCtx._shellFragmentPayload = true;
|
|
612
663
|
return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
|
|
613
664
|
}
|
|
614
|
-
|
|
665
|
+
// No snapshot (e.g. a producer B entry whose capture hit only the
|
|
666
|
+
// prerender store): still a shell-HIT tail, so arm the fragment splice on
|
|
667
|
+
// a derived context — the tail's prerender-store/cache hits (if any) then
|
|
668
|
+
// splice; a route with neither serves exactly as before. Derived, never
|
|
669
|
+
// the shared reqCtx: scheduleShellCapture derives the capture context from
|
|
670
|
+
// reqCtx and the flag must not be inherited there.
|
|
671
|
+
const fragmentCtx: RequestContext<any> = Object.create(reqCtx);
|
|
672
|
+
fragmentCtx._shellFragmentPayload = true;
|
|
673
|
+
return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
|
|
615
674
|
})();
|
|
616
675
|
// The stream below is the only consumer; pre-attach a no-op catch so a tail
|
|
617
676
|
// failure before the stream is pulled never surfaces as an unhandled rejection.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time shell entry read-through (producer B, issue #699).
|
|
3
|
+
*
|
|
4
|
+
* The build stages one ShellCacheEntry per Prerender+ppr URL as a lazy
|
|
5
|
+
* manifest module injected into the RSC bundle
|
|
6
|
+
* (`globalThis.__loadShellManifestModule`, mirroring the prerender payload
|
|
7
|
+
* manifest — the worker handles every request; nothing is served from
|
|
8
|
+
* assets). The serve path consults this on a runtime shell-store MISS, so the
|
|
9
|
+
* FIRST request after a deploy is already a shell HIT with zero runtime
|
|
10
|
+
* capture. The worker cannot tell where an entry came from: a build hit is
|
|
11
|
+
* served through the same serveShellHit as a captured one.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* - No expiry until the next deploy — the buildVersion gate retires entries
|
|
15
|
+
* the moment a new build ships (a new manifest replaces them anyway).
|
|
16
|
+
* - `ppr.ttl` drives STALENESS ONLY: past createdAt + ttl the entry still
|
|
17
|
+
* serves, but a runtime recapture is scheduled — SWR is the UPGRADE path
|
|
18
|
+
* from build entry to fresher runtime entry, not the bootstrap path. The
|
|
19
|
+
* runtime store is consulted first, so a captured entry supersedes the
|
|
20
|
+
* build entry as soon as it lands.
|
|
21
|
+
* - `updateTag()` evicts build entries like runtime ones: the read-through
|
|
22
|
+
* validates the entry's baked tag union against the store's tag
|
|
23
|
+
* invalidation markers (isTagsInvalidatedSince) with the entry's createdAt
|
|
24
|
+
* as the reference instant. No tombstones — the manifest is immutable; the
|
|
25
|
+
* markers say whether it is still current.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
|
|
29
|
+
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
30
|
+
import { hasIntactShellPayload, isValidShellHit } from "./shell-serve.js";
|
|
31
|
+
import { buildShellManifestKey } from "../prerender/shell-manifest-key.js";
|
|
32
|
+
|
|
33
|
+
/** One baked manifest record (the __ps asset module's default export). */
|
|
34
|
+
export interface BuildShellEntry {
|
|
35
|
+
entry: ShellCacheEntry;
|
|
36
|
+
/** Resolved ppr ttl (seconds) — drives staleness/recapture, never expiry. */
|
|
37
|
+
ttl: number;
|
|
38
|
+
swr?: number;
|
|
39
|
+
/** The putShell-barrier tag union baked at build (static + recorded). */
|
|
40
|
+
tags?: string[];
|
|
41
|
+
routeName: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ShellManifestModule {
|
|
45
|
+
/** Manifest key (pathname — see shell-manifest-key.ts) -> asset specifier. */
|
|
46
|
+
default: Record<string, string>;
|
|
47
|
+
loadShellAsset: (spec: string) => Promise<{ default: BuildShellEntry }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
declare global {
|
|
51
|
+
// Injected into the built RSC entry by the shell prerender phase
|
|
52
|
+
// (vite/discovery/shell-prerender-phase.ts): lazy loader for the shell
|
|
53
|
+
// manifest module.
|
|
54
|
+
// eslint-disable-next-line no-var
|
|
55
|
+
var __loadShellManifestModule:
|
|
56
|
+
| (() => Promise<ShellManifestModule>)
|
|
57
|
+
| undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let manifestPromise: Promise<ShellManifestModule | null> | null = null;
|
|
61
|
+
|
|
62
|
+
function loadManifest(): Promise<ShellManifestModule | null> {
|
|
63
|
+
if (!manifestPromise) {
|
|
64
|
+
const loader = globalThis.__loadShellManifestModule;
|
|
65
|
+
if (!loader) return Promise.resolve(null);
|
|
66
|
+
// A failing import is memoized as absent: the module is a build artifact,
|
|
67
|
+
// so the failure is deterministic — retrying per request only re-pays it.
|
|
68
|
+
manifestPromise = loader().catch(() => null);
|
|
69
|
+
}
|
|
70
|
+
return manifestPromise;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-spec verdict memo for the version + integrity gates. A manifest record
|
|
75
|
+
* is immutable for the process lifetime (content-hashed asset module), so its
|
|
76
|
+
* gate verdict is constant — without this, EVERY request to a baked ppr route
|
|
77
|
+
* re-decodes the full prelude base64 (hasIntactShellPayload) on the hot path:
|
|
78
|
+
* a fresh build hit never populates the runtime store, so the store MISS +
|
|
79
|
+
* read-through is the steady state, not a warm-up. Only the per-request
|
|
80
|
+
* gates (tag markers, staleness) stay outside the memo. `null` memoizes a
|
|
81
|
+
* failed verdict — deterministically invalid, don't re-pay the decode.
|
|
82
|
+
* Spec-only keying is sound because buildVersion is process-constant on the
|
|
83
|
+
* manifest path (folded into the shipped worker; dev never loads a manifest).
|
|
84
|
+
*/
|
|
85
|
+
const validatedSpecs = new Map<string, BuildShellEntry | null>();
|
|
86
|
+
|
|
87
|
+
async function validatedManifestRecord(
|
|
88
|
+
mod: ShellManifestModule,
|
|
89
|
+
spec: string,
|
|
90
|
+
buildVersion: string,
|
|
91
|
+
): Promise<BuildShellEntry | undefined> {
|
|
92
|
+
let verdict = validatedSpecs.get(spec);
|
|
93
|
+
if (verdict === undefined) {
|
|
94
|
+
const record = (await mod.loadShellAsset(spec)).default;
|
|
95
|
+
verdict =
|
|
96
|
+
isValidShellHit(record.entry, buildVersion) &&
|
|
97
|
+
hasIntactShellPayload(record.entry)
|
|
98
|
+
? record
|
|
99
|
+
: null;
|
|
100
|
+
validatedSpecs.set(spec, verdict);
|
|
101
|
+
}
|
|
102
|
+
return verdict ?? undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reset the memoized manifest (unit tests swap the global loader). */
|
|
106
|
+
export function resetBuildShellManifestForTests(): void {
|
|
107
|
+
manifestPromise = null;
|
|
108
|
+
validatedSpecs.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Keys already warned about a tag-check-incapable store (once per key). */
|
|
112
|
+
const warnedTagCheckUnsupported = new Set<string>();
|
|
113
|
+
|
|
114
|
+
export interface BuildShellHit {
|
|
115
|
+
entry: ShellCacheEntry;
|
|
116
|
+
/** Past createdAt + ttl: serve, but schedule the runtime recapture. */
|
|
117
|
+
stale: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Dev-mode lookup context: there is no build manifest in dev, so producer B
|
|
122
|
+
* runs ON DEMAND through the Vite dev server's /__rsc_shell endpoint
|
|
123
|
+
* (memoized per router HMR generation), mirroring the dev prerender store's
|
|
124
|
+
* /__rsc_prerender flow. Only armed for PRERENDERED routes (matched.pr) —
|
|
125
|
+
* exactly production's candidate set; everything else keeps runtime capture.
|
|
126
|
+
*/
|
|
127
|
+
export interface DevShellLookup {
|
|
128
|
+
/** True when the classified route is trie-flagged pr (Prerender-backed). */
|
|
129
|
+
isPrerenderRoute: boolean;
|
|
130
|
+
/** Canonical route key of the classified route (endpoint verification). */
|
|
131
|
+
routeName: string | undefined;
|
|
132
|
+
/** Resolved ppr policy from the serve gate (the endpoint is policy-free). */
|
|
133
|
+
ttl: number;
|
|
134
|
+
swr?: number;
|
|
135
|
+
tags?: string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bound like the dev prerender store fetch (see #697): inside a workerd
|
|
140
|
+
* waitUntil an unsettled fetch pends forever instead of rejecting; on
|
|
141
|
+
* timeout this degrades to a MISS and the runtime capture path takes over.
|
|
142
|
+
* 20s (not the store fetch's 10s): the endpoint's response IS an inline
|
|
143
|
+
* capture — up to ~5s attempt + 400ms + ~5s cold-graph retry — and this
|
|
144
|
+
* fetch blocks a foreground document request, so it must outlast a full
|
|
145
|
+
* cold capture cycle rather than abort into a MISS at 10s.
|
|
146
|
+
*/
|
|
147
|
+
const DEV_SHELL_FETCH_TIMEOUT_MS = 20_000;
|
|
148
|
+
|
|
149
|
+
async function fetchDevShellEntry(
|
|
150
|
+
pathname: string,
|
|
151
|
+
buildVersion: string,
|
|
152
|
+
dev: DevShellLookup,
|
|
153
|
+
): Promise<BuildShellEntry | undefined> {
|
|
154
|
+
if (!dev.isPrerenderRoute || !dev.routeName) return undefined;
|
|
155
|
+
const devUrl = globalThis.__PRERENDER_DEV_URL;
|
|
156
|
+
if (!devUrl) return undefined;
|
|
157
|
+
const params = new URLSearchParams({
|
|
158
|
+
pathname,
|
|
159
|
+
routeName: dev.routeName,
|
|
160
|
+
ttl: String(dev.ttl),
|
|
161
|
+
version: buildVersion,
|
|
162
|
+
});
|
|
163
|
+
if (dev.swr !== undefined) params.set("swr", String(dev.swr));
|
|
164
|
+
if (dev.tags && dev.tags.length > 0) params.set("tags", dev.tags.join(","));
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${devUrl}/__rsc_shell?${params}`, {
|
|
167
|
+
signal: AbortSignal.timeout(DEV_SHELL_FETCH_TIMEOUT_MS),
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) return undefined;
|
|
170
|
+
return (await res.json()) as BuildShellEntry;
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Look up the baked shell entry for a request, applying every serve gate:
|
|
178
|
+
* search-less requests only (the build captured the bare pathname; a
|
|
179
|
+
* search-bearing URL has its own shell identity owned by runtime capture),
|
|
180
|
+
* version validity, payload integrity, and tag-invalidation markers. Returns
|
|
181
|
+
* null on any gate failure — the caller degrades to the ordinary MISS path
|
|
182
|
+
* (axis 1 + runtime capture), never a broken serve.
|
|
183
|
+
*/
|
|
184
|
+
export async function lookupBuildShell(
|
|
185
|
+
url: URL,
|
|
186
|
+
buildVersion: string,
|
|
187
|
+
store: SegmentCacheStore,
|
|
188
|
+
dev?: DevShellLookup,
|
|
189
|
+
): Promise<BuildShellHit | null> {
|
|
190
|
+
try {
|
|
191
|
+
// Source-presence first: with no manifest and no dev context this is the
|
|
192
|
+
// steady-state MISS shape for every non-baked ppr route — return before
|
|
193
|
+
// the searchParams sort/allocation below.
|
|
194
|
+
const hasManifest = globalThis.__loadShellManifestModule !== undefined;
|
|
195
|
+
if (!hasManifest && !dev) return null;
|
|
196
|
+
if (sortedSearchString(url.searchParams) !== "") return null;
|
|
197
|
+
let record: BuildShellEntry | undefined;
|
|
198
|
+
if (hasManifest) {
|
|
199
|
+
const mod = await loadManifest();
|
|
200
|
+
if (!mod) return null;
|
|
201
|
+
const spec = mod.default[buildShellManifestKey(url.pathname)];
|
|
202
|
+
if (!spec) return null;
|
|
203
|
+
record = await validatedManifestRecord(mod, spec, buildVersion);
|
|
204
|
+
} else if (dev) {
|
|
205
|
+
const fetched = await fetchDevShellEntry(url.pathname, buildVersion, dev);
|
|
206
|
+
record =
|
|
207
|
+
fetched !== undefined &&
|
|
208
|
+
isValidShellHit(fetched.entry, buildVersion) &&
|
|
209
|
+
hasIntactShellPayload(fetched.entry)
|
|
210
|
+
? fetched
|
|
211
|
+
: undefined;
|
|
212
|
+
}
|
|
213
|
+
if (!record) return null;
|
|
214
|
+
const entry = record.entry;
|
|
215
|
+
if (record.tags && record.tags.length > 0) {
|
|
216
|
+
const check = store.isTagsInvalidatedSince;
|
|
217
|
+
if (typeof check !== "function") {
|
|
218
|
+
// A tagged build entry on a store that cannot answer "was this tag
|
|
219
|
+
// invalidated since the build" must not serve: updateTag() could
|
|
220
|
+
// never evict it. Declared intent that cannot be honored deserves a
|
|
221
|
+
// diagnostic; the route keeps runtime-capture semantics.
|
|
222
|
+
const key = buildShellManifestKey(url.pathname);
|
|
223
|
+
if (!warnedTagCheckUnsupported.has(key)) {
|
|
224
|
+
warnedTagCheckUnsupported.add(key);
|
|
225
|
+
console.warn(
|
|
226
|
+
`[rango] Build-time shell for "${url.pathname}" carries cache tags, but ` +
|
|
227
|
+
"the app cache store does not implement isTagsInvalidatedSince(), so " +
|
|
228
|
+
"updateTag() could not evict it. The entry is not served; the route " +
|
|
229
|
+
"keeps runtime shell capture. Use MemorySegmentCacheStore, CFCacheStore, " +
|
|
230
|
+
"or VercelCacheStore (or add the method to your custom store).",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (await check.call(store, record.tags, entry.createdAt)) return null;
|
|
236
|
+
}
|
|
237
|
+
const stale = Date.now() >= entry.createdAt + record.ttl * 1000;
|
|
238
|
+
return { entry, stale };
|
|
239
|
+
} catch {
|
|
240
|
+
// Any read-through fault is a MISS, never a 500 — the ordinary axis-1 +
|
|
241
|
+
// runtime-capture path takes over.
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|