@rangojs/router 0.1.0 → 0.2.0

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 (128) hide show
  1. package/README.md +2 -1
  2. package/dist/bin/rango.js +11 -1
  3. package/dist/types/browser/dev-discovery.d.ts +11 -0
  4. package/dist/types/browser/navigation-store.d.ts +3 -13
  5. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  6. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  7. package/dist/types/browser/types.d.ts +4 -16
  8. package/dist/types/build/generate-manifest.d.ts +6 -0
  9. package/dist/types/cache/cache-scope.d.ts +7 -5
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  11. package/dist/types/cache/document-cache.d.ts +3 -1
  12. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  13. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  14. package/dist/types/cache/types.d.ts +25 -7
  15. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  16. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  17. package/dist/types/prerender/store.d.ts +1 -0
  18. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  19. package/dist/types/route-map-builder.d.ts +4 -29
  20. package/dist/types/router/match-handlers.d.ts +2 -3
  21. package/dist/types/router/prerender-match.d.ts +4 -1
  22. package/dist/types/router/router-interfaces.d.ts +3 -1
  23. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  24. package/dist/types/router/transition-when.d.ts +13 -0
  25. package/dist/types/rsc/handler-context.d.ts +1 -0
  26. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  27. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  28. package/dist/types/rsc/shell-capture.d.ts +4 -8
  29. package/dist/types/rsc/shell-serve.d.ts +2 -0
  30. package/dist/types/rsc/transition-gate.d.ts +10 -14
  31. package/dist/types/rsc/types.d.ts +2 -0
  32. package/dist/types/server/context.d.ts +16 -0
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/server.d.ts +1 -1
  35. package/dist/types/testing/e2e/index.d.ts +1 -1
  36. package/dist/types/testing/index.d.ts +2 -2
  37. package/dist/types/testing/run-transition-when.d.ts +6 -5
  38. package/dist/types/testing/shell-status.d.ts +23 -3
  39. package/dist/types/types/route-entry.d.ts +3 -0
  40. package/dist/types/types/segments.d.ts +27 -22
  41. package/dist/types/urls/path-helper-types.d.ts +4 -3
  42. package/dist/types/vite/discovery/state.d.ts +3 -2
  43. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  44. package/dist/vite/index.js +194 -107
  45. package/package.json +24 -20
  46. package/skills/cache-guide/SKILL.md +6 -3
  47. package/skills/caching/SKILL.md +1 -1
  48. package/skills/catalog.json +7 -1
  49. package/skills/deployment-caching/SKILL.md +176 -0
  50. package/skills/document-cache/SKILL.md +30 -3
  51. package/skills/ppr/SKILL.md +90 -14
  52. package/skills/prerender/SKILL.md +15 -8
  53. package/skills/rango/SKILL.md +20 -17
  54. package/skills/testing/SKILL.md +1 -1
  55. package/skills/testing/cache-prerender.md +5 -1
  56. package/skills/vercel/SKILL.md +22 -1
  57. package/skills/view-transitions/SKILL.md +12 -8
  58. package/src/browser/dev-discovery.ts +66 -0
  59. package/src/browser/navigation-client.ts +6 -0
  60. package/src/browser/navigation-store.ts +4 -271
  61. package/src/browser/partial-update.ts +7 -14
  62. package/src/browser/prefetch/cache.ts +1 -1
  63. package/src/browser/prefetch/loader.ts +110 -0
  64. package/src/browser/prefetch/runtime.ts +7 -0
  65. package/src/browser/react/Link.tsx +7 -10
  66. package/src/browser/react/NavigationProvider.tsx +1 -1
  67. package/src/browser/react/use-router.ts +1 -1
  68. package/src/browser/rsc-router.tsx +4 -2
  69. package/src/browser/types.ts +4 -27
  70. package/src/build/generate-manifest.ts +11 -0
  71. package/src/cache/cache-scope.ts +23 -7
  72. package/src/cache/cf/cf-cache-store.ts +63 -39
  73. package/src/cache/document-cache.ts +4 -2
  74. package/src/cache/memory-segment-store.ts +17 -6
  75. package/src/cache/shell-snapshot.ts +45 -15
  76. package/src/cache/types.ts +24 -6
  77. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  78. package/src/dev-discovery-protocol.ts +11 -0
  79. package/src/prerender/build-shell-capture.ts +7 -1
  80. package/src/prerender/store.ts +5 -0
  81. package/src/route-definition/dsl-helpers.ts +7 -2
  82. package/src/route-definition/helpers-types.ts +6 -6
  83. package/src/route-map-builder.ts +56 -49
  84. package/src/router/handler-context.ts +13 -5
  85. package/src/router/lazy-includes.ts +1 -0
  86. package/src/router/match-api.ts +8 -4
  87. package/src/router/match-handlers.ts +44 -6
  88. package/src/router/match-middleware/cache-lookup.ts +10 -3
  89. package/src/router/prerender-match.ts +19 -2
  90. package/src/router/router-interfaces.ts +4 -0
  91. package/src/router/segment-resolution/static-store.ts +36 -10
  92. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  93. package/src/router/transition-when.ts +76 -0
  94. package/src/router.ts +34 -3
  95. package/src/rsc/handler-context.ts +1 -0
  96. package/src/rsc/handler.ts +3 -1
  97. package/src/rsc/loader-fetch.ts +2 -2
  98. package/src/rsc/manifest-init.ts +4 -11
  99. package/src/rsc/progressive-enhancement.ts +9 -14
  100. package/src/rsc/render-pipeline.ts +10 -2
  101. package/src/rsc/rsc-rendering.ts +319 -147
  102. package/src/rsc/shell-capture.ts +34 -11
  103. package/src/rsc/shell-serve.ts +3 -0
  104. package/src/rsc/transition-gate.ts +37 -40
  105. package/src/rsc/types.ts +2 -0
  106. package/src/server/context.ts +28 -0
  107. package/src/server/request-context.ts +38 -10
  108. package/src/server.ts +0 -2
  109. package/src/testing/dispatch.ts +1 -0
  110. package/src/testing/e2e/index.ts +5 -0
  111. package/src/testing/index.ts +9 -1
  112. package/src/testing/run-transition-when.ts +42 -9
  113. package/src/testing/shell-status.ts +92 -3
  114. package/src/types/route-entry.ts +3 -0
  115. package/src/types/segments.ts +27 -22
  116. package/src/urls/include-helper.ts +1 -0
  117. package/src/urls/path-helper-types.ts +4 -3
  118. package/src/urls/path-helper.ts +8 -4
  119. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  120. package/src/vite/discovery/discover-routers.ts +7 -66
  121. package/src/vite/discovery/prerender-collection.ts +13 -2
  122. package/src/vite/discovery/state.ts +4 -4
  123. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  124. package/src/vite/plugins/version-injector.ts +0 -1
  125. package/src/vite/plugins/virtual-entries.ts +11 -1
  126. package/src/vite/router-discovery.ts +132 -22
  127. package/src/vite/utils/manifest-utils.ts +1 -4
  128. package/src/vite/utils/shared-utils.ts +7 -0
