@rangojs/router 0.0.0-experimental.145 → 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.
Files changed (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. package/src/vite/utils/shared-utils.ts +4 -2
@@ -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
- if (cached.shouldRevalidate) {
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 };
@@ -493,7 +537,13 @@ function serveShellHit(
493
537
  const renderTail = async (
494
538
  activeCtx: RequestContext<any>,
495
539
  ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
540
+ const matchStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
496
541
  const match = await ctx.router.match(request, { env });
542
+ if (INTERNAL_RANGO_DEBUG) {
543
+ console.log(
544
+ `[Server][ppr] shell HIT: tail match done +${Math.round(performance.now() - matchStart)}ms (abs ${Math.round(performance.now())}, started ${Math.round(matchStart)})`,
545
+ );
546
+ }
497
547
  if (match.redirect) return { redirect: match.redirect };
498
548
  setRequestContextParams(match.params, match.routeName);
499
549
  const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
@@ -530,7 +580,7 @@ function serveShellHit(
530
580
  if (!first) {
531
581
  first = true;
532
582
  console.log(
533
- `[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms`,
583
+ `[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms (abs ${Math.round(performance.now())})`,
534
584
  );
535
585
  }
536
586
  controller.enqueue(chunk);
@@ -571,11 +621,56 @@ function serveShellHit(
571
621
  // decode into a seed Map for the resolveLoaderData overlay, so the
572
622
  // payload's baked container bytes match the frozen prelude while the
573
623
  // hole-marker paths keep the fresh run's live nested promises.
624
+ const seedStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
574
625
  const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
626
+ if (INTERNAL_RANGO_DEBUG) {
627
+ console.log(
628
+ `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
629
+ );
630
+ }
575
631
  if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
632
+ // Shell fast path (serve side): when the capture recorded the implicit
633
+ // doc segment record and the handler layer declared no liveness, arm the
634
+ // implicit scope on the seeded context — the tail match's cache lookup
635
+ // then HITs the SeededShellStore's doc entry and the whole handler layer
636
+ // is REPLAYED, not re-executed (loaders still run fresh via
637
+ // resolveFreshLoadersAndYield; per-request payload metadata is rebuilt
638
+ // by buildFullPayload as always). A route with handler-live holes, a
639
+ // route-derived cache scope, or a missing/corrupt record degrades to
640
+ // the full tail (handler re-run — today's behavior) automatically.
641
+ if (!entry.handlerLiveHoles) {
642
+ seededCtx._shellImplicitCache = {
643
+ ttl: descriptor.ttl,
644
+ swr: descriptor.swr,
645
+ };
646
+ if (INTERNAL_RANGO_DEBUG) {
647
+ console.log(
648
+ `[Server][ppr] shell HIT: fast path armed (implicit doc cache) (abs ${Math.round(performance.now())})`,
649
+ );
650
+ }
651
+ } else if (INTERNAL_RANGO_DEBUG) {
652
+ console.log(
653
+ `[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
654
+ );
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;
576
663
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
577
664
  }
578
- return renderTail(reqCtx);
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));
579
674
  })();
580
675
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
581
676
  // failure before the stream is pulled never surfaces as an unhandled rejection.
@@ -592,9 +687,15 @@ function serveShellHit(
592
687
  }
593
688
  try {
594
689
  const tail = await tailPromise;
690
+ if (INTERNAL_RANGO_DEBUG) {
691
+ console.log(
692
+ `[Server][ppr] shell HIT: tail stream handed over +${Math.round(performance.now() - serveStart)}ms (abs ${Math.round(performance.now())})`,
693
+ );
694
+ }
595
695
  if (tail instanceof ReadableStream) {
596
696
  const reader = tail.getReader();
597
697
  let firstTailChunk = true;
698
+ let tailBytes = 0;
598
699
  try {
599
700
  for (;;) {
600
701
  const { done, value } = await reader.read();
@@ -602,14 +703,24 @@ function serveShellHit(
602
703
  if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
603
704
  firstTailChunk = false;
604
705
  console.log(
605
- `[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms`,
706
+ `[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms (abs ${Math.round(performance.now())})`,
606
707
  );
607
708
  }
709
+ if (INTERNAL_RANGO_DEBUG) tailBytes += value.length;
608
710
  controller.enqueue(value);
609
711
  }
610
712
  } finally {
611
713
  reader.releaseLock();
612
714
  }
715
+ // Bounds the post-header work Server-Timing structurally cannot see:
716
+ // the HIT commits headers at the flush, so ALL live-tail time (match,
717
+ // loaders, Flight, resume) happens inside the response body. This
718
+ // line plus the [Server][segments] build logs narrate that window.
719
+ if (INTERNAL_RANGO_DEBUG) {
720
+ console.log(
721
+ `[Server][ppr] shell HIT: tail complete +${Math.round(performance.now() - serveStart)}ms (${tailBytes}b)`,
722
+ );
723
+ }
613
724
  } else {
614
725
  // Defensive, near-unreachable: a redirecting match cannot have captured
615
726
  // a shell (capture bails on redirects), so a HIT on a redirecting URL
@@ -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
+ }