@rangojs/router 0.17.0 → 0.18.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.
- package/README.md +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Testing a PPR shell — serveShellRequest
|
|
2
|
+
|
|
3
|
+
**Layer:** RSC unit (react-server project) · **Import:** `@rangojs/router/testing/flight` · **DSL it tests:** the `ppr` path option (see `/ppr`)
|
|
4
|
+
|
|
5
|
+
`serveShellRequest(router, url, options?)` serves one GET through your router's production request handler — built as `router.fetch` builds it, with the router's `nonce`, `version`, `cache` config and middleware — and settles every background task the request scheduled before it resolves. So the first request for a `ppr` route is a MISS whose real capture has stored its shell (`putShell` landed), and the next request with the same store is a real HIT: shell read, version gates, the tail replaying the handler output the capture baked (no handler runs), live loaders. A `partial` request is a real client navigation with its `x-rango-ppr-replay` decision. Flight is real.
|
|
6
|
+
|
|
7
|
+
The one stub is the HTML step (the SSR module), because `react-dom/server` does not load under the react-server condition:
|
|
8
|
+
|
|
9
|
+
| SSR step | Under `serveShellRequest` |
|
|
10
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `renderHTML` | passes the Flight stream through: a document body is its Flight payload |
|
|
12
|
+
| `captureShellHTML` | stores, as the prelude, the Flight text the capture rendered before it went quiet; `postponed` is null |
|
|
13
|
+
| `resumeShellHTML` | passes the tail's Flight through: a HIT body is the stored prelude, then the tail's Flight payload |
|
|
14
|
+
|
|
15
|
+
So `prelude` is the shell as captured (Flight text, not HTML): a value the shell froze appears there, and a hole (a live loader under `loading()`) does not.
|
|
16
|
+
|
|
17
|
+
## API
|
|
18
|
+
|
|
19
|
+
### Options — `ServeShellRequestOptions`
|
|
20
|
+
|
|
21
|
+
| Field | Type | Meaning |
|
|
22
|
+
| ------------ | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
|
|
24
|
+
| `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
|
|
25
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
|
|
26
|
+
| `partial` | `true \| { from?: string; segments?: string[] }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). |
|
|
27
|
+
|
|
28
|
+
### Returns — `ServeShellRequestResult`
|
|
29
|
+
|
|
30
|
+
| Field | Type | Meaning |
|
|
31
|
+
| -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
|
|
33
|
+
| `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
|
|
34
|
+
| `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
|
|
35
|
+
| `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
|
|
36
|
+
| `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
|
|
37
|
+
| `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
|
|
38
|
+
| `response` | `Response` | Status and headers. Its body is already read. |
|
|
39
|
+
| `body` | `string` | The body text. |
|
|
40
|
+
|
|
41
|
+
### `resetShellTestState(): Promise<void>`
|
|
42
|
+
|
|
43
|
+
A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
|
|
44
|
+
|
|
45
|
+
## Recipe
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// test/product-shell.rsc-test.tsx — in the react-server project (./setup.md)
|
|
49
|
+
import { beforeEach, expect, it } from "vitest";
|
|
50
|
+
import { createRouter, updateTag, urls } from "@rangojs/router";
|
|
51
|
+
import { MemorySegmentCacheStore } from "@rangojs/router/cache";
|
|
52
|
+
import { runInRequestContext } from "@rangojs/router/testing";
|
|
53
|
+
import {
|
|
54
|
+
resetShellTestState,
|
|
55
|
+
serveShellRequest,
|
|
56
|
+
} from "@rangojs/router/testing/flight";
|
|
57
|
+
import { ProductPage } from "../src/pages/product"; // renders the product name
|
|
58
|
+
import { StockLoader } from "../src/loaders/stock"; // live stock count
|
|
59
|
+
|
|
60
|
+
const router = createRouter().routes(
|
|
61
|
+
urls(({ path, loader, loading }) => [
|
|
62
|
+
path("/product/:id", ProductPage, { name: "product", ppr: { tags: ["catalog"] } }, () => [
|
|
63
|
+
loader(StockLoader),
|
|
64
|
+
loading(<p>Checking stock…</p>),
|
|
65
|
+
]),
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
beforeEach(() => resetShellTestState());
|
|
70
|
+
|
|
71
|
+
it("serves the captured shell and fills the stock hole live", async () => {
|
|
72
|
+
const cacheStore = new MemorySegmentCacheStore();
|
|
73
|
+
|
|
74
|
+
const miss = await serveShellRequest(router, "/product/1", { cacheStore });
|
|
75
|
+
expect(miss.shellStatus).toBe("MISS"); // a normal render; the capture stored the shell
|
|
76
|
+
|
|
77
|
+
const hit = await serveShellRequest(router, "/product/1", { cacheStore });
|
|
78
|
+
expect(hit.shellStatus).toBe("HIT");
|
|
79
|
+
expect(hit.prelude).toContain("Widget"); // frozen at capture
|
|
80
|
+
expect(hit.prelude).not.toContain("in stock"); // the hole
|
|
81
|
+
expect(hit.flight).toContain("in stock"); // rendered by this request
|
|
82
|
+
|
|
83
|
+
await runInRequestContext(() => updateTag("catalog"), { cacheStore });
|
|
84
|
+
const recapture = await serveShellRequest(router, "/product/1", { cacheStore });
|
|
85
|
+
expect(recapture.shellStatus).toBe("MISS");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("replays the captured segments on a client navigation", async () => {
|
|
89
|
+
const cacheStore = new MemorySegmentCacheStore();
|
|
90
|
+
await serveShellRequest(router, "/product/2", { cacheStore });
|
|
91
|
+
|
|
92
|
+
const nav = await serveShellRequest(router, "/product/2", {
|
|
93
|
+
cacheStore,
|
|
94
|
+
partial: { from: "/" },
|
|
95
|
+
});
|
|
96
|
+
expect(nav.replayStatus).toEqual({ outcome: "HIT", freshness: "fresh" });
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A store built per request from a function `cache` config gets the request's execution context (`ctx`), so its own `waitUntil` writes (`CFCacheStore({ ctx })`) settle with the call. A store built outside the request with its own `waitUntil` (`VercelCacheStore`'s option) is not settled by the call. For `CFCacheStore`, stub the `caches` global and pass a KV double in `env`; for `VercelCacheStore`, pass a `cache` double. A store's shell memo then behaves as in production (count your double's reads across two HITs).
|
|
101
|
+
|
|
102
|
+
## A HIT runs no handler
|
|
103
|
+
|
|
104
|
+
A HIT replays what the capture's handlers produced — their elements, the promises they passed or pushed, their handle values — and runs only middleware and loaders. Count runs to pin it:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
let pageRuns = 0;
|
|
108
|
+
// the page handler does `pageRuns += 1` and renders `page-run-${pageRuns}`
|
|
109
|
+
|
|
110
|
+
it("a HIT runs no handler", async () => {
|
|
111
|
+
const cacheStore = new MemorySegmentCacheStore();
|
|
112
|
+
await serveShellRequest(router, "/product/3", { cacheStore }); // MISS + capture
|
|
113
|
+
const baked = pageRuns; // the capture's run
|
|
114
|
+
|
|
115
|
+
const hit = await serveShellRequest(router, "/product/3", { cacheStore });
|
|
116
|
+
expect(hit.shellStatus).toBe("HIT");
|
|
117
|
+
expect(pageRuns).toBe(baked);
|
|
118
|
+
expect(hit.flight).toContain(`page-run-${baked}`);
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Request-partitioned shells
|
|
123
|
+
|
|
124
|
+
A route whose `cache()` record is partitioned by the request (`cache({ key })`, or the store's `keyGenerator`) has a shell per partition. Serve each partition with the request headers or cookies its key reads; the result's `key` is the key the serve path resolved, partition included, and `readEntry()` reads it:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// cache({ ttl: 300, key: (ctx) => `tier:${ctx.request.headers.get("x-tier")}` }, () => [
|
|
128
|
+
// path("/pricing", PricingPage, { ppr: true }),
|
|
129
|
+
// ])
|
|
130
|
+
const tier = (name: string) => ({ cacheStore, headers: { "x-tier": name } });
|
|
131
|
+
|
|
132
|
+
await serveShellRequest(router, "/pricing", tier("gold")); // MISS + capture
|
|
133
|
+
const silver = await serveShellRequest(router, "/pricing", tier("silver"));
|
|
134
|
+
expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
|
|
135
|
+
const gold = await serveShellRequest(router, "/pricing", tier("gold"));
|
|
136
|
+
expect(gold.shellStatus).toBe("HIT");
|
|
137
|
+
expect(gold.key).toBe(shellCacheKey("/pricing", undefined, "tier:gold"));
|
|
138
|
+
expect(await gold.readEntry()).not.toBeNull();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Under nested keyed `cache()` boundaries the partition is their `key()` results composed, outermost first (see `/caching`, "Keys nest"). Pass them to `shellCacheKey` as an array and it composes them the same way:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// cache({ key: tierKey }, () => [
|
|
145
|
+
// layout(TierLayout, () => [
|
|
146
|
+
// cache({ key: (ctx) => `v:${variantOf(ctx)}` }, () => [path("/plans", PlansPage, { ppr: true })]),
|
|
147
|
+
// ]),
|
|
148
|
+
// ])
|
|
149
|
+
expect(result.key).toBe(
|
|
150
|
+
shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
|
|
151
|
+
);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
|
|
155
|
+
|
|
156
|
+
## Handles and loader data
|
|
157
|
+
|
|
158
|
+
The result carries no structured `handles` map (unlike `renderHandler`): a handle value and loader data ride the payload, so you assert them as text in `prelude` and `flight`. A value the handler pushed is in the prelude and in every HIT's tail; a live loader's data is only in the tail. No public helper decodes a payload string into values: `normalizeFlight` and the `flightMatchers` (`toMatchFlight`) work on the text too.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// The handler pushed ctx.use(Meta)({ title: "Widget - Shop" })
|
|
162
|
+
expect(hit.prelude).toContain('"title":"Widget - Shop"');
|
|
163
|
+
expect(hit.flight).toContain('"title":"Widget - Shop"');
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Caveats
|
|
167
|
+
|
|
168
|
+
What the HTML stub cannot reproduce, so keep it in e2e:
|
|
169
|
+
|
|
170
|
+
- The prelude's `<body` sanity gate. A route whose production capture refuses for a root postpone (a live loader read with no `loading()` or `<Suspense>` above it) stores a shell here.
|
|
171
|
+
- The capture deadline. `ppr.captureTimeout` (default 15s) bounds the whole capture: the match, the handler output, then the shell render. A capture that runs out of it stores nothing and is not retried in place, here as in production, so the call waits one deadline: give such a route a short `captureTimeout` in a test (Vitest's default test timeout is 5s). One difference: when the handler output settled but the shell render does not go quiet in what is left, the stub returns no shell, while production goes on to its abort and can still store the prelude rendered by then.
|
|
172
|
+
- SSR render errors. In production a fizz render error during the capture refuses it (#915). The stub renders no HTML and reports none, so a shell whose client component throws during SSR is stored here. A server component that throws still refuses the capture, as in production.
|
|
173
|
+
- Fizz resume of the holes, bootstrap scripts, nonce injection, and browser resume.
|
|
174
|
+
|
|
175
|
+
Other caveats:
|
|
176
|
+
|
|
177
|
+
- A HIT's tail carries replayed segments as Flight fragments inside JSON strings, so their quotes are escaped (`\"`). Match plain text, or unescape before matching JSON-shaped text.
|
|
178
|
+
- Call `resetShellTestState()` in `beforeEach` (see above). Without it, a URL a previous test left backed off, or a shell memoized by another `CFCacheStore`, changes what the next test sees.
|
|
179
|
+
- `readEntry()` reads the document entry under `key`. A partial request that finds no entry captures a navigation-only entry, which is stored apart from it.
|
|
180
|
+
- `updateTag()` needs a request context with the store: call it through `runInRequestContext(fn, { cacheStore })` or your app's own endpoint (`dispatch`). A store refuses a capture that starts in the invalidation's millisecond (the invalidation wins) and backs the key off; `serveShellRequest` starts each request in a later millisecond than the call, so an invalidation made before the call cannot collide with it.
|
|
181
|
+
- GET only. Server actions are not served; test their effects with `runInRequestContext`.
|
|
182
|
+
- Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
|
|
183
|
+
|
|
184
|
+
## See also
|
|
185
|
+
|
|
186
|
+
- `/ppr` — the DSL this tests; `/caching` — stores and tags
|
|
187
|
+
- Siblings: [`./cache-prerender.md`](./cache-prerender.md) (the shell-status helpers and the e2e recipe), [`./render-handler.md`](./render-handler.md)
|
|
188
|
+
- Long-form prose: [docs/testing.md](https://github.com/rangojs/rango/blob/main/packages/rangojs-router/docs/testing.md) — section "serveShellRequest — a real PPR capture and HIT"
|
package/skills/theme/SKILL.md
CHANGED
|
@@ -86,10 +86,13 @@ from `@rangojs/router/theme` in `<head>` instead. It takes the resolved config
|
|
|
86
86
|
are rejected with a console warning. Both are typed optional because they only
|
|
87
87
|
exist when `theme` is configured.
|
|
88
88
|
|
|
89
|
+
`ctx.theme` is the visitor's theme cookie, so a handler reads it only on a
|
|
90
|
+
route whose output is rendered per request:
|
|
91
|
+
|
|
89
92
|
```typescript
|
|
90
93
|
import type { Middleware } from "@rangojs/router";
|
|
91
94
|
|
|
92
|
-
// In a handler
|
|
95
|
+
// In a handler on a route without ppr or cache()
|
|
93
96
|
path("/settings", (ctx) => {
|
|
94
97
|
const currentTheme = ctx.theme; // "light" | "dark" | "system" | undefined
|
|
95
98
|
return <SettingsPage theme={currentTheme} />;
|
|
@@ -105,9 +108,41 @@ export const themeFromQuery: Middleware = async (ctx, next) => {
|
|
|
105
108
|
};
|
|
106
109
|
```
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
### On `ppr` and `cache()` routes
|
|
112
|
+
|
|
113
|
+
A `ppr` shell and a `cache()` entry are shared by every visitor, so a handler
|
|
114
|
+
there must not read the visitor's theme. `ctx.theme` (handler and middleware)
|
|
115
|
+
and `getRequestContext().theme` are guarded like `cookies()`:
|
|
116
|
+
|
|
117
|
+
| Where the theme is read | What happens |
|
|
118
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
119
|
+
| a handler on a `ppr` route | The shell capture is refused (warned once per URL); every request stays a MISS |
|
|
120
|
+
| inside a `cache()` boundary | Throws on a cache miss, like `cookies()` |
|
|
121
|
+
| inside a `"use cache"` function (handler or middleware) | Throws; read it outside and pass it in as an argument |
|
|
122
|
+
| after `ctx.dynamic()` on `ppr` | Allowed: that render is never captured |
|
|
123
|
+
| middleware `ctx.set()` of the theme, read by a handler | Not guarded: the first visitor's theme bakes into the shell or `cache()` entry, like a session object |
|
|
124
|
+
| a route with no `ppr` or `cache()` above it, or middleware | Allowed: rendered per request |
|
|
125
|
+
| a loader with its own `cache()` and no `key()` | The miss fails and stores nothing (#972), like a `cookies()` read there; key the theme |
|
|
126
|
+
|
|
127
|
+
The theme getters are read-only and non-enumerable: `{ ...ctx }` and
|
|
128
|
+
`Object.assign({}, ctx)` do not carry `theme`, and assigning it throws.
|
|
129
|
+
|
|
130
|
+
`ctx.setTheme()` throws in a handler on these routes too, like any response
|
|
131
|
+
write there; call it from middleware.
|
|
132
|
+
|
|
133
|
+
Read the theme where it is per request instead:
|
|
134
|
+
|
|
135
|
+
- `useTheme()` in a client component. The `<html>` class is right before
|
|
136
|
+
paint (the theme script sets it). On a `ppr` HIT the shell carries the
|
|
137
|
+
no-cookie default (`defaultTheme`), whoever captured it: a visitor with no
|
|
138
|
+
stored theme sees the default, and one with a stored theme sees it after
|
|
139
|
+
hydration.
|
|
140
|
+
- A live loader (no `ssr: false`). Loaders do not get `ctx.theme` or
|
|
141
|
+
`ctx.setTheme`; read the cookie directly. A live loader runs on every
|
|
142
|
+
request, HITs included, and on a `ppr` route needs `loading()` or an inline
|
|
143
|
+
`<Suspense>` above its reader. A bake-lane loader (`ssr: false`) runs at
|
|
144
|
+
capture, where this `cookies()` read refuses the capture, and so does a
|
|
145
|
+
loader a handler awaits with `ctx.use()`.
|
|
111
146
|
|
|
112
147
|
```typescript
|
|
113
148
|
import { cookies, createLoader } from "@rangojs/router";
|
|
@@ -118,6 +153,19 @@ export const ThemedLoader = createLoader(async () => {
|
|
|
118
153
|
});
|
|
119
154
|
```
|
|
120
155
|
|
|
156
|
+
If you bind it with its own `cache()`, the entry is shared across users, so the
|
|
157
|
+
miss fails unless it has a `key()`. Any `key()` switches that check off, so
|
|
158
|
+
make it include the theme:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
loader(ThemedLoader, () => [
|
|
162
|
+
cache({
|
|
163
|
+
ttl: 300,
|
|
164
|
+
key: () => `themed:${cookies().get("theme")?.value ?? "system"}`,
|
|
165
|
+
}),
|
|
166
|
+
]);
|
|
167
|
+
```
|
|
168
|
+
|
|
121
169
|
## Client
|
|
122
170
|
|
|
123
171
|
```tsx
|
|
@@ -103,7 +103,8 @@ interface PaginationData {
|
|
|
103
103
|
}
|
|
104
104
|
export const Pagination = createVar<PaginationData>();
|
|
105
105
|
|
|
106
|
-
// Non-cacheable var — reading inside cache()
|
|
106
|
+
// Non-cacheable var — reading inside cache(), "use cache", or a PPR shell
|
|
107
|
+
// capture throws at runtime
|
|
107
108
|
interface SessionData {
|
|
108
109
|
userId: string;
|
|
109
110
|
}
|
|
@@ -115,7 +116,14 @@ export const Session = createVar<SessionData>({ cache: false });
|
|
|
115
116
|
Write-level escalation is also supported: `ctx.set(Var, value, { cache: false })`
|
|
116
117
|
marks a specific write as non-cacheable even if the var itself is cacheable.
|
|
117
118
|
"Least cacheable wins" — if either says `cache: false`, the value throws on
|
|
118
|
-
read inside `cache()` or `"use cache"`.
|
|
119
|
+
read inside `cache()` or `"use cache"`. On a `ppr` route the read also throws
|
|
120
|
+
anywhere the shell capture waits for it (a handler, a promise it passes or
|
|
121
|
+
pushes, an async server component, an `ssr: false` loader, a loader a handler
|
|
122
|
+
awaits) and refuses the capture: the route keeps rendering without a shell,
|
|
123
|
+
and a warning fires once per key. A loader without `ssr: false` that no
|
|
124
|
+
handler awaits may read it, unless it is bound with its own `cache()` and no
|
|
125
|
+
`key()`: that miss fails, since the entry is shared (`/loader` → "Cache
|
|
126
|
+
Key").
|
|
119
127
|
|
|
120
128
|
### Producer (handler, layout, or middleware)
|
|
121
129
|
|
|
@@ -142,6 +142,11 @@ Different functions always produce different cache keys, even for the same route
|
|
|
142
142
|
This is important for intercepted routes -- the path handler and intercept handler
|
|
143
143
|
each have their own `functionId` and therefore their own cache entries.
|
|
144
144
|
|
|
145
|
+
The key is only what the function declares: its id and arguments. A call made
|
|
146
|
+
inside a route `cache()` with a `key()` does not inherit that key, so a value
|
|
147
|
+
the route partitions by (a tier header, a session) must be passed in as an
|
|
148
|
+
argument. The same holds for a loader's own `cache()` (`/loader` → "Cache Key").
|
|
149
|
+
|
|
145
150
|
### Route context is folded into the key
|
|
146
151
|
|
|
147
152
|
The tainted `ctx` object is excluded from arg serialization (see below), but
|
|
@@ -261,7 +266,10 @@ into the shared cache entry like any other computed data. (`cookies()` and
|
|
|
261
266
|
`headers()` still throw there: that guard follows the cached body's whole
|
|
262
267
|
async chain, loaders included.) Same rule across `cache()` and the PPR shell:
|
|
263
268
|
handler/cached-scope consumption = baked copy, client-side `useLoader` = live
|
|
264
|
-
(the consumption-lane rule, `/rango` → Invariants).
|
|
269
|
+
(the consumption-lane rule, `/rango` → Invariants). The PPR shell is stricter
|
|
270
|
+
about the loader's own reads: during a capture, `cookies()`, `headers()`, and a
|
|
271
|
+
`{ cache: false }` variable read inside a handler-consumed loader refuse the
|
|
272
|
+
capture.
|
|
265
273
|
|
|
266
274
|
### Side-Effect Guards
|
|
267
275
|
|
|
@@ -504,20 +512,31 @@ built into the server bundle, see `/prerender`.)
|
|
|
504
512
|
Entries are tagged by the profile's `tags` plus any runtime `cacheTag(...tags)`
|
|
505
513
|
calls in the function body. `cacheTag` has two forms: inside a `"use cache"`
|
|
506
514
|
function it tags that entry; called during a request render outside
|
|
507
|
-
`"use cache"` it tags the
|
|
508
|
-
|
|
509
|
-
(
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
515
|
+
`"use cache"` it tags what the render stores: the enclosing route `cache()`
|
|
516
|
+
entry, a cached loader's own entry when called in its body, and the `ppr`
|
|
517
|
+
shell or document-cache entry built from them (on a `ppr` route, only when
|
|
518
|
+
shell material calls it). See `/caching` → "Tag-Based Invalidation". All
|
|
519
|
+
built-in stores (`MemorySegmentCacheStore`, `CFCacheStore`,
|
|
520
|
+
`VercelCacheStore`) index by tag. Invalidate on demand with
|
|
521
|
+
`updateTag(...tags)` (awaitable; for server actions) or
|
|
522
|
+
`revalidateTag(...tags)` (background, non-blocking; for route
|
|
523
|
+
handlers/webhooks). Both evict rather than mark stale, and the request that
|
|
524
|
+
calls either reads its own writes; `updateTag()` also waits for the durable
|
|
525
|
+
write. PPR shell reads on `CFCacheStore`/`VercelCacheStore` go through
|
|
526
|
+
per-isolate memos, which the invalidating user skips via the fresh-reads
|
|
527
|
+
cookie. For `CFCacheStore`, cross-colo invalidation needs a `kv` namespace
|
|
528
|
+
(markers live in that same namespace) or `tagPurge`. The separate
|
|
529
|
+
`revalidate()` export is the client-update control (which segments re-render
|
|
530
|
+
on a navigation or action), not a cache bust.
|
|
531
|
+
|
|
532
|
+
A route `cache()` entry stores the tags of the `"use cache"` reads inside it,
|
|
533
|
+
so `updateTag()` of one evicts the enclosing entry and any `ppr` shell or
|
|
534
|
+
document built from it. A loader's own `cache()` entry does the same for the
|
|
535
|
+
reads in its body. The exception is a `"use cache"` function called inside
|
|
536
|
+
another `"use cache"` function: its tags do not reach the enclosing entry, so
|
|
537
|
+
invalidating one of them neither evicts the outer entry nor keeps this request
|
|
538
|
+
from joining or storing an outer call that started before the invalidation
|
|
539
|
+
([#980](https://github.com/rangojs/rango/issues/980)).
|
|
521
540
|
|
|
522
541
|
## Interaction with Other Caching
|
|
523
542
|
|
|
@@ -531,7 +550,11 @@ boundary, not before (see `/cache-guide` → "Nesting rule").
|
|
|
531
550
|
| `Prerender()` | Route segment tree | Build-time | Pre-render known params, optional live fallback |
|
|
532
551
|
|
|
533
552
|
Inside a `ppr` shell, a `"use cache"` value that renders as shell material is
|
|
534
|
-
pinned at capture time for the life of that shell (see `/ppr` → Pitfalls).
|
|
553
|
+
pinned at capture time for the life of that shell (see `/ppr` → Pitfalls). A
|
|
554
|
+
hole's own read (in a loader without `ssr: false` under `loading()` or an
|
|
555
|
+
inline `<Suspense>`) returns the current entry, not the shell's copy, unless a
|
|
556
|
+
loader the capture ran (an `ssr: false` loader, or one a handler awaits) read
|
|
557
|
+
the same entry.
|
|
535
558
|
|
|
536
559
|
## Dev Mode and tests
|
|
537
560
|
|
package/skills/vercel/SKILL.md
CHANGED
|
@@ -89,12 +89,24 @@ import { getCache, waitUntil } from "@vercel/functions";
|
|
|
89
89
|
|
|
90
90
|
const defaults = { ttl: 60, swr: 300 };
|
|
91
91
|
const memoryStore = new MemorySegmentCacheStore({ defaults });
|
|
92
|
+
// One handle per process: getCache() resolves the platform cache on every
|
|
93
|
+
// call, and VercelCacheStore keeps its PPR shell and tag-marker memos per
|
|
94
|
+
// handle. After updateTag(), the same process misses at once, the rest of the
|
|
95
|
+
// region once its marker memo refreshes (memo.markerMaxStaleMs, default 2000),
|
|
96
|
+
// and the mutating user at once (the fresh-reads cookie skips the memos).
|
|
97
|
+
// Another region serves a memoized shell until memo.shellMs (default 2000)
|
|
98
|
+
// passes, since the tag markers are regional and only expireTag is global. A
|
|
99
|
+
// platform expireTag issued outside rango writes no marker, so every memo
|
|
100
|
+
// serves the shell until its window passes.
|
|
101
|
+
const runtimeCache = process.env.VERCEL
|
|
102
|
+
? getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })
|
|
103
|
+
: undefined;
|
|
92
104
|
|
|
93
105
|
function resolveCache() {
|
|
94
|
-
if (
|
|
106
|
+
if (runtimeCache) {
|
|
95
107
|
return {
|
|
96
108
|
store: new VercelCacheStore({
|
|
97
|
-
cache:
|
|
109
|
+
cache: runtimeCache,
|
|
98
110
|
waitUntil,
|
|
99
111
|
defaults,
|
|
100
112
|
}),
|
|
@@ -245,7 +245,7 @@ interface TransitionConfig {
|
|
|
245
245
|
Timing follows the route's rendering contract:
|
|
246
246
|
|
|
247
247
|
- On an ordinary route it runs after the route handler during fresh resolution, so `get()` can read handler- and middleware-set context. Cache/prerender hits replay the stored decision.
|
|
248
|
-
- On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers
|
|
248
|
+
- On a `ppr` route it is automatically hoisted before route handlers and runs on every match, including runtime-cache, prerender, document-shell, and partial-navigation replay. It can read URL/params/action metadata, `env`, and middleware-set context, but not values set by route handlers, because a shell HIT never runs a handler.
|
|
249
249
|
|
|
250
250
|
Its context mirrors the `revalidate()` predicate args — the same navigation/action metadata — plus `get`/`env` for request-context reads:
|
|
251
251
|
|
|
@@ -14,6 +14,29 @@
|
|
|
14
14
|
/** Default prefix when `stateCookiePrefix` is unset or empty after sanitization. */
|
|
15
15
|
export const DEFAULT_STATE_COOKIE_PREFIX = "rango-state";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The fresh-reads cookie: the state cookie PREFIX plus this suffix
|
|
19
|
+
* (`rango-state-fresh` by default). A response whose request ran
|
|
20
|
+
* updateTag()/revalidateTag() sets it (HttpOnly, SameSite=Lax, Path=/,
|
|
21
|
+
* Max-Age = the stores' longest memo staleness), and the same user's requests
|
|
22
|
+
* carrying it skip the stores' isolate memos (src/cache/isolate-tag-memo.ts).
|
|
23
|
+
* Keyed by prefix, not by router, so a mutation through one router also
|
|
24
|
+
* reaches the memos another router on the host reads (they often share a
|
|
25
|
+
* store); a router with its own `stateCookiePrefix` gets its own cookie.
|
|
26
|
+
*/
|
|
27
|
+
const FRESH_READS_COOKIE_SUFFIX = "-fresh";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The fresh-reads cookie name for a router's resolved state cookie name
|
|
31
|
+
* (`{prefix}_{routerId}`; the prefix never contains `_`, see
|
|
32
|
+
* router/state-cookie-name.ts).
|
|
33
|
+
*/
|
|
34
|
+
export function freshReadsCookieName(stateCookieName: string): string {
|
|
35
|
+
const cut = stateCookieName.indexOf("_");
|
|
36
|
+
const prefix = cut < 0 ? stateCookieName : stateCookieName.slice(0, cut);
|
|
37
|
+
return `${prefix}${FRESH_READS_COOKIE_SUFFIX}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
/** Internal response header carrying the keepClientCache() directive. */
|
|
18
41
|
export const KEEP_CACHE_HEADER = "x-rango-keep-cache";
|
|
19
42
|
|
|
@@ -82,6 +82,29 @@ export function pushHistoryWithIdx(
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The forced-MISS reload marker (`SHELL_MISS_PARAM` in rsc/shell-serve.ts,
|
|
87
|
+
* which the browser bundle must not import): a degraded PPR shell HIT
|
|
88
|
+
* reloads the page with it, and the server renders a marked request like a
|
|
89
|
+
* cache miss.
|
|
90
|
+
*/
|
|
91
|
+
export const SHELL_MISS_MARKER: string = "_rsc_shell";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Drop the forced-MISS marker from the address bar at boot, keeping the
|
|
95
|
+
* history entry's state (initBrowserApp calls it before any location read).
|
|
96
|
+
* Left in place, a refresh or a shared link would stay off the shell and the
|
|
97
|
+
* app's search params would carry it. The server strips the same marker from
|
|
98
|
+
* the request before rendering (shell-serve.ts withoutShellMissMarker), so
|
|
99
|
+
* the page was rendered for the clean URL and the hydrating client agrees.
|
|
100
|
+
*/
|
|
101
|
+
export function stripShellMissMarker(): void {
|
|
102
|
+
const url = new URL(window.location.href);
|
|
103
|
+
if (!url.searchParams.has(SHELL_MISS_MARKER)) return;
|
|
104
|
+
url.searchParams.delete(SHELL_MISS_MARKER);
|
|
105
|
+
window.history.replaceState(window.history.state, "", url.href);
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
/**
|
|
86
109
|
* Merge server-set location state into the current history entry.
|
|
87
110
|
* Replaces the current history state and dispatches notification event
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./intercept-utils.js";
|
|
40
40
|
import { createAppShellRef } from "./app-shell.js";
|
|
41
41
|
import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
|
|
42
|
+
import { stripShellMissMarker } from "./history-state.js";
|
|
42
43
|
import { setActiveInterceptTargets } from "../client-urls/navigation.js";
|
|
43
44
|
|
|
44
45
|
// Vite HMR types are provided by vite/client
|
|
@@ -169,6 +170,10 @@ export async function initBrowserApp(
|
|
|
169
170
|
} = options;
|
|
170
171
|
|
|
171
172
|
bootLog("initBrowserApp start");
|
|
173
|
+
// Before any location read (the history key, the navigation store, the
|
|
174
|
+
// event controller): the server rendered the marked request for the clean
|
|
175
|
+
// URL, so the client starts from the same one and hydration agrees.
|
|
176
|
+
stripShellMissMarker();
|
|
172
177
|
bootLog("flight decode: awaiting initial payload from document stream");
|
|
173
178
|
const initialPayload =
|
|
174
179
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Shared Cache Key Utilities
|
|
3
3
|
*
|
|
4
4
|
* Deterministic normalization of search params and route params
|
|
5
|
-
* for cache key generation
|
|
6
|
-
* document-cache, and loader-cache.
|
|
5
|
+
* for cache key generation, and the composition of nested cache() keys.
|
|
6
|
+
* Used by cache-runtime, cache-scope, document-cache, and loader-cache.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { encodeKV } from "../encode-kv.js";
|
|
@@ -95,3 +95,23 @@ export function cacheKeyBase(
|
|
|
95
95
|
if (searchStr) key += `?${searchStr}`;
|
|
96
96
|
return key;
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The record key, or shell partition, of nested keyed cache() scopes (#970)
|
|
101
|
+
* from its parts, outermost first: the chain's `key()` results and, for an
|
|
102
|
+
* inner scope without its own `key()`, that scope's default key
|
|
103
|
+
* (CacheScope.resolveKeyFrom). One part is returned raw, so a single keyed
|
|
104
|
+
* scope keeps its key byte for byte. Two or more are joined by `|`, each
|
|
105
|
+
* URI-encoded (like partitionShellKey, rsc/shell-capture-constants.ts): an
|
|
106
|
+
* encoded part holds no `|`, so every tuple maps to its own key, of any
|
|
107
|
+
* depth. Raw, `("a|b", "c")` and `("a", "b|c")` named one record. A composed
|
|
108
|
+
* key holds no `:` either, so a single raw result with one (a prefixed
|
|
109
|
+
* `tier:...`, a `doc:` default key) never equals it; an unprefixed raw result
|
|
110
|
+
* can. Shared with the testing helper `shellCacheKey`, which composes a
|
|
111
|
+
* nested partition the same way.
|
|
112
|
+
*/
|
|
113
|
+
export function composeCacheKeys(parts: readonly string[]): string {
|
|
114
|
+
return parts.length === 1
|
|
115
|
+
? parts[0]
|
|
116
|
+
: parts.map((part) => encodeURIComponent(part)).join("|");
|
|
117
|
+
}
|
|
@@ -83,6 +83,28 @@ export function resolveSwrWindow(
|
|
|
83
83
|
return 0;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Minimum age (ms) of a stale PPR shell before a HIT schedules its recapture.
|
|
88
|
+
* A shell capped to a route cache() record inside its swr window is stale
|
|
89
|
+
* from the start (rsc/shell-capture.ts capShellWindow), so without a floor
|
|
90
|
+
* every HIT of it recaptured, and a capture's store write is one KV write per
|
|
91
|
+
* key: Cloudflare KV accepts one per second per key. Inside the floor the
|
|
92
|
+
* stale shell is still served. Per isolate: each isolate's stale HIT past the
|
|
93
|
+
* floor can schedule one (stampede-guarded per isolate).
|
|
94
|
+
*/
|
|
95
|
+
export const SHELL_MIN_RECAPTURE_INTERVAL_MS = 1_000;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether a stale shell HIT schedules its recapture (see the floor above).
|
|
99
|
+
* Fails open: an entry without a numeric `createdAt` recaptures.
|
|
100
|
+
*/
|
|
101
|
+
export function staleShellRecaptureDue(
|
|
102
|
+
entry: { readonly createdAt?: number },
|
|
103
|
+
now: number,
|
|
104
|
+
): boolean {
|
|
105
|
+
return !(now - (entry.createdAt as number) < SHELL_MIN_RECAPTURE_INTERVAL_MS);
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
/**
|
|
87
109
|
* Compute staleAt and expiresAt timestamps from TTL and SWR window.
|
|
88
110
|
*
|