@@ -32,12 +32,12 @@ export type LinkState =
32
32
  | LocationStateEntry[]
33
33
  | StateOrGetter<Record<string, unknown>>;
34
34
 
35
- import { prefetchDirect, prefetchQueued } from "../prefetch/fetch.js";
36
- import { getAppVersion } from "../app-version.js";
37
35
  import {
38
36
  observeForPrefetch,
39
- unobserveForPrefetch,
40
- } from "../prefetch/observer.js";
37
+ prefetchDirect,
38
+ prefetchQueued,
39
+ } from "../prefetch/loader.js";
40
+ import { getAppVersion } from "../app-version.js";
41
41
 
42
42
  // The (hover: none) MediaQueryList, created lazily on first client read and
43
43
  // reused across every Link render. matchMedia allocates and registers a live
@@ -394,7 +394,7 @@ export const Link: ForwardRefExoticComponent<
394
394
 
395
395
  let cancelled = false;
396
396
  let unsubIdle: (() => void) | undefined;
397
- let observedElement: Element | null = null;
397
+ let stopObserving: (() => void) | undefined;
398
398
 
399
399
  const triggerPrefetch = () => {
400
400
  if (cancelled) return;
@@ -431,8 +431,7 @@ export const Link: ForwardRefExoticComponent<
431
431
  } else if (isViewport) {
432
432
  const element = internalRef.current;
433
433
  if (!element) return;
434
- observedElement = element;
435
- observeForPrefetch(element, () => {
434
+ stopObserving = observeForPrefetch(element, () => {
436
435
  scheduleWhenIdle(triggerPrefetch);
437
436
  });
438
437
  }
@@ -440,9 +439,7 @@ export const Link: ForwardRefExoticComponent<
440
439
  return () => {
441
440
  cancelled = true;
442
441
  unsubIdle?.();
443
- if (isViewport && observedElement) {
444
- unobserveForPrefetch(observedElement);
445
- }
442
+ stopObserving?.();
446
443
  };
447
444
  }, [resolvedStrategy, resolvedTo, isExternal, ctx, prefetchKey]);
