@rangojs/router 0.0.0-experimental.146 → 0.0.0-experimental.148

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 (45) hide show
  1. package/dist/bin/rango.js +7 -0
  2. package/dist/vite/index.js +823 -235
  3. package/package.json +6 -1
  4. package/skills/mime-routes/SKILL.md +25 -17
  5. package/skills/ppr/SKILL.md +20 -10
  6. package/src/browser/event-controller.ts +16 -2
  7. package/src/browser/rsc-router.tsx +11 -0
  8. package/src/cache/cache-scope.ts +11 -2
  9. package/src/cache/cf/cf-cache-store.ts +60 -2
  10. package/src/cache/memory-segment-store.ts +32 -0
  11. package/src/cache/segment-codec.ts +47 -0
  12. package/src/cache/types.ts +14 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/index.rsc.ts +6 -0
  15. package/src/prerender/build-shell-capture.ts +253 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/content-negotiation.ts +47 -5
  19. package/src/router/match-middleware/cache-lookup.ts +12 -1
  20. package/src/router/metrics.ts +17 -2
  21. package/src/router/prerender-match.ts +21 -0
  22. package/src/router/router-interfaces.ts +7 -0
  23. package/src/router/router-options.ts +13 -0
  24. package/src/router.ts +5 -0
  25. package/src/rsc/capture-queue.ts +67 -0
  26. package/src/rsc/handler.ts +4 -2
  27. package/src/rsc/rsc-rendering.ts +131 -23
  28. package/src/rsc/shell-build-manifest.ts +274 -0
  29. package/src/rsc/shell-capture.ts +486 -63
  30. package/src/rsc/shell-serve.ts +44 -0
  31. package/src/rsc/ssr-setup.ts +54 -22
  32. package/src/segment-fragments.ts +124 -0
  33. package/src/server/context.ts +1 -0
  34. package/src/server/request-context.ts +65 -11
  35. package/src/ssr/index.tsx +47 -9
  36. package/src/ssr/ssr-root.tsx +35 -2
  37. package/src/urls/pattern-types.ts +27 -0
  38. package/src/vite/discovery/discover-routers.ts +27 -0
  39. package/src/vite/discovery/prerender-collection.ts +16 -0
  40. package/src/vite/discovery/shell-prerender-phase.ts +397 -0
  41. package/src/vite/discovery/state.ts +44 -0
  42. package/src/vite/plugins/version-plugin.ts +8 -0
  43. package/src/vite/rango.ts +1 -0
  44. package/src/vite/router-discovery.ts +310 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
@@ -31,6 +31,9 @@ import { gateTransitions } from "./transition-gate.js";
31
31
  import { buildFullPayload } from "./full-payload.js";
32
32
  import {
33
33
  scheduleShellCapture,
34
+ resolveShellCaptureDebugSink,
35
+ takeCaptureDebugEventForTiming,
36
+ describeShellCaptureEvent,
34
37
  type ShellCaptureDescriptor,
35
38
  } from "./shell-capture.js";
