@rangojs/router 0.5.0 → 0.5.2
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/README.md +5 -1
- package/dist/types/browser/event-controller.d.ts +6 -0
- package/dist/types/browser/prefetch/cache.d.ts +31 -2
- package/dist/types/cache/cf/cf-cache-constants.d.ts +8 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +15 -1
- package/dist/types/cache/cf/cf-cache-types.d.ts +1 -1
- package/dist/types/cache/cf/cf-kv-utils.d.ts +21 -0
- package/dist/types/handles/is-thenable.d.ts +2 -4
- package/dist/types/route-definition/helpers-types.d.ts +5 -4
- package/dist/types/rsc/helpers.d.ts +3 -0
- package/dist/types/rsc/render-pipeline.d.ts +9 -0
- package/dist/types/rsc/routine-plan.d.ts +124 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +17 -0
- package/dist/types/server/request-context.d.ts +4 -1
- package/dist/types/static-handler.d.ts +15 -1
- package/dist/types/urls/path-helper-types.d.ts +6 -7
- package/dist/types/vite/encryption-key.d.ts +2 -0
- package/dist/types/vite/plugins/expose-internal-ids.d.ts +10 -0
- package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
- package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
- package/dist/types/vite/utils/shared-utils.d.ts +12 -0
- package/dist/vite/index.js +154 -57
- package/package.json +3 -3
- package/skills/caching/SKILL.md +1 -1
- package/skills/mime-routes/SKILL.md +3 -1
- package/skills/parallel/SKILL.md +1 -1
- package/skills/response-routes/SKILL.md +4 -2
- package/skills/typesafety/generated-files-and-cli.md +16 -9
- package/skills/typesafety/route-types.md +5 -1
- package/skills/use-cache/SKILL.md +47 -0
- package/src/browser/event-controller.ts +40 -15
- package/src/browser/partial-update.ts +26 -11
- package/src/browser/prefetch/cache.ts +53 -9
- package/src/browser/prefetch/fetch.ts +83 -2
- package/src/browser/react/NavigationProvider.tsx +7 -0
- package/src/cache/cache-runtime.ts +89 -15
- package/src/cache/cf/cf-cache-constants.ts +8 -1
- package/src/cache/cf/cf-cache-store.ts +41 -64
- package/src/cache/cf/cf-cache-types.ts +1 -1
- package/src/cache/cf/cf-kv-utils.ts +38 -0
- package/src/cache/segment-codec.ts +21 -5
- package/src/handles/is-thenable.ts +2 -4
- package/src/route-definition/helpers-types.ts +5 -3
- package/src/router/match-middleware/cache-lookup.ts +24 -15
- package/src/rsc/handler.ts +26 -5
- package/src/rsc/helpers.ts +13 -0
- package/src/rsc/progressive-enhancement.ts +247 -70
- package/src/rsc/render-pipeline.ts +68 -24
- package/src/rsc/routine-plan.ts +359 -0
- package/src/rsc/rsc-rendering.ts +555 -302
- package/src/rsc/server-action.ts +180 -71
- package/src/rsc/shell-capture-constants.ts +18 -0
- package/src/rsc/shell-capture.ts +92 -21
- package/src/server/request-context.ts +6 -0
- package/src/ssr/ssr-root.tsx +1 -0
- package/src/static-handler.ts +18 -2
- package/src/urls/path-helper-types.ts +9 -10
- package/src/vite/encryption-key.ts +29 -0
- package/src/vite/plugins/expose-action-id.ts +2 -2
- package/src/vite/plugins/expose-internal-ids.ts +46 -0
- package/src/vite/plugins/server-ref-hashing.ts +74 -0
- package/src/vite/plugins/server-reference-pattern.ts +10 -0
- package/src/vite/rango.ts +9 -0
- package/src/vite/router-discovery.ts +21 -2
- package/src/vite/utils/shared-utils.ts +12 -7
|
@@ -307,6 +307,53 @@ intercept("@modal", ".product", async (ctx) => {
|
|
|
307
307
|
}),
|
|
308
308
|
```
|
|
309
309
|
|
|
310
|
+
## Embedding server actions in cached components
|
|
311
|
+
|
|
312
|
+
A cached function may return a component that creates an inline `"use server"`
|
|
313
|
+
action. The canonical case is a cached list whose items each carry an action --
|
|
314
|
+
e.g. a cached article list where every row has a like button:
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
export async function ArticleList() {
|
|
318
|
+
"use cache: articles";
|
|
319
|
+
const articles = await db.query("SELECT id, title FROM articles");
|
|
320
|
+
return (
|
|
321
|
+
<ul>
|
|
322
|
+
{articles.map((a) => {
|
|
323
|
+
const articleId = a.id; // captured -> frozen with the cache entry
|
|
324
|
+
async function like() {
|
|
325
|
+
"use server";
|
|
326
|
+
// runs live on click, in the CURRENT request scope
|
|
327
|
+
const user = cookies().get("session")?.value;
|
|
328
|
+
if (user) await db.like(articleId, user);
|
|
329
|
+
}
|
|
330
|
+
return <LikeButton key={a.id} title={a.title} action={like} />;
|
|
331
|
+
})}
|
|
332
|
+
</ul>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Three behaviors, locked by `use-cache-inline-action.test.ts` (dev + prod):
|
|
338
|
+
|
|
339
|
+
| What | Behavior | Why |
|
|
340
|
+
| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
341
|
+
| Values the action **closes over** (`articleId`) | **Frozen** at cache-write | The closure compiles to encrypted bound args, snapshotted when the entry is written and replayed verbatim on a hit. Correct for stable identities; wrong for volatile values. |
|
|
342
|
+
| The action **body** | **Runs live** every call | Once invoked it is an ordinary server function: fresh computation and live request context. `cookies()` / `headers()` work here (the body executes in the live request). |
|
|
343
|
+
| Invocability on a **cache hit** | **Works** | The action survives serialize -> cache -> deserialize and stays callable. |
|
|
344
|
+
|
|
345
|
+
Rule of thumb: **capture stable identities, read volatile/request-scoped values
|
|
346
|
+
live in the body.** Do not close over a per-request token, the current user, or
|
|
347
|
+
the current time and expect freshness -- those are frozen at cache-write. The
|
|
348
|
+
`cookies()`/`headers()` read guard applies to the cached function body, NOT to an
|
|
349
|
+
inline action's body.
|
|
350
|
+
|
|
351
|
+
Deploy note: captured values are encrypted with a per-build key by default. If
|
|
352
|
+
cache entries can outlive a deploy (a persistent store such as the Cloudflare
|
|
353
|
+
cache store), set `RANGO_ENCRYPTION_KEY` (base64-encoded 32 bytes) in the build
|
|
354
|
+
environment so actions embedded in entries written by a previous deploy still
|
|
355
|
+
decrypt; without it they fail until the entry expires or revalidates.
|
|
356
|
+
|
|
310
357
|
## Vite Transform
|
|
311
358
|
|
|
312
359
|
The `rango:use-cache` Vite plugin detects the directive and wraps exports with
|
|
@@ -234,6 +234,12 @@ export interface EventController {
|
|
|
234
234
|
listener: ActionStateListener,
|
|
235
235
|
): () => void;
|
|
236
236
|
subscribeToHandles(listener: HandleListener): () => void;
|
|
237
|
+
/**
|
|
238
|
+
* Deliver queued location, params, navigation, and handle notifications now.
|
|
239
|
+
* NavigationProvider calls this inside the payload update so React assigns
|
|
240
|
+
* every route-state hook update to the same normal or transition lane.
|
|
241
|
+
*/
|
|
242
|
+
flushRouteState(): void;
|
|
237
243
|
|
|
238
244
|
// Handle operations
|
|
239
245
|
setHandleData(
|
|
@@ -367,19 +373,32 @@ function matchesActionId(
|
|
|
367
373
|
return entryActionId.endsWith(`#${subscriptionId}`);
|
|
368
374
|
}
|
|
369
375
|
|
|
370
|
-
|
|
371
|
-
|
|
376
|
+
interface DebouncedNotifier {
|
|
377
|
+
schedule(): void;
|
|
378
|
+
flush(): void;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Batch rapid notifications into one task to prevent render storms. The
|
|
382
|
+
// explicit flush lets a React update owner preserve its scheduling lane.
|
|
383
|
+
function makeDebouncedNotifier(listeners: Set<() => void>): DebouncedNotifier {
|
|
372
384
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
373
|
-
|
|
374
|
-
if (timeout
|
|
375
|
-
timeout
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
385
|
+
const flush = () => {
|
|
386
|
+
if (timeout === null) return;
|
|
387
|
+
clearTimeout(timeout);
|
|
388
|
+
timeout = null;
|
|
389
|
+
notifyListeners(
|
|
390
|
+
[...listeners],
|
|
391
|
+
(listener) => listener(),
|
|
392
|
+
(listener) => listeners.has(listener),
|
|
393
|
+
);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
schedule() {
|
|
398
|
+
if (timeout !== null) clearTimeout(timeout);
|
|
399
|
+
timeout = setTimeout(flush, 0);
|
|
400
|
+
},
|
|
401
|
+
flush,
|
|
383
402
|
};
|
|
384
403
|
}
|
|
385
404
|
|
|
@@ -459,7 +478,7 @@ export function createEventController(
|
|
|
459
478
|
|
|
460
479
|
function notify(): void {
|
|
461
480
|
cachedDerivedState = null;
|
|
462
|
-
notifyStateListeners();
|
|
481
|
+
notifyStateListeners.schedule();
|
|
463
482
|
}
|
|
464
483
|
|
|
465
484
|
const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -517,6 +536,11 @@ export function createEventController(
|
|
|
517
536
|
|
|
518
537
|
const notifyHandles = makeDebouncedNotifier(handleListeners);
|
|
519
538
|
|
|
539
|
+
function flushRouteState(): void {
|
|
540
|
+
notifyStateListeners.flush();
|
|
541
|
+
notifyHandles.flush();
|
|
542
|
+
}
|
|
543
|
+
|
|
520
544
|
function getState(): DerivedNavigationState {
|
|
521
545
|
if (cachedDerivedState) return cachedDerivedState;
|
|
522
546
|
|
|
@@ -1001,7 +1025,7 @@ export function createEventController(
|
|
|
1001
1025
|
handleSegmentOrder = newSegmentOrder;
|
|
1002
1026
|
routeSegmentIds = newRouteSegmentIds;
|
|
1003
1027
|
|
|
1004
|
-
notifyHandles();
|
|
1028
|
+
notifyHandles.schedule();
|
|
1005
1029
|
}
|
|
1006
1030
|
|
|
1007
1031
|
function getHandleState(): HandleState {
|
|
@@ -1021,7 +1045,7 @@ export function createEventController(
|
|
|
1021
1045
|
return;
|
|
1022
1046
|
}
|
|
1023
1047
|
routeSegmentIds = next;
|
|
1024
|
-
notifyHandles();
|
|
1048
|
+
notifyHandles.schedule();
|
|
1025
1049
|
}
|
|
1026
1050
|
|
|
1027
1051
|
// ========================================================================
|
|
@@ -1102,6 +1126,7 @@ export function createEventController(
|
|
|
1102
1126
|
subscribe,
|
|
1103
1127
|
subscribeToAction,
|
|
1104
1128
|
subscribeToHandles,
|
|
1129
|
+
flushRouteState,
|
|
1105
1130
|
|
|
1106
1131
|
// Direct access
|
|
1107
1132
|
getCurrentNavigation: () => currentNavigation,
|
|
@@ -421,10 +421,8 @@ export function createPartialUpdater(
|
|
|
421
421
|
// forceAwait unwraps the ROUTER loader promises during render so they
|
|
422
422
|
// land without a loading()/fallback frame. A fully-prefetched nav has
|
|
423
423
|
// its router data already resolved (the prefetch stream drained), so
|
|
424
|
-
// awaiting it here is free
|
|
425
|
-
//
|
|
426
|
-
// CLIENT component that suspends on mount, which a transition would
|
|
427
|
-
// wrongly suppress by holding the old UI until that suspense settles.
|
|
424
|
+
// awaiting it here is free; the commit below then runs in a transition
|
|
425
|
+
// (fullyPrefetched branch) so nothing router-owned can flash.
|
|
428
426
|
forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
|
|
429
427
|
interceptSegments:
|
|
430
428
|
reconciled.interceptSegments.length > 0
|
|
@@ -549,14 +547,31 @@ export function createPartialUpdater(
|
|
|
549
547
|
scroll: scrollPayload,
|
|
550
548
|
});
|
|
551
549
|
});
|
|
550
|
+
} else if (fullyPrefetched) {
|
|
551
|
+
// Fully-prefetched nav: the payload is fully resolved (forceAwait
|
|
552
|
+
// above), so commit inside a transition to hold the current UI across
|
|
553
|
+
// the synchronous resolution — no fallback flash. No addTransitionType:
|
|
554
|
+
// this is the React content-hold, not a view transition. Deliberate
|
|
555
|
+
// trade-off (#622 introduced, #624 reverted, then reinstated): a client
|
|
556
|
+
// component that suspends during its FIRST render (use() of a promise
|
|
557
|
+
// created at mount — see ClientMountSuspense in the e2e test-app) under
|
|
558
|
+
// an ALREADY-REVEALED boundary holds the old content until it resolves
|
|
559
|
+
// instead of revealing that boundary's fallback; its render happens
|
|
560
|
+
// pre-commit inside the transition, so userland effects cannot run
|
|
561
|
+
// first. Boundaries newly mounted by this nav still reveal their
|
|
562
|
+
// fallbacks (React shows new boundaries inside transitions).
|
|
563
|
+
startTransition(() => {
|
|
564
|
+
onUpdate({
|
|
565
|
+
root: newTree,
|
|
566
|
+
metadata: payload.metadata!,
|
|
567
|
+
scroll: scrollPayload,
|
|
568
|
+
});
|
|
569
|
+
});
|
|
552
570
|
} else {
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
// component that only starts fetching post-mount, retaining the
|
|
558
|
-
// previous page indefinitely. Explicit transition() routes keep the
|
|
559
|
-
// content-hold via the hasTransition branch above (the opt-in).
|
|
571
|
+
// Cold/partially-prefetched nav: normal commit so fallbacks stream
|
|
572
|
+
// like a first load and the click has visible feedback. Explicit
|
|
573
|
+
// transition() routes keep the content-hold via the hasTransition
|
|
574
|
+
// branch above (the opt-in).
|
|
560
575
|
onUpdate({
|
|
561
576
|
root: newTree,
|
|
562
577
|
metadata: payload.metadata!,
|
|
@@ -72,8 +72,31 @@ export interface DecodedPrefetch {
|
|
|
72
72
|
* tell a FULLY warmed prefetch (payload commits without suspending → safe to
|
|
73
73
|
* commit in a startTransition, no fallback flash) from a partially-warmed one
|
|
74
74
|
* (still streaming → stream its fallbacks like a cold load). Starts false.
|
|
75
|
+
*
|
|
76
|
+
* On a respawned entry (see `respawn`) this starts true: the buffered bytes
|
|
77
|
+
* are a complete stream by construction.
|
|
75
78
|
*/
|
|
76
79
|
complete: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Re-create this entry from its buffered raw Flight bytes. Attached (by
|
|
82
|
+
* `executePrefetchFetch`) only after the stream ended cleanly AND the eager
|
|
83
|
+
* decode succeeded — a truncated or failed prefetch must stay one-shot.
|
|
84
|
+
*
|
|
85
|
+
* Why bytes and not the decoded payload: the revived payload is a live graph
|
|
86
|
+
* with one-shot stream edges (the handle stream is an async generator drained
|
|
87
|
+
* during commit; userland payloads may embed serialized ReadableStreams).
|
|
88
|
+
* Re-decoding from the wire bytes manufactures fresh instances of every
|
|
89
|
+
* stream, which no amount of cloning the revived graph can do. Each call tees
|
|
90
|
+
* the reserve branch, so the replacement entry carries its own `respawn` —
|
|
91
|
+
* one prefetch serves unlimited adoptions within TTL/state validity.
|
|
92
|
+
*/
|
|
93
|
+
respawn?: () => DecodedPrefetch;
|
|
94
|
+
/**
|
|
95
|
+
* Release the buffered reserve bytes. Called when the entry is evicted
|
|
96
|
+
* without being consumed (expiry, FIFO, sweep, cache clear) so the tee
|
|
97
|
+
* buffer is dropped promptly instead of waiting for GC.
|
|
98
|
+
*/
|
|
99
|
+
dispose?: () => void;
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
let cacheTTL = 300_000;
|
|
@@ -208,6 +231,7 @@ export function hasPrefetch(key: string): boolean {
|
|
|
208
231
|
const entry = cache.get(key);
|
|
209
232
|
if (!entry) return false;
|
|
210
233
|
if (Date.now() - entry.timestamp > cacheTTL) {
|
|
234
|
+
entry.entry.dispose?.();
|
|
211
235
|
cache.delete(key);
|
|
212
236
|
return false;
|
|
213
237
|
}
|
|
@@ -216,22 +240,34 @@ export function hasPrefetch(key: string): boolean {
|
|
|
216
240
|
|
|
217
241
|
/**
|
|
218
242
|
* Consume a cached, eagerly-decoded prefetch. Returns null if not found or
|
|
219
|
-
* expired.
|
|
220
|
-
*
|
|
243
|
+
* expired. Returns null when caching is disabled (TTL <= 0).
|
|
244
|
+
*
|
|
245
|
+
* A decoded payload is single-render (its handle stream is drained during the
|
|
246
|
+
* commit), so the returned entry is never served twice. When the entry carries
|
|
247
|
+
* `respawn` (clean-EOF prefetch with buffered bytes), the slot is re-armed in
|
|
248
|
+
* place with a fresh decode of those bytes — the ORIGINAL timestamp is kept so
|
|
249
|
+
* TTL bounds the age of the data, not of the latest adoption. Without
|
|
250
|
+
* `respawn` the entry is deleted (legacy one-shot behavior).
|
|
221
251
|
*
|
|
222
252
|
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
223
253
|
* for that (returns a Promise instead of a resolved entry).
|
|
224
254
|
*/
|
|
225
255
|
export function consumePrefetch(key: string): DecodedPrefetch | null {
|
|
226
256
|
if (cacheTTL <= 0) return null;
|
|
227
|
-
const
|
|
228
|
-
if (!
|
|
229
|
-
if (Date.now() -
|
|
257
|
+
const cached = cache.get(key);
|
|
258
|
+
if (!cached) return null;
|
|
259
|
+
if (Date.now() - cached.timestamp > cacheTTL) {
|
|
260
|
+
cached.entry.dispose?.();
|
|
230
261
|
cache.delete(key);
|
|
231
262
|
return null;
|
|
232
263
|
}
|
|
233
|
-
|
|
234
|
-
|
|
264
|
+
const entry = cached.entry;
|
|
265
|
+
if (entry.respawn) {
|
|
266
|
+
cached.entry = entry.respawn();
|
|
267
|
+
} else {
|
|
268
|
+
cache.delete(key);
|
|
269
|
+
}
|
|
270
|
+
return entry;
|
|
235
271
|
}
|
|
236
272
|
|
|
237
273
|
/**
|
|
@@ -301,6 +337,7 @@ export function storePrefetch(
|
|
|
301
337
|
let min = Infinity;
|
|
302
338
|
for (const [k, cached] of cache) {
|
|
303
339
|
if (now - cached.timestamp > cacheTTL) {
|
|
340
|
+
cached.entry.dispose?.();
|
|
304
341
|
cache.delete(k);
|
|
305
342
|
} else if (cached.timestamp < min) {
|
|
306
343
|
min = cached.timestamp;
|
|
@@ -312,7 +349,10 @@ export function storePrefetch(
|
|
|
312
349
|
// FIFO eviction if at capacity
|
|
313
350
|
if (cache.size >= maxPrefetchCacheSize) {
|
|
314
351
|
const oldest = cache.keys().next().value;
|
|
315
|
-
if (oldest)
|
|
352
|
+
if (oldest) {
|
|
353
|
+
cache.get(oldest)?.entry.dispose?.();
|
|
354
|
+
cache.delete(oldest);
|
|
355
|
+
}
|
|
316
356
|
}
|
|
317
357
|
|
|
318
358
|
cache.set(key, { entry, timestamp: now });
|
|
@@ -335,7 +375,10 @@ export function storePrefetch(
|
|
|
335
375
|
* entry.
|
|
336
376
|
*/
|
|
337
377
|
export function removePrefetch(key: string, entry: DecodedPrefetch): void {
|
|
338
|
-
if (cache.get(key)?.entry === entry)
|
|
378
|
+
if (cache.get(key)?.entry === entry) {
|
|
379
|
+
entry.dispose?.();
|
|
380
|
+
cache.delete(key);
|
|
381
|
+
}
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
/**
|
|
@@ -405,6 +448,7 @@ export function clearPrefetchCache(rotateRangoState = true): void {
|
|
|
405
448
|
inflightPromises.clear();
|
|
406
449
|
inflightAliases.clear();
|
|
407
450
|
adoptedKeys.clear();
|
|
451
|
+
for (const cached of cache.values()) cached.entry.dispose?.();
|
|
408
452
|
cache.clear();
|
|
409
453
|
earliestTimestamp = Infinity;
|
|
410
454
|
abortAllPrefetches();
|
|
@@ -70,6 +70,49 @@ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
|
|
|
70
70
|
decoder = fn;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Build the respawn closure for a cleanly-completed prefetch entry. Each call
|
|
75
|
+
* tees the reserve byte branch: one side feeds a fresh decode — a fresh handle
|
|
76
|
+
* stream and fresh serialized-stream instances, which no cloning of the
|
|
77
|
+
* revived payload graph could produce — and the other becomes the next
|
|
78
|
+
* reserve, so the replacement entry can respawn again. `streamComplete`
|
|
79
|
+
* resolves immediately and `complete` starts true: the buffered bytes are a
|
|
80
|
+
* finished stream by construction. The chunk imports the decode triggers are
|
|
81
|
+
* already in the module cache from the original eager decode, so a respawn
|
|
82
|
+
* costs ~ms with no network.
|
|
83
|
+
*/
|
|
84
|
+
function makeRespawn(
|
|
85
|
+
storageKey: string,
|
|
86
|
+
reserve: ReadableStream<Uint8Array>,
|
|
87
|
+
scope: "source" | "wildcard",
|
|
88
|
+
): () => DecodedPrefetch {
|
|
89
|
+
return () => {
|
|
90
|
+
const [body, nextReserve] = reserve.tee();
|
|
91
|
+
// decoder is non-null here: respawn is only armed after a successful
|
|
92
|
+
// eager decode, which requires the decoder to have been set.
|
|
93
|
+
const payload = decoder!(Promise.resolve(new Response(body)));
|
|
94
|
+
payload.catch(() => {});
|
|
95
|
+
const entry: DecodedPrefetch = {
|
|
96
|
+
payload,
|
|
97
|
+
streamComplete: Promise.resolve(),
|
|
98
|
+
scope,
|
|
99
|
+
complete: true,
|
|
100
|
+
dispose: () => {
|
|
101
|
+
// Rejects (caught) instead of throwing if the branch is already
|
|
102
|
+
// locked by a later respawn — cache eviction never races adoption on
|
|
103
|
+
// the same entry object, so this is belt-and-braces.
|
|
104
|
+
nextReserve.cancel().catch(() => {});
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
entry.respawn = makeRespawn(storageKey, nextReserve, scope);
|
|
108
|
+
// Parity with the original path: a rejected decode must not stay
|
|
109
|
+
// consumable. Clean bytes should never fail to decode, but if they do,
|
|
110
|
+
// evict (identity-guarded) so navigation refetches instead.
|
|
111
|
+
payload.catch(() => removePrefetch(storageKey, entry));
|
|
112
|
+
return entry;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
73
116
|
/**
|
|
74
117
|
* Check if a URL resolves to the current page (same pathname + search).
|
|
75
118
|
* Used to prevent same-page prefetching, which produces a trivial diff
|
|
@@ -271,9 +314,23 @@ function executePrefetchFetch(
|
|
|
271
314
|
true,
|
|
272
315
|
);
|
|
273
316
|
|
|
317
|
+
// Split the decode branch: one side feeds the eager decoder (unchanged —
|
|
318
|
+
// client chunks still import at prefetch time), the other stays unread as
|
|
319
|
+
// a reserve of the raw Flight bytes. A decoded payload is single-render
|
|
320
|
+
// (its handle stream drains during commit), so adoption would otherwise
|
|
321
|
+
// spend the entry; the reserve lets a cleanly-completed entry respawn a
|
|
322
|
+
// fresh decode per adoption instead (see makeRespawn).
|
|
323
|
+
let decodeSource: Response = tracked;
|
|
324
|
+
let reserve: ReadableStream<Uint8Array> | undefined;
|
|
325
|
+
if (tracked.body) {
|
|
326
|
+
const [decodeBody, reserveBody] = tracked.body.tee();
|
|
327
|
+
decodeSource = new Response(decodeBody, { headers: tracked.headers });
|
|
328
|
+
reserve = reserveBody;
|
|
329
|
+
}
|
|
330
|
+
|
|
274
331
|
// Eager decode: parsing the Flight stream imports the route's client
|
|
275
332
|
// chunks now, not on click.
|
|
276
|
-
const payload = decoder(Promise.resolve(
|
|
333
|
+
const payload = decoder(Promise.resolve(decodeSource));
|
|
277
334
|
// Mark handled so an unconsumed prefetch decode error stays quiet; the
|
|
278
335
|
// error is still surfaced to navigation if it consumes the entry.
|
|
279
336
|
payload.catch(() => {});
|
|
@@ -283,6 +340,11 @@ function executePrefetchFetch(
|
|
|
283
340
|
streamComplete,
|
|
284
341
|
scope,
|
|
285
342
|
complete: false,
|
|
343
|
+
...(reserve && {
|
|
344
|
+
dispose: () => {
|
|
345
|
+
reserve.cancel().catch(() => {});
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
286
348
|
};
|
|
287
349
|
storePrefetch(storageKey, entry, gen);
|
|
288
350
|
// The stall timeout now owns the body stream: arm eviction (publishedKey)
|
|
@@ -301,10 +363,29 @@ function executePrefetchFetch(
|
|
|
301
363
|
streamComplete.then(() => {
|
|
302
364
|
if (!endedCleanly) removePrefetch(storageKey, entry);
|
|
303
365
|
});
|
|
304
|
-
// Mark complete ONLY on a fully-healthy prefetch (decode resolved AND
|
|
366
|
+
// Mark complete ONLY on a fully-healthy prefetch (decode resolved AND
|
|
367
|
+
// clean EOF) — and only then arm respawn: a truncated or failed stream's
|
|
368
|
+
// reserve would replay broken bytes, so it stays one-shot (and its
|
|
369
|
+
// buffered reserve is released via the removePrefetch dispose above).
|
|
305
370
|
Promise.allSettled([payload, streamComplete]).then(([decode]) => {
|
|
306
371
|
if (decode.status === "fulfilled" && endedCleanly) {
|
|
307
372
|
entry.complete = true;
|
|
373
|
+
if (reserve) {
|
|
374
|
+
entry.respawn = makeRespawn(storageKey, reserve, scope);
|
|
375
|
+
// Refill after an early adoption. A click that lands before this
|
|
376
|
+
// point adopts the entry with respawn unarmed and leaves the slot
|
|
377
|
+
// empty — via either path: pre-headers inflight adoption makes
|
|
378
|
+
// storePrefetch skip publication (adoptedKeys), and a mid-stream
|
|
379
|
+
// cache adoption deletes the incomplete entry. Now that the bytes
|
|
380
|
+
// proved themselves complete, publish a respawned sibling so the
|
|
381
|
+
// revisit is warm. Guards: hasPrefetch skips when the slot is
|
|
382
|
+
// live again (our own un-adopted entry, a fresh entry, or a new
|
|
383
|
+
// in-flight prefetch), and storePrefetch's generation check drops
|
|
384
|
+
// the sibling if the cache was invalidated since the fetch began.
|
|
385
|
+
if (!hasPrefetch(storageKey)) {
|
|
386
|
+
storePrefetch(storageKey, entry.respawn(), gen);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
308
389
|
}
|
|
309
390
|
clearTimeout(timeoutId);
|
|
310
391
|
});
|
|
@@ -456,6 +456,13 @@ export function NavigationProvider({
|
|
|
456
456
|
cached === undefined ? update.metadata.resolvedIds : undefined,
|
|
457
457
|
);
|
|
458
458
|
}
|
|
459
|
+
|
|
460
|
+
// tx.commit() and the metadata updates above mutate the controller
|
|
461
|
+
// synchronously, but its ordinary notifications are task-debounced. Flush
|
|
462
|
+
// them here so hook setState calls inherit this payload update's lane. In
|
|
463
|
+
// a transition that suspends, the source tree therefore keeps its source
|
|
464
|
+
// pathname/params until the destination payload commits with them.
|
|
465
|
+
eventController.flushRouteState();
|
|
459
466
|
});
|
|
460
467
|
|
|
461
468
|
return unsubscribe;
|
|
@@ -205,8 +205,63 @@ interface CacheEnvelope {
|
|
|
205
205
|
* singleton), and cleared for a key as soon as the leader settles: a rejected
|
|
206
206
|
* leader (function threw, or the result was not serializable) propagates to
|
|
207
207
|
* current waiters, which then retry fresh.
|
|
208
|
+
*
|
|
209
|
+
* A leader that NEVER settles must not hang followers (scar tissue, autobarn
|
|
210
|
+
* pilot outage): a background shell capture's render became leader, awaited a
|
|
211
|
+
* tarpitting upstream fetch, and workerd killed the capture's waitUntil context
|
|
212
|
+
* — orphaning the leader promise as permanently pending, its map entry never
|
|
213
|
+
* cleared. Every later document render calling the same cached function (an
|
|
214
|
+
* isolate-global key for plain-args calls) awaited it forever before first
|
|
215
|
+
* byte: isolate-wide TTFB-0 until redeploy. Followers therefore trust an entry
|
|
216
|
+
* only for {@link IN_FLIGHT_LEADER_MAX_WAIT_MS} from registration; past it
|
|
217
|
+
* they evict the entry and run fresh — bounded duplicate upstream work instead
|
|
218
|
+
* of an unbounded hang. Eviction is age-based off `registeredAt`, so it also
|
|
219
|
+
* heals entries stranded by a killed context (no timer in that context needs
|
|
220
|
+
* to survive).
|
|
221
|
+
*/
|
|
222
|
+
interface InFlightExecution {
|
|
223
|
+
promise: Promise<CacheEnvelope>;
|
|
224
|
+
registeredAt: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const inFlightExecutions = new Map<string, InFlightExecution>();
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* How long a follower trusts an in-flight leader before evicting it and
|
|
231
|
+
* running fresh. High enough that a slow-but-healthy upstream never triggers
|
|
232
|
+
* duplicate work (a legitimate cached call taking >15s is already pathological);
|
|
233
|
+
* low enough to bound the blast radius of a wedged leader to seconds, not the
|
|
234
|
+
* isolate lifetime. Aligned with SHELL_CAPTURE_MAX_WAIT_MS.
|
|
235
|
+
*/
|
|
236
|
+
const IN_FLIGHT_LEADER_MAX_WAIT_MS = 15_000;
|
|
237
|
+
|
|
238
|
+
/** Distinguishes a leader timeout from a leader rejection in raceLeader. */
|
|
239
|
+
const LEADER_TIMED_OUT = Symbol("leader-timed-out");
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Await a leader's envelope for at most `remainingMs`. Resolves with the
|
|
243
|
+
* envelope, `undefined` on leader rejection (the leader already cleared its
|
|
244
|
+
* entry — caller falls through to a fresh run), or {@link LEADER_TIMED_OUT}
|
|
245
|
+
* when the window expires first (caller must evict the entry itself).
|
|
208
246
|
*/
|
|
209
|
-
|
|
247
|
+
function raceLeader(
|
|
248
|
+
promise: Promise<CacheEnvelope>,
|
|
249
|
+
remainingMs: number,
|
|
250
|
+
): Promise<CacheEnvelope | undefined | typeof LEADER_TIMED_OUT> {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const timer = setTimeout(() => resolve(LEADER_TIMED_OUT), remainingMs);
|
|
253
|
+
promise.then(
|
|
254
|
+
(envelope) => {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
resolve(envelope);
|
|
257
|
+
},
|
|
258
|
+
() => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
resolve(undefined);
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
210
265
|
|
|
211
266
|
// ============================================================================
|
|
212
267
|
// Core: registerCachedFunction
|
|
@@ -541,20 +596,34 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
541
596
|
// requests. The store write stays exactly once (the leader's).
|
|
542
597
|
const existing = inFlightExecutions.get(cacheKey);
|
|
543
598
|
if (existing) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
599
|
+
const remainingMs =
|
|
600
|
+
IN_FLIGHT_LEADER_MAX_WAIT_MS - (Date.now() - existing.registeredAt);
|
|
601
|
+
const raced =
|
|
602
|
+
remainingMs <= 0
|
|
603
|
+
? LEADER_TIMED_OUT
|
|
604
|
+
: await raceLeader(existing.promise, remainingMs);
|
|
605
|
+
if (raced === LEADER_TIMED_OUT) {
|
|
606
|
+
// Wedged (or context-orphaned) leader: evict so this call and every
|
|
607
|
+
// later one run fresh. Guard the delete so a newer leader's entry is
|
|
608
|
+
// never removed; the stale leader's own clearSelf is identity-guarded
|
|
609
|
+
// the same way, so it can't evict our replacement if it settles late.
|
|
610
|
+
if (inFlightExecutions.get(cacheKey) === existing) {
|
|
611
|
+
inFlightExecutions.delete(cacheKey);
|
|
612
|
+
}
|
|
613
|
+
reportCacheError(
|
|
614
|
+
new Error(
|
|
615
|
+
`in-flight leader did not settle within ${IN_FLIGHT_LEADER_MAX_WAIT_MS}ms; evicted — executing fresh`,
|
|
616
|
+
),
|
|
617
|
+
"cache-read",
|
|
618
|
+
`[use cache] "${id}" inflight-timeout`,
|
|
619
|
+
);
|
|
620
|
+
// Fall through to a fresh execution below (this call becomes leader).
|
|
621
|
+
} else if (raced) {
|
|
553
622
|
try {
|
|
554
623
|
return await serveCached({
|
|
555
|
-
value:
|
|
556
|
-
handles:
|
|
557
|
-
tags:
|
|
624
|
+
value: raced.serialized,
|
|
625
|
+
handles: raced.handles,
|
|
626
|
+
tags: raced.tags,
|
|
558
627
|
shouldRevalidate: false,
|
|
559
628
|
});
|
|
560
629
|
} catch (error) {
|
|
@@ -566,6 +635,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
566
635
|
// Fall through to a fresh execution below.
|
|
567
636
|
}
|
|
568
637
|
}
|
|
638
|
+
// raced === undefined: leader rejected; its map entry is already
|
|
639
|
+
// cleared, so fall through to a fresh run.
|
|
569
640
|
}
|
|
570
641
|
|
|
571
642
|
// This call becomes the leader. Register a deferred envelope so concurrent
|
|
@@ -582,9 +653,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
582
653
|
// Followers attach their own catch; guard the map's own reference so a
|
|
583
654
|
// rejected envelope with no waiter is not an unhandled rejection.
|
|
584
655
|
envelopePromise.catch(() => {});
|
|
585
|
-
inFlightExecutions.set(cacheKey,
|
|
656
|
+
inFlightExecutions.set(cacheKey, {
|
|
657
|
+
promise: envelopePromise,
|
|
658
|
+
registeredAt: Date.now(),
|
|
659
|
+
});
|
|
586
660
|
const clearSelf = (): void => {
|
|
587
|
-
if (inFlightExecutions.get(cacheKey) === envelopePromise) {
|
|
661
|
+
if (inFlightExecutions.get(cacheKey)?.promise === envelopePromise) {
|
|
588
662
|
inFlightExecutions.delete(cacheKey);
|
|
589
663
|
}
|
|
590
664
|
};
|
|
@@ -85,8 +85,15 @@ export const MAX_REVALIDATION_INTERVAL = 30;
|
|
|
85
85
|
*
|
|
86
86
|
* This is the default; override per store via
|
|
87
87
|
* `CFCacheStoreOptions.edgeLookupTimeoutMs` (<= 0 disables the budget).
|
|
88
|
+
*
|
|
89
|
+
* 25ms, raised from 10: production Workers logs (autobarn pilot) showed the
|
|
90
|
+
* 10ms budget firing frequently on cold colos where the first Cache API touch
|
|
91
|
+
* is slow but healthy — each false positive downgrades a warm L1 HIT to an
|
|
92
|
+
* L2/render round trip that costs far more than the 15ms of extra patience.
|
|
93
|
+
* A genuinely degraded colo still gets cut off; tune per store for
|
|
94
|
+
* shell-critical routes.
|
|
88
95
|
*/
|
|
89
|
-
export const EDGE_LOOKUP_TIMEOUT_MS =
|
|
96
|
+
export const EDGE_LOOKUP_TIMEOUT_MS = 25;
|
|
90
97
|
|
|
91
98
|
/**
|
|
92
99
|
* Maximum time (ms) to wait for the BODY of a matched L1 entry to be read
|