448
445
 
@@ -26,7 +26,7 @@ import type { HandleData } from "../types.js";
26
26
  import { ThemeProvider } from "../../theme/ThemeProvider.js";
27
27
  import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
- import { cancelAllPrefetches } from "../prefetch/queue.js";
29
+ import { cancelAllPrefetches } from "../prefetch/loader.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
31
  import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
32
  import { startConnectionWarmup } from "../connection-warmup.js";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useContext, useMemo } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
- import { prefetchDirect } from "../prefetch/fetch.js";
5
+ import { prefetchDirect } from "../prefetch/loader.js";
6
6
  import { getAppVersion } from "../app-version.js";
7
7
  import type { RouterInstance, RouterNavigateOptions } from "../types.js";
8
8
 
@@ -26,8 +26,10 @@ import { expandSegmentFragments } from "../segment-fragments.js";
26
26
  import { initRangoState } from "./rango-state.js";
27
27
  import { registerNavigationStore } from "./navigation-store-handle.js";
28
28
  import { initPrefetchCache } from "./prefetch/cache.js";
29
- import { setPrefetchConcurrency } from "./prefetch/queue.js";
30
- import { setPrefetchDecoder } from "./prefetch/fetch.js";
29
+ import {
30
+ setPrefetchConcurrency,
31
+ setPrefetchDecoder,
32
+ } from "./prefetch/loader.js";
31
33
  import { setAppVersion } from "./app-version.js";