36
39
  import {
@@ -44,6 +47,7 @@ import {
44
47
  warnShellStoreMissingOnce,
45
48
  warnPprNonceActiveOnce,
46
49
  } from "./shell-serve.js";
50
+ import { lookupBuildShell } from "./shell-build-manifest.js";
47
51
  import { contextGet } from "../context-var.js";
48
52
  import {
49
53
  resolveSameOriginRedirect,
@@ -139,6 +143,29 @@ async function handleRscRenderingInner<TEnv>(
139
143
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
140
144
  const store = reqCtx._cacheStore;
141
145
  const key = buildShellKey(url);
146
+ // Dev Server-Timing mirror (issue #651): a capture runs AFTER its
147
+ // triggering response committed, so its outcome can only ride a LATER
148
+ // response's header. When the metrics surface is active
149
+ // (debugPerformance), fold the buffered terminal capture event for this
150
+ // key into THIS request's Server-Timing as `ppr-capture;dur=<attempt
151
+ // ms>;desc="<outcome + sizes + waits>"`. Consuming (read-and-clear)
152
+ // keeps one capture = one report. Dev-only: the buffer is only written
153
+ // in dev (see takeCaptureDebugEventForTiming), and production folds the
154
+ // whole branch away.
155
+ if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
156
+ const lastCapture = takeCaptureDebugEventForTiming(key);
157
+ if (lastCapture) {
158
+ appendMetric(
159
+ reqCtx._metricsStore,
160
+ "ppr:capture",
161
+ performance.now(),
162
+ lastCapture.attemptMs ?? 0,
163
+ undefined,
164
+ // attemptMs already rides as this entry's dur — drop it from desc.
165
+ describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
166
+ );
167
+ }
168
+ }
142
169
  if (activeNonce !== undefined) {
143
170
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
144
171
  // undeclared route, which is silent): a ppr route gated off by an active
@@ -169,16 +196,67 @@ async function handleRscRenderingInner<TEnv>(
169
196
  ttl: pprConfig.ttl,
170
197
  swr: pprConfig.swr,
171
198
  tags: pprConfig.tags,
199
+ captureTimeout: pprConfig.captureTimeout,
172
200
  store,
173
201
  debug: INTERNAL_RANGO_DEBUG,
202
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
203
+ // The resolver owns the whole policy: option wins, the
204
+ // INTERNAL_RANGO_DEBUG env flag lights the events up when no
205
+ // option is set, an explicit `false` stays off.
206
+ debugSink: resolveShellCaptureDebugSink(
207
+ ctx.router.debugShellCapture,
208
+ ),
209
+ };
210
+ // One serve funnel for BOTH entry sources (runtime store hit below,
211
+ // build-manifest hit further down): schedule the background
212
+ // recapture when asked, then commit the composed response.
213
+ const serveHit = (
214
+ entry: ShellCacheEntry,
215
+ revalidate: boolean | undefined,
216
+ ): Response => {
217
+ if (revalidate) {
218
+ scheduleShellCapture(
219
+ ctx,
220
+ request,
221
+ env,
222
+ url,
223
+ reqCtx,
224
+ ssrModule,
225
+ descriptor,
226
+ );
227
+ }
228
+ return serveShellHit(
229
+ ctx,
230
+ request,
231
+ env,
232
+ url,
233
+ reqCtx,
234
+ handleStore,
235
+ ssrModule,
236
+ entry,
237
+ descriptor,
238
+ );
174
239
  };
175
240
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
241
+ const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
176
242
  try {
177
243
  cached = await store.getShell(key);
178
244
  } catch (error) {
179
245
  // A failing store read degrades to axis 1 (MISS), never a 500.
180
246
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
181
247
  }
248
+ if (reqCtx._metricsStore) {
249
+ // Raw store outcome (pre-validity-gates), so a version-mismatch
250
+ // lifecycle miss is still distinguishable from a store miss.
251
+ appendMetric(
252
+ reqCtx._metricsStore,
253
+ "ppr:shell-read",
254
+ shellReadStart,
255
+ performance.now() - shellReadStart,
256
+ undefined,
257
+ cached ? "hit" : "miss",
258
+ );
259
+ }
182
260
  if (cached && isValidShellHit(cached.entry, ctx.version)) {
183
261
  if (!hasIntactShellPayload(cached.entry)) {
184
262
  // Corrupt stored payload (undecodable prelude / unparseable
@@ -196,30 +274,45 @@ async function handleRscRenderingInner<TEnv>(
196
274
  } else {
197
275
  // Stale (SWR) hit: serve the stale shell now, recapture in the
198
276
  // 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
- );
277
+ return serveHit(cached.entry, cached.shouldRevalidate);
221
278
  }
222
279
  }
280
+ // Build-time shell read-through (producer B, #699): on a runtime
281
+ // store MISS (or an invalid/corrupt runtime entry), a Prerender+ppr
282
+ // route's shell was already produced at `vite build` — serve it
283
+ // through the SAME serveShellHit, so the first-ever request after a
284
+ // deploy is a HIT with zero runtime capture. lookupBuildShell owns
285
+ // every gate (search-less request, versions, integrity, tag
286
+ // markers) and fails to null — the ordinary MISS path below takes
287
+ // over. Past ppr.ttl the baked entry still serves but a runtime
288
+ // recapture is scheduled: SWR is the UPGRADE path from build entry
289
+ // to fresher runtime entry (the runtime store read above wins once
290
+ // the capture lands).
291
+ const buildHit = await lookupBuildShell(
292
+ url,
293
+ ctx.version,
294
+ store,
295
+ // Dev: no build manifest exists; producer B runs on demand via
296
+ // the dev server's /__rsc_shell endpoint for PRERENDERED routes
297
+ // only (production's exact candidate set). Folded away in
298
+ // production builds (NODE_ENV is a compile-time constant).
299
+ process.env.NODE_ENV !== "production"
300
+ ? {
301
+ isPrerenderRoute:
302
+ reqCtx._classifiedRoute?.matched?.pr === true,
303
+ routeName: reqCtx._classifiedRoute?.routeKey,
304
+ ttl: pprConfig.ttl,
305
+ swr: pprConfig.swr,
306
+ tags: pprConfig.tags,
307
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
308
+ captureTimeout: pprConfig.captureTimeout,
309
+ }
310
+ : undefined,
311
+ );
312
+ if (buildHit) {
313
+ // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
314
+ return serveHit(buildHit.entry, buildHit.stale);
315
+ }
223
316
  // MISS (no entry, invalid reactVersion, or store read failure): axis 1
224
317
  // + a background capture scheduled once the response is known servable.
225
318
  pprMiss = { descriptor, ssrModule };
@@ -609,9 +702,24 @@ function serveShellHit(
609
702
  `[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
610
703
  );
611
704
  }
705
+ // Fragment splice (issue #700): cache/prerender-store hits inside THIS
706
+ // tail render emit their stored segment fragments verbatim into the
707
+ // payload; the SSR resume pass and browser hydration expand them
708
+ // (segment-fragments.ts). Tail-only: the flag lives on the derived
709
+ // context so it can never leak into a capture render (which serializes
710
+ // segments and must see real elements).
711
+ seededCtx._shellFragmentPayload = true;
612
712
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
613
713
  }
614
- return renderTail(reqCtx);
714
+ // No snapshot (e.g. a producer B entry whose capture hit only the
715
+ // prerender store): still a shell-HIT tail, so arm the fragment splice on
716
+ // a derived context — the tail's prerender-store/cache hits (if any) then
717
+ // splice; a route with neither serves exactly as before. Derived, never
718
+ // the shared reqCtx: scheduleShellCapture derives the capture context from
719
+ // reqCtx and the flag must not be inherited there.
720
+ const fragmentCtx: RequestContext<any> = Object.create(reqCtx);
721
+ fragmentCtx._shellFragmentPayload = true;
722
+ return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
615
723
  })();
616
724
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
617
725
  // failure before the stream is pulled never surfaces as an unhandled rejection.
@@ -0,0 +1,274 @@
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 {
31
+ DEV_SHELL_PROBE_TIMEOUT_MS,
32
+ hasIntactShellPayload,
33
+ isValidShellHit,
34
+ } from "./shell-serve.js";
35
+ import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture.js";
36
+ import { buildShellManifestKey } from "../prerender/shell-manifest-key.js";
37
+
38
+ /** One baked manifest record (the __ps asset module's default export). */
39
+ export interface BuildShellEntry {
40
+ entry: ShellCacheEntry;
41
+ /** Resolved ppr ttl (seconds) — drives staleness/recapture, never expiry. */
42
+ ttl: number;
43
+ swr?: number;
44
+ /** The putShell-barrier tag union baked at build (static + recorded). */
45
+ tags?: string[];
46
+ routeName: string;
47
+ }
48
+
49
+ interface ShellManifestModule {
50
+ /** Manifest key (pathname — see shell-manifest-key.ts) -> asset specifier. */
51
+ default: Record<string, string>;
52
+ loadShellAsset: (spec: string) => Promise<{ default: BuildShellEntry }>;
53
+ }
54
+
55
+ declare global {
56
+ // Injected into the built RSC entry by the shell prerender phase
57
+ // (vite/discovery/shell-prerender-phase.ts): lazy loader for the shell
58
+ // manifest module.
59
+ // eslint-disable-next-line no-var
60
+ var __loadShellManifestModule:
61
+ | (() => Promise<ShellManifestModule>)
62
+ | undefined;
63
+ }
64
+
65
+ let manifestPromise: Promise<ShellManifestModule | null> | null = null;
66
+
67
+ function loadManifest(): Promise<ShellManifestModule | null> {
68
+ if (!manifestPromise) {
69
+ const loader = globalThis.__loadShellManifestModule;
70
+ if (!loader) return Promise.resolve(null);
71
+ // A failing import is memoized as absent: the module is a build artifact,
72
+ // so the failure is deterministic — retrying per request only re-pays it.
73
+ manifestPromise = loader().catch(() => null);
74
+ }
75
+ return manifestPromise;
76
+ }
77
+
78
+ /**
79
+ * Per-spec verdict memo for the version + integrity gates. A manifest record
80
+ * is immutable for the process lifetime (content-hashed asset module), so its
81
+ * gate verdict is constant — without this, EVERY request to a baked ppr route
82
+ * re-decodes the full prelude base64 (hasIntactShellPayload) on the hot path:
83
+ * a fresh build hit never populates the runtime store, so the store MISS +
84
+ * read-through is the steady state, not a warm-up. Only the per-request
85
+ * gates (tag markers, staleness) stay outside the memo. `null` memoizes a
86
+ * failed verdict — deterministically invalid, don't re-pay the decode.
87
+ * Spec-only keying is sound because buildVersion is process-constant on the
88
+ * manifest path (folded into the shipped worker; dev never loads a manifest).
89
+ */
90
+ const validatedSpecs = new Map<string, BuildShellEntry | null>();
91
+
92
+ async function validatedManifestRecord(
93
+ mod: ShellManifestModule,
94
+ spec: string,
95
+ buildVersion: string,
96
+ ): Promise<BuildShellEntry | undefined> {
97
+ let verdict = validatedSpecs.get(spec);
98
+ if (verdict === undefined) {
99
+ const record = (await mod.loadShellAsset(spec)).default;
100
+ verdict =
101
+ isValidShellHit(record.entry, buildVersion) &&
102
+ hasIntactShellPayload(record.entry)
103
+ ? record
104
+ : null;
105
+ validatedSpecs.set(spec, verdict);
106
+ }
107
+ return verdict ?? undefined;
108
+ }
109
+
110
+ /** Reset the memoized manifest (unit tests swap the global loader). */
111
+ export function resetBuildShellManifestForTests(): void {
112
+ manifestPromise = null;
113
+ validatedSpecs.clear();
114
+ }
115
+
116
+ /** Keys already warned about a tag-check-incapable store (once per key). */
117
+ const warnedTagCheckUnsupported = new Set<string>();
118
+
119
+ export interface BuildShellHit {
120
+ entry: ShellCacheEntry;
121
+ /** Past createdAt + ttl: serve, but schedule the runtime recapture. */
122
+ stale: boolean;
123
+ }
124
+
125
+ /**
126
+ * Dev-mode lookup context: there is no build manifest in dev, so producer B
127
+ * runs ON DEMAND through the Vite dev server's /__rsc_shell endpoint
128
+ * (memoized per router HMR generation), mirroring the dev prerender store's
129
+ * /__rsc_prerender flow. Only armed for PRERENDERED routes (matched.pr) —
130
+ * exactly production's candidate set; everything else keeps runtime capture.
131
+ */
132
+ export interface DevShellLookup {
133
+ /** True when the classified route is trie-flagged pr (Prerender-backed). */
134
+ isPrerenderRoute: boolean;
135
+ /** Canonical route key of the classified route (endpoint verification). */
136
+ routeName: string | undefined;
137
+ /** Resolved ppr policy from the serve gate (the endpoint is policy-free). */
138
+ ttl: number;
139
+ swr?: number;
140
+ tags?: string[];
141
+ maxSnapshotBytes?: number;
142
+ /** Resolved `ppr.captureTimeout` (ms) — the endpoint's capture honors it. */
143
+ captureTimeout?: number;
144
+ }
145
+
146
+ /** Retry delay (~400ms) plus quiesce/fizz/store headroom past the budgets. */
147
+ const DEV_SHELL_RETRY_MARGIN_MS = 5_000;
148
+
149
+ /**
150
+ * Timed out like the dev prerender store fetch (see #697): inside a workerd
151
+ * waitUntil an unsettled fetch pends forever instead of rejecting; on timeout
152
+ * this degrades to a MISS and the runtime capture path takes over. But this
153
+ * fetch blocks a foreground document request and its response IS an inline
154
+ * capture, so the bound must cover the endpoint's FULL sequential worst case
155
+ * — aborting a still-healthy capture turns it into a spurious MISS. The terms
156
+ * of the server envelope (dev /__rsc_shell, vite/router-discovery.ts):
157
+ * - DEV_SHELL_PROBE_TIMEOUT_MS: the sequential /__rsc_prerender pre-flight
158
+ * probe (same constant on the endpoint side).
159
+ * - 2x the capture settle budget (`ppr.captureTimeout`, default
160
+ * SHELL_CAPTURE_MAX_WAIT_MS): first attempt + one in-place cold-graph retry.
161
+ * - DEV_SHELL_RETRY_MARGIN_MS: the ~400ms retry delay plus headroom.
162
+ * Deeper fix (possible follow-up): the endpoint owns ONE total deadline and
163
+ * this bound becomes a plain liveness backstop instead of envelope math.
164
+ */
165
+ function devShellFetchTimeoutMs(captureTimeout: number | undefined): number {
166
+ return (
167
+ DEV_SHELL_PROBE_TIMEOUT_MS +
168
+ 2 * (captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS) +
169
+ DEV_SHELL_RETRY_MARGIN_MS
170
+ );
171
+ }
172
+
173
+ async function fetchDevShellEntry(
174
+ pathname: string,
175
+ buildVersion: string,
176
+ dev: DevShellLookup,
177
+ ): Promise<BuildShellEntry | undefined> {
178
+ if (!dev.isPrerenderRoute || !dev.routeName) return undefined;
179
+ const devUrl = globalThis.__PRERENDER_DEV_URL;
180
+ if (!devUrl) return undefined;
181
+ const params = new URLSearchParams({
182
+ pathname,
183
+ routeName: dev.routeName,
184
+ ttl: String(dev.ttl),
185
+ version: buildVersion,
186
+ });
187
+ if (dev.swr !== undefined) params.set("swr", String(dev.swr));
188
+ if (dev.tags && dev.tags.length > 0) params.set("tags", dev.tags.join(","));
189
+ if (dev.maxSnapshotBytes !== undefined) {
190
+ params.set("maxSnapshotBytes", String(dev.maxSnapshotBytes));
191
+ }
192
+ if (dev.captureTimeout !== undefined) {
193
+ params.set("captureTimeout", String(dev.captureTimeout));
194
+ }
195
+ try {
196
+ const res = await fetch(`${devUrl}/__rsc_shell?${params}`, {
197
+ signal: AbortSignal.timeout(devShellFetchTimeoutMs(dev.captureTimeout)),
198
+ });
199
+ if (!res.ok) return undefined;
200
+ return (await res.json()) as BuildShellEntry;
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Look up the baked shell entry for a request, applying every serve gate:
208
+ * search-less requests only (the build captured the bare pathname; a
209
+ * search-bearing URL has its own shell identity owned by runtime capture),
210
+ * version validity, payload integrity, and tag-invalidation markers. Returns
211
+ * null on any gate failure — the caller degrades to the ordinary MISS path
212
+ * (axis 1 + runtime capture), never a broken serve.
213
+ */
214
+ export async function lookupBuildShell(
215
+ url: URL,
216
+ buildVersion: string,
217
+ store: SegmentCacheStore,
218
+ dev?: DevShellLookup,
219
+ ): Promise<BuildShellHit | null> {
220
+ try {
221
+ // Source-presence first: with no manifest and no dev context this is the
222
+ // steady-state MISS shape for every non-baked ppr route — return before
223
+ // the searchParams sort/allocation below.
224
+ const hasManifest = globalThis.__loadShellManifestModule !== undefined;
225
+ if (!hasManifest && !dev) return null;
226
+ if (sortedSearchString(url.searchParams) !== "") return null;
227
+ let record: BuildShellEntry | undefined;
228
+ if (hasManifest) {
229
+ const mod = await loadManifest();
230
+ if (!mod) return null;
231
+ const spec = mod.default[buildShellManifestKey(url.pathname)];
232
+ if (!spec) return null;
233
+ record = await validatedManifestRecord(mod, spec, buildVersion);
234
+ } else if (dev) {
235
+ const fetched = await fetchDevShellEntry(url.pathname, buildVersion, dev);
236
+ record =
237
+ fetched !== undefined &&
238
+ isValidShellHit(fetched.entry, buildVersion) &&
239
+ hasIntactShellPayload(fetched.entry)
240
+ ? fetched
241
+ : undefined;
242
+ }
243
+ if (!record) return null;
244
+ const entry = record.entry;
245
+ if (record.tags && record.tags.length > 0) {
246
+ const check = store.isTagsInvalidatedSince;
247
+ if (typeof check !== "function") {
248
+ // A tagged build entry on a store that cannot answer "was this tag
249
+ // invalidated since the build" must not serve: updateTag() could
250
+ // never evict it. Declared intent that cannot be honored deserves a
251
+ // diagnostic; the route keeps runtime-capture semantics.
252
+ const key = buildShellManifestKey(url.pathname);
253
+ if (!warnedTagCheckUnsupported.has(key)) {
254
+ warnedTagCheckUnsupported.add(key);
255
+ console.warn(
256
+ `[rango] Build-time shell for "${url.pathname}" carries cache tags, but ` +
257
+ "the app cache store does not implement isTagsInvalidatedSince(), so " +
258
+ "updateTag() could not evict it. The entry is not served; the route " +
259
+ "keeps runtime shell capture. Use MemorySegmentCacheStore, CFCacheStore, " +
260
+ "or VercelCacheStore (or add the method to your custom store).",
261
+ );
262
+ }
263
+ return null;
264
+ }
265
+ if (await check.call(store, record.tags, entry.createdAt)) return null;
266
+ }
267
+ const stale = Date.now() >= entry.createdAt + record.ttl * 1000;
268
+ return { entry, stale };
269
+ } catch {
270
+ // Any read-through fault is a MISS, never a 500 — the ordinary axis-1 +
271
+ // runtime-capture path takes over.
272
+ return null;
273
+ }
274
+ }