32
34
  import {
33
35
  isInterceptSegment,
@@ -65,6 +65,8 @@ export interface RscMetadata {
65
65
  * Used to detect version mismatches after HMR/deployment.
66
66
  */
67
67
  version?: string;
68
+ /** Cloudflare dev worker generation used for stale-document convergence. */
69
+ devDiscoveryEpoch?: number;
68
70
  /**
69
71
  * TTL in milliseconds for the client-side in-memory prefetch cache.
70
72
  * Sent on initial render so the browser can configure its cache duration.
@@ -430,24 +432,10 @@ export type StateListener = () => void;
430
432
  /**
431
433
  * Navigation store interface
432
434
  *
433
- * Manages both:
434
- * - NavigationState: Public state exposed via useNavigation hook
435
- * - SegmentState: Internal segment management for partial updates
435
+ * Owns segment state, history snapshots, and partial-update notifications.
436
+ * EventController owns the public navigation lifecycle exposed by hooks.
436
437
  */
437
438
  export interface NavigationStore {
438
- // Public state (for useNavigation hook)
439
- getState(): NavigationState;
440
- setState(partial: Partial<NavigationState>): void;
441
- subscribe(listener: StateListener): () => void;
442
-
443
- // Inflight action management
444
- addInflightAction(action: InflightAction): void;
445
- removeInflightAction(id: string): void;
446
-
447
- // Action state (for controlling update behavior during server actions)
448
- isActionInProgress(): boolean;
449
- setActionInProgress(value: boolean): void;
450
-
451
439
  // Internal segment state (for bridges)
452
440
  getSegmentState(): SegmentState;
453
441
  setPath(path: string): void;
@@ -459,8 +447,6 @@ export interface NavigationStore {
459
447
  setHistoryKey(key: string): void;
460
448
  /** Monotonic token of the most recently committed navigation. */
461
449
  getNavInstance(): number;
462
- /** Nav-instance token recorded on a cache entry (undefined if absent). */
463
- getCacheEntryInstance(historyKey: string): number | undefined;
464
450
  cacheSegmentsForHistory(
465
451
  historyKey: string,
466
452
  segments: ResolvedSegment[],
@@ -506,7 +492,6 @@ export interface NavigationStore {
506
492
  stale?: boolean,
507
493
  handlesPending?: boolean,
508
494
  ): void;
509
- markCacheAsStale(): void;
510
495
  markHistoryCacheStale(): void;
511
496
  markCacheAsStaleAndBroadcast(): void;
512
497
  clearHistoryCache(): void;
@@ -525,14 +510,6 @@ export interface NavigationStore {
525
510
  // UI update notifications
526
511
  onUpdate(callback: UpdateSubscriber): () => void;
527
512
  emitUpdate(update: NavigationUpdate): void;
528
-
529
- // Action state tracking (for useAction hook)
530
- getActionState(actionId: string): TrackedActionState;
531
- setActionState(actionId: string, state: Partial<TrackedActionState>): void;
532
- subscribeToAction(
533
- actionId: string,
534
- listener: ActionStateListener,
535
- ): () => void;
536
513
  }
537
514
 
538
515
  // ============================================================================
@@ -115,6 +115,7 @@ async function buildPrefixTreeNode(
115
115
  passthroughRoutes?: string[],
116
116
  responseTypeRoutes?: Record<string, string>,
117
117
  routeSearchSchemas?: Record<string, Record<string, string>>,
118
+ routerId?: string,
118
119
  ): Promise<PrefixTreeNode> {
119
120
  // Resolve an async include provider (`() => import("./routes")`) so its routes
120
121
  // are walked into the build-time manifest/types/href. Runtime matching still
@@ -156,6 +157,7 @@ async function buildPrefixTreeNode(
156
157
  parent: null,
157
158
  counters: {},
158
159
  mountIndex,
160
+ ...(routerId ? { routerId } : {}),
159
161
  trackedIncludes, // Enable nested include tracking
160
162
  },
161
163
  () => {
@@ -227,6 +229,7 @@ async function buildPrefixTreeNode(
227
229
  passthroughRoutes,
228
230
  responseTypeRoutes,
229
231
  routeSearchSchemas,
232
+ routerId,
230
233
  ),
231
234
  );
232
235
 
@@ -296,6 +299,12 @@ export async function generateManifestFull<TEnv>(
296
299
  mountIndex: number = 0,
297
300
  options?: {
298
301
  urlPrefix?: string;
302
+ /**
303
+ * Owning router id. Threaded into the evaluation store so path() scopes
304
+ * its search-schema/root-scope registrations per router — same-named
305
+ * routes in different routers must not clobber each other.
306
+ */
307
+ routerId?: string;
299
308
  /**
300
309
  * Called once per `"use client"` component registered as an
301
310
  * errorBoundary/notFoundBoundary fallback, with its client-reference key
@@ -328,6 +337,7 @@ export async function generateManifestFull<TEnv>(
328
337
  parent: null,
329
338
  counters: {},
330
339
  mountIndex,
340
+ ...(options?.routerId ? { routerId: options.routerId } : {}),
331
341
  trackedIncludes, // Enable include tracking
332
342
  // basename sets the initial URL prefix for all path() registrations
333
343
  ...(options?.urlPrefix ? { urlPrefix: options.urlPrefix } : {}),
@@ -402,6 +412,7 @@ export async function generateManifestFull<TEnv>(
402
412
  passthroughRoutes,
403
413
  responseTypeRoutes,
404
414
  routeSearchSchemas,
415
+ options?.routerId,
405
416
  ),
406
417
  );
407
418
 
@@ -89,6 +89,7 @@ function getDefaultRouteCacheKey(
89
89
  pathname: string,
90
90
  params?: Record<string, string>,
91
91
  isIntercept?: boolean,
92
+ prefixOverride?: "doc",
92
93
  ): string {
93
94
  const ctx = getRequestContext();
94
95
  const isPartial = ctx?.originalUrl?.searchParams.has("_rsc_partial") ?? false;
@@ -96,7 +97,9 @@ function getDefaultRouteCacheKey(
96
97
  const host = ctx?.url.host ?? "localhost";
97
98
 
98
99
  // Intercept navigations get their own cache namespace
99
- const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
100
+ const prefix = isIntercept
101
+ ? "intercept"
102
+ : (prefixOverride ?? (isPartial ? "partial" : "doc"));
100
103
 
101
104
  return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
102
105
  }
@@ -105,6 +108,8 @@ function getDefaultRouteCacheKey(
105
108
  // CacheScope
106
109
  // ============================================================================
107
110
 
111
+ const CACHE_HIT_OBSERVERS = new WeakMap<CacheScope, () => void>();
112
+
108
113
  /**
109
114
  * CacheScope represents a cache boundary in the route tree.
110
115
  *
@@ -131,6 +136,7 @@ export class CacheScope {
131
136
  constructor(
132
137
  config: PartialCacheOptions | false,
133
138
  parent: CacheScope | null = null,
139
+ private readonly defaultKeyPrefix?: "doc",
134
140
  ) {
135
141
  this.config = config;
136
142
  this.parent = parent;
@@ -209,7 +215,12 @@ export class CacheScope {
209
215
  params: Record<string, string>,
210
216
  isIntercept?: boolean,
211
217
  ): Promise<string> {
212
- const defaultKey = getDefaultRouteCacheKey(pathname, params, isIntercept);
218
+ const defaultKey = getDefaultRouteCacheKey(
219
+ pathname,
220
+ params,
221
+ isIntercept,
222
+ this.defaultKeyPrefix,
223
+ );
213
224
  const keyFn = this.config !== false ? this.config.key : undefined;
214
225
  return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
215
226
  }
@@ -339,6 +350,7 @@ export class CacheScope {
339
350
  );
340
351
  }
341
352
 
353
+ CACHE_HIT_OBSERVERS.get(this)?.();
342
354
  return { segments, shouldRevalidate };
343
355
  } catch (error) {
344
356
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
@@ -529,10 +541,11 @@ export function createCacheScope(
529
541
  /**
530
542
  * Shell fast path: when the route tree derived NO cache scope and the current
531
543
  * request context carries the `_shellImplicitCache` marker (a shell capture,
532
- * or a HIT tail serving an eligible entry), substitute an implicit doc-level
533
- * scope so withCacheLookup/withCacheStore treat the WHOLE matched route as a
534
- * cache() boundary — the shell entry IS a cache() of the handler layer, with
535
- * loaders as the live carve-outs (resolveFreshLoadersAndYield).
544
+ * an eligible HIT tail, or a normal partial navigation replay), substitute an
545
+ * implicit doc-level scope so withCacheLookup/withCacheStore treat the WHOLE
546
+ * matched route as a cache() boundary — the shell entry IS a cache() of the
547
+ * handler layer, with loaders as the live carve-outs
548
+ * (resolveFreshLoadersAndYield).
536
549
  *
537
550
  * An existing scope — including an explicit cache(false) opt-out — always
538
551
  * wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
@@ -545,8 +558,11 @@ export function resolveShellImplicitCacheScope(
545
558
  if (scope) return scope;
546
559
  const marker = getRequestContext()?._shellImplicitCache;
547
560
  if (!marker) return null;
548
- return new CacheScope(
561
+ const implicitScope = new CacheScope(
549
562
  { ttl: marker.ttl, swr: marker.swr, store: marker.store },
550
563
  null,
564
+ marker.keyPrefix,
551
565
  );
566
+ if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
567
+ return implicitScope;
552
568
  }
@@ -272,7 +272,7 @@ interface KVShellEnvelope {
272
272
  rv: string;
273
273
  /** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
274
274
  bv?: string;
275
- /** createdAt (ms epoch) */
275
+ /** Capture-generation start time (ms epoch), used by tag marker checks. */
276
276
  c: number;
277
277
  /** When entry becomes stale (ms epoch) */
278
278
  s: number;
@@ -293,6 +293,8 @@ interface KVShellEnvelope {
293
293
  * their holes only a handler re-run can fill.
294
294
  */
295
295
  lh?: boolean;
296
+ /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
297
+ tw?: true;
296
298
  }
297
299
 
298
300
  /**
@@ -323,6 +325,7 @@ interface KVResponseEnvelope {
323
325
  // ============================================================================
324
326
 
325
327
  export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
328
+ readonly supportsPassiveShellReads: true = true;
326
329
  readonly defaults?: CacheDefaults;
327
330
  readonly keyGenerator?: (
328
331
  ctx: RequestContext<TEnv>,
@@ -1743,7 +1746,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1743
1746
  // per-colo L1 for them is a deliberate follow-up (see the PPR shell-resume
1744
1747
  // design doc). Shell entries live only in KV (the global tier), so the family
1745
1748
  // requires a configured KV namespace; without one, getShell/putShell no-op and
1746
- // the shell-cache middleware fails open to a full HTML render. Tag invalidation
1749
+ // the integrated PPR serve path fails open to a full HTML render. Tag invalidation
1747
1750
  // still applies: shell entries carry tags/taggedAt and are checked against the
1748
1751
  // same KV markers isGloballyInvalidated() reads for every other tier.
1749
1752
 
@@ -1772,7 +1775,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1772
1775
  * Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
1773
1776
  * budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
1774
1777
  * like kvGetItem, minus the L1 promote. SWR is a plain staleness flag — KV has
1775
- * no REVALIDATING herd guard, so the shell-cache middleware's module-level
1778
+ * no REVALIDATING herd guard, so the capture scheduler's module-level
1776
1779
  * in-flight set is the recapture stampede guard.
1777
1780
  */
1778
1781
  async getShell(
@@ -1815,6 +1818,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1815
1818
  initialTheme: envelope.i,
1816
1819
  snapshot: envelope.sn,
1817
1820
  handlerLiveHoles: envelope.lh,
1821
+ transitionWhen: envelope.tw,
1818
1822
  createdAt: envelope.c,
1819
1823
  },
1820
1824
  shouldRevalidate,
@@ -1826,8 +1830,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1826
1830
  }
1827
1831
 
1828
1832
  /**
1829
- * Store a PPR shell entry in KV with TTL and optional SWR window. Non-blocking
1830
- * (waitUntil) like the other KV writes. The tags/taggedAt ride in the envelope
1833
+ * Store a PPR shell entry in KV with TTL and optional SWR window. The write is
1834
+ * registered with waitUntil and awaited so invalidation rejection can be
1835
+ * acknowledged to the capture scheduler. The tags/taggedAt ride in the envelope
1831
1836
  * so isGloballyInvalidated() can invalidate the shell via the shared KV markers.
1832
1837
  */
1833
1838
  async putShell(
@@ -1836,8 +1841,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1836
1841
  ttlSeconds?: number,
1837
1842
  swrSeconds?: number,
1838
1843
  tags?: string[],
1839
- ): Promise<void> {
1840
- // KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
1844
+ ): Promise<"stored" | "invalidated" | void> {
1845
+ // KV-only tier: needs a KV namespace and waitUntil. The same write promise is
1846
+ // registered for isolate lifetime and awaited so invalidation rejection can
1847
+ // be acknowledged to the capture scheduler.
1841
1848
  if (!this.kv) {
1842
1849
  this.warnShellFamilyInertOnce();
1843
1850
  return;
@@ -1851,9 +1858,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1851
1858
  // letting kv.put reject inside waitUntil (mirrors setItem/kvSetSegment).
1852
1859
  if (totalTtl < 60) return;
1853
1860
 
1854
- const staleAt = Date.now() + ttl * 1000;
1861
+ const retentionTtl =
1862
+ tags && tags.length > 0 && this.tagInvalidationTtl
1863
+ ? Math.min(totalTtl, this.tagInvalidationTtl)
1864
+ : totalTtl;
1865
+ const now = Date.now();
1866
+ const staleAt = now + ttl * 1000;
1867
+ const expiresAt = now + retentionTtl * 1000;
1855
1868
  const taggedAt =
1856
- Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1869
+ Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
1857
1870
 
1858
1871
  const kvKey = this.toKVKey(`shell:${key}`);
1859
1872
  // A key over the KV limit makes kv.put reject deep inside waitUntil; report
@@ -1871,31 +1884,43 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1871
1884
  return;
1872
1885
  }
1873
1886
 
1874
- this.waitUntil(() =>
1875
- reportingAsync(
1876
- () => {
1877
- const envelope: KVShellEnvelope = {
1878
- p: entry.prelude,
1879
- po: entry.postponed,
1880
- rv: entry.reactVersion,
1881
- bv: entry.buildVersion,
1882
- c: entry.createdAt,
1883
- s: staleAt,
1884
- e: staleAt + swrWindow * 1000,
1885
- t: tags,
1886
- ta: taggedAt,
1887
- i: entry.initialTheme,
1888
- sn: entry.snapshot,
1889
- lh: entry.handlerLiveHoles,
1890
- };
1891
- return this.kv!.put(kvKey, JSON.stringify(envelope), {
1892
- expirationTtl: totalTtl,
1893
- });
1894
- },
1895
- "cache-write",
1896
- "[CFCacheStore] putShell",
1897
- ),
1898
- );
1887
+ const write = (async (): Promise<"stored" | "invalidated" | void> => {
1888
+ try {
1889
+ if (
1890
+ tags &&
1891
+ tags.length > 0 &&
1892
+ (await this.isGloballyInvalidated(tags, entry.createdAt))
1893
+ ) {
1894
+ return "invalidated";
1895
+ }
1896
+ const envelope: KVShellEnvelope = {
1897
+ p: entry.prelude,
1898
+ po: entry.postponed,
1899
+ rv: entry.reactVersion,
1900
+ bv: entry.buildVersion,
1901
+ c: entry.createdAt,
1902
+ s: staleAt,
1903
+ e: expiresAt,
1904
+ t: tags,
1905
+ ta: taggedAt,
1906
+ i: entry.initialTheme,
1907
+ sn: entry.snapshot,
1908
+ lh: entry.handlerLiveHoles,
1909
+ tw: entry.transitionWhen,
1910
+ };
1911
+ await this.kv!.put(kvKey, JSON.stringify(envelope), {
1912
+ expirationTtl: retentionTtl,
1913
+ });
1914
+ return "stored";
1915
+ } catch (error) {
1916
+ reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1917
+ return undefined;
1918
+ }
1919
+ })();
1920
+ this.waitUntil(async () => {
1921
+ await write;
1922
+ });
1923
+ return await write;
1899
1924
  } catch (error) {
1900
1925
  reportCacheError(error, "cache-write", "[CFCacheStore] putShell");
1901
1926
  }
@@ -2610,11 +2635,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2610
2635
  * eager purge still fires for the whole batch first (it is additive).
2611
2636
  */
2612
2637
  /**
2613
- * Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince):
2614
- * a baked shell entry is immutable in the build manifest, so eviction is
2615
- * answered by the SAME KV tag markers updateTag() writes, compared against
2616
- * the entry's build-time createdAt. Thin public wrapper over the private
2617
- * envelope check (identical semantics: marker >= since, fail open).
2638
+ * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
2639
+ * SAME KV markers used by runtime envelopes also evict immutable build shells
2640
+ * and captures whose write races updateTag(). Thin public wrapper over the
2641
+ * private envelope check (marker >= since, fail open).
2618
2642
  */
2619
2643
  async isTagsInvalidatedSince(
2620
2644
  tags: string[],
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Document-Level Cache Middleware
3
3
  *
4
- * Caches full HTTP responses at the edge based on Cache-Control headers.
4
+ * Caches full HTTP responses in the configured app store based on Cache-Control
5
+ * headers. A deployment CDN may independently consume the same shared-cache
6
+ * directives; this middleware itself runs inside the worker/function.
5
7
  * Routes opt-in to caching by setting s-maxage or stale-while-revalidate headers.
6
8
  *
7
9
  * Flow:
@@ -58,7 +60,7 @@ function parseCacheControl(header: string | null): CacheDirectives | null {
58
60
 
59
61
  // RFC 7234: in a SHARED cache, `private` and `no-store` forbid storage and
60
62
  // MUST win over `s-maxage` even though `private, s-maxage` is contradictory.
61
- // The document cache is a shared edge store, so refuse both regardless of any
63
+ // The document cache is a shared app store, so refuse both regardless of any
62
64
  // s-maxage / stale-while-revalidate also present. Match standalone directive
63
65
  // tokens (start/end, whitespace, comma, semicolon, or `=` bounded), not a
64
66
  // substring, so a value containing "private" cannot false-veto.
@@ -188,6 +188,7 @@ const DEFAULT_MAX_ENTRIES = 1000;
188
188
  export class MemorySegmentCacheStore<
189
189
  TEnv = unknown,
190
190
  > implements SegmentCacheStore<TEnv> {
191
+ readonly supportsPassiveShellReads: true = true;
191
192
  private cache: Map<string, CachedEntryData>;
192
193
  private responseCache: Map<string, CachedResponseEntry>;
193
194
  private itemCache: Map<string, CachedItemEntry>;
@@ -197,11 +198,9 @@ export class MemorySegmentCacheStore<
197
198
  /** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
198
199
  private keyTags: Map<string, Set<string>>;
199
200
  /**
200
- * tag -> epoch ms of its latest invalidateTags() call. The build-shell
201
- * read-through's isTagsInvalidatedSince gate: baked shell entries are
202
- * immutable in the build manifest, so eviction is answered by comparing
203
- * these markers against the entry's build-time createdAt. Per-isolate,
204
- * like every other map here — matching this store's tag semantics.
201
+ * tag -> epoch ms of its latest invalidateTags() call. Runtime capture races
202
+ * and immutable build shells are gated by comparing these markers with the
203
+ * shell generation start. Per-isolate, like every other map here.
205
204
  */
206
205
  private tagInvalidatedAt: Map<string, number>;
207
206
  readonly defaults?: CacheDefaults;
@@ -459,7 +458,14 @@ export class MemorySegmentCacheStore<
459
458
  ttlSeconds?: number,
460
459
  swrSeconds?: number,
461
460
  tags?: string[],
462
- ): Promise<void> {
461
+ ): Promise<"stored" | "invalidated"> {
462
+ if (
463
+ tags &&
464
+ tags.length > 0 &&
465
+ this.tagsInvalidatedSince(tags, entry.createdAt)
466
+ ) {
467
+ return "invalidated";
468
+ }
463
469
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
464
470
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
465
471
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
@@ -470,12 +476,17 @@ export class MemorySegmentCacheStore<
470
476
  if (tags && tags.length > 0) {
471
477
  this.registerTags(tags, prefixedKey);
472
478
  }
479
+ return "stored";
473
480
  }
474
481
 
475
482
  async isTagsInvalidatedSince(
476
483
  tags: string[],
477
484
  sinceMs: number,
478
485
  ): Promise<boolean> {
486
+ return this.tagsInvalidatedSince(tags, sinceMs);
487
+ }
488
+
489
+ private tagsInvalidatedSince(tags: string[], sinceMs: number): boolean {
479
490
  for (const tag of tags) {
480
491
  const at = this.tagInvalidatedAt.get(tag);
481
492
  // >= so a same-millisecond invalidation wins (freshness over staleness),