@rangojs/router 0.0.0-experimental.140 → 0.0.0-experimental.141
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/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +229 -362
- package/skills/rango/SKILL.md +2 -2
- package/src/cache/cf/cf-cache-store.ts +8 -0
- package/src/cache/index.ts +0 -5
- package/src/cache/shell-snapshot.ts +368 -0
- package/src/cache/types.ts +66 -0
- package/src/cache/vercel/vercel-cache-store.ts +12 -1
- package/src/index.rsc.ts +1 -5
- package/src/index.ts +1 -17
- package/src/rsc/rsc-rendering.ts +279 -89
- package/src/rsc/shell-capture.ts +523 -65
- package/src/rsc/shell-serve.ts +124 -0
- package/src/server/context.ts +7 -0
- package/src/server/request-context.ts +52 -46
- package/src/ssr/index.tsx +38 -6
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/cache/shell-cache.ts +0 -386
- package/src/server/live.ts +0 -130
package/skills/rango/SKILL.md
CHANGED
|
@@ -109,7 +109,7 @@ stated, greppable contract.
|
|
|
109
109
|
| modal / soft navigation | `intercept()` | /intercept |
|
|
110
110
|
| pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
|
|
111
111
|
| feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
|
|
112
|
-
| cache the HTML shell, keep loaders live | `
|
|
112
|
+
| cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
|
|
113
113
|
| stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
|
|
114
114
|
|
|
115
115
|
## Invariants
|
|
@@ -153,7 +153,7 @@ Same words, different jobs — this is the most common source of the
|
|
|
153
153
|
| Next.js `revalidateTag` / `updateTag` | **Axis 1** (cache) | Cache busting by tag. Tag via `cache({ tags })` / `cacheTag(...tags)`; invalidate with `updateTag(...tags)` (awaitable, read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking). Built-in stores index by tag. No `revalidatePath` (path-based busting); use tags. |
|
|
154
154
|
| React Router / Remix `shouldRevalidate` | **Axis 2** | This is the correct mental model for Rango's `revalidate()`. |
|
|
155
155
|
| HTTP `Cache-Control` / ISR | **Axis 1** | Edge/document layer — see `/document-cache`. Separate from both `cache()` and `revalidate()`. |
|
|
156
|
-
| Next.js PPR (partial prerendering) | HTML shell layer | Same idea, different wiring: opt-in `
|
|
156
|
+
| Next.js PPR (partial prerendering) | HTML shell layer | Same idea, different wiring: the opt-in `ppr` path option captures at runtime (no build-time default); holes are render-defined — `loading()` subtrees plus pending promises under a consumer's own `<Suspense>`. See `/ppr`. |
|
|
157
157
|
| Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
|
|
158
158
|
|
|
159
159
|
See `/cache-guide` for the axis-1 decision guide, `/loader` and `/route` for
|
|
@@ -211,6 +211,10 @@ interface KVShellEnvelope {
|
|
|
211
211
|
t?: string[];
|
|
212
212
|
/** Timestamp when tags were attached (ms epoch) */
|
|
213
213
|
ta?: number;
|
|
214
|
+
/** initialTheme the capture render was built with (resume theme fidelity) */
|
|
215
|
+
i?: string;
|
|
216
|
+
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
|
|
217
|
+
sn?: import("../types.js").ShellSnapshotRecord[];
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
/**
|
|
@@ -1651,6 +1655,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1651
1655
|
prelude: envelope.p,
|
|
1652
1656
|
postponed: envelope.po,
|
|
1653
1657
|
reactVersion: envelope.rv,
|
|
1658
|
+
initialTheme: envelope.i,
|
|
1659
|
+
snapshot: envelope.sn,
|
|
1654
1660
|
createdAt: envelope.c,
|
|
1655
1661
|
},
|
|
1656
1662
|
shouldRevalidate,
|
|
@@ -1715,6 +1721,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1715
1721
|
e: staleAt + swrWindow * 1000,
|
|
1716
1722
|
t: tags,
|
|
1717
1723
|
ta: taggedAt,
|
|
1724
|
+
i: entry.initialTheme,
|
|
1725
|
+
sn: entry.snapshot,
|
|
1718
1726
|
};
|
|
1719
1727
|
return this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1720
1728
|
expirationTtl: totalTtl,
|
package/src/cache/index.ts
CHANGED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture data snapshot: recording + seeding stores for PPR shell parity.
|
|
3
|
+
*
|
|
4
|
+
* The scar tissue this fixes: a PPR HIT serves frozen prelude bytes, then a
|
|
5
|
+
* FULL FRESH Flight render for hydration. Any shell-baked (non-hole) content
|
|
6
|
+
* that drifts between capture time and hit time — a cache() segment with a
|
|
7
|
+
* shorter ttl than the shell, a tag-invalidated item — makes the fresh payload
|
|
8
|
+
* disagree with the prelude, so React throws a hydration text mismatch and
|
|
9
|
+
* regenerates the tree client-side (wiping the FOUC theme class, flashing
|
|
10
|
+
* content). See docs/design/ppr-shell-resume.md.
|
|
11
|
+
*
|
|
12
|
+
* The fix (Next.js resume-data-cache analog, adapted to Rango's cache rings):
|
|
13
|
+
* the CAPTURE render records every cache-store read-hit and write it performed
|
|
14
|
+
* (the {@link RecordingShellStore}); the record rides inside the ShellCacheEntry
|
|
15
|
+
* as its `snapshot`; on a HIT the tail render reads through a
|
|
16
|
+
* {@link SeededShellStore} overlay that serves those recorded values AS FRESH,
|
|
17
|
+
* so the shell region reproduces byte-identically while everything NOT recorded
|
|
18
|
+
* (the holes — masked loaders were never executed at capture, so their reads
|
|
19
|
+
* were never recorded) stays live.
|
|
20
|
+
*
|
|
21
|
+
* The invariant, verbatim: the snapshot is exactly the set of cache-store reads
|
|
22
|
+
* the capture render performed; replaying them on a HIT reproduces the shell
|
|
23
|
+
* content byte-identically; everything not recorded stays live.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
SegmentCacheStore,
|
|
28
|
+
CacheGetResult,
|
|
29
|
+
CacheItemResult,
|
|
30
|
+
CacheItemOptions,
|
|
31
|
+
CachedEntryData,
|
|
32
|
+
ShellCacheEntry,
|
|
33
|
+
ShellSnapshotRecord,
|
|
34
|
+
ShellSnapshotItemValue,
|
|
35
|
+
ShellSnapshotResponseValue,
|
|
36
|
+
} from "./types.js";
|
|
37
|
+
import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
|
|
38
|
+
import { isPerClientSignalHeader } from "../browser/cookie-name.js";
|
|
39
|
+
|
|
40
|
+
/** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
|
|
41
|
+
function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
|
|
42
|
+
return `${family}\u0000${key}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Serialize a Response to the snapshot's stored shape (base64 body). */
|
|
46
|
+
async function serializeResponse(
|
|
47
|
+
response: Response,
|
|
48
|
+
): Promise<ShellSnapshotResponseValue> {
|
|
49
|
+
const body = await response.clone().arrayBuffer();
|
|
50
|
+
const headers: [string, string][] = [];
|
|
51
|
+
response.headers.forEach((value, name) => {
|
|
52
|
+
// Mirror putResponse: per-client signal headers never enter a shared entry.
|
|
53
|
+
if (isPerClientSignalHeader(name)) return;
|
|
54
|
+
headers.push([name, value]);
|
|
55
|
+
});
|
|
56
|
+
return { status: response.status, headers, body: bufferToBase64(body) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Rebuild a live Response from a snapshot's stored response shape. */
|
|
60
|
+
function deserializeResponse(value: ShellSnapshotResponseValue): Response {
|
|
61
|
+
return new Response(base64ToBuffer(value.body), {
|
|
62
|
+
status: value.status,
|
|
63
|
+
headers: new Headers(value.headers),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A store wrapper the CAPTURE render reads through. Every call passes through to
|
|
69
|
+
* the underlying store unchanged; for the item/segment/response families it also
|
|
70
|
+
* RECORDS, last-write-wins per (family, key):
|
|
71
|
+
* - read-hits (get/getItem/getResponse returning non-null) — the value that
|
|
72
|
+
* fed the shell,
|
|
73
|
+
* - writes (set/setItem/putResponse) — the value a MISS computed and baked.
|
|
74
|
+
* The shell family (getShell/putShell) is never recorded (the snapshot rides
|
|
75
|
+
* inside a shell entry). Reads that MISS are not recorded (a miss produced no
|
|
76
|
+
* shell content; if the render then computed and wrote, that write is recorded).
|
|
77
|
+
*
|
|
78
|
+
* Deferred writes: cache writes run under waitUntil (fire-and-forget on Node,
|
|
79
|
+
* executionContext on workerd), so their setItem/set calls — hence their records
|
|
80
|
+
* — may land after the shell has quiesced. The capture collects those write
|
|
81
|
+
* promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
|
|
82
|
+
* draining, so a MISS-at-capture value is still pinned.
|
|
83
|
+
*/
|
|
84
|
+
export class RecordingShellStore<
|
|
85
|
+
TEnv = unknown,
|
|
86
|
+
> implements SegmentCacheStore<TEnv> {
|
|
87
|
+
private readonly records = new Map<string, ShellSnapshotRecord>();
|
|
88
|
+
private readonly writes: Promise<unknown>[] = [];
|
|
89
|
+
|
|
90
|
+
constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
|
|
91
|
+
|
|
92
|
+
get defaults(): SegmentCacheStore<TEnv>["defaults"] {
|
|
93
|
+
return this.inner.defaults;
|
|
94
|
+
}
|
|
95
|
+
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
96
|
+
return this.inner.keyGenerator;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private record(
|
|
100
|
+
family: ShellSnapshotRecord["family"],
|
|
101
|
+
key: string,
|
|
102
|
+
value: ShellSnapshotRecord["value"],
|
|
103
|
+
): void {
|
|
104
|
+
this.records.set(recordKey(family, key), { family, key, value });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Track a deferred cache-write promise so the capture can await it pre-drain. */
|
|
108
|
+
trackWrite(p: Promise<unknown>): void {
|
|
109
|
+
this.writes.push(p);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Await the tracked deferred writes so their records are present before drain.
|
|
114
|
+
* Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
|
|
115
|
+
* cacheRoute path schedules its actual store.set in a second waitUntil while the
|
|
116
|
+
* first is running), so each awaited batch may enqueue more. Loop until the
|
|
117
|
+
* queue empties or the deadline passes. Bounded: a pathologically slow write
|
|
118
|
+
* must never stall the capture task, so a key that does not settle in time is
|
|
119
|
+
* left unpinned (it drifts, the pre-snapshot behavior) rather than hanging.
|
|
120
|
+
*/
|
|
121
|
+
async settleWrites(timeoutMs: number): Promise<void> {
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
while (this.writes.length > 0) {
|
|
124
|
+
const remaining = deadline - Date.now();
|
|
125
|
+
if (remaining <= 0) return;
|
|
126
|
+
// Take the current batch; new writes scheduled while awaiting accumulate in
|
|
127
|
+
// this.writes and are drained on the next iteration.
|
|
128
|
+
const batch = this.writes.splice(0);
|
|
129
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
+
const guard = new Promise<void>((resolve) => {
|
|
131
|
+
timer = setTimeout(resolve, remaining);
|
|
132
|
+
(timer as { unref?: () => void }).unref?.();
|
|
133
|
+
});
|
|
134
|
+
await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
|
|
135
|
+
if (timer) clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
|
|
140
|
+
drainSnapshot(): ShellSnapshotRecord[] | undefined {
|
|
141
|
+
return this.records.size > 0 ? [...this.records.values()] : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
145
|
+
const result = await this.inner.get(key);
|
|
146
|
+
if (result) this.record("segment", key, result.data);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async set(
|
|
151
|
+
key: string,
|
|
152
|
+
data: CachedEntryData,
|
|
153
|
+
ttl: number,
|
|
154
|
+
swr?: number,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
this.record("segment", key, data);
|
|
157
|
+
return this.inner.set(key, data, ttl, swr);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async delete(key: string): Promise<boolean> {
|
|
161
|
+
return this.inner.delete(key);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async clear(): Promise<void> {
|
|
165
|
+
return this.inner.clear?.();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getResponse(
|
|
169
|
+
key: string,
|
|
170
|
+
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
171
|
+
if (!this.inner.getResponse) return null;
|
|
172
|
+
const result = await this.inner.getResponse(key);
|
|
173
|
+
if (result)
|
|
174
|
+
this.record("response", key, await serializeResponse(result.response));
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async putResponse(
|
|
179
|
+
key: string,
|
|
180
|
+
response: Response,
|
|
181
|
+
ttl: number,
|
|
182
|
+
swr?: number,
|
|
183
|
+
tags?: string[],
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
if (!this.inner.putResponse) return;
|
|
186
|
+
this.record("response", key, await serializeResponse(response));
|
|
187
|
+
return this.inner.putResponse(key, response, ttl, swr, tags);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
191
|
+
if (!this.inner.getItem) return null;
|
|
192
|
+
const result = await this.inner.getItem(key);
|
|
193
|
+
if (result) {
|
|
194
|
+
const value: ShellSnapshotItemValue = {
|
|
195
|
+
value: result.value,
|
|
196
|
+
handles: result.handles,
|
|
197
|
+
tags: result.tags,
|
|
198
|
+
};
|
|
199
|
+
this.record("item", key, value);
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async setItem(
|
|
205
|
+
key: string,
|
|
206
|
+
value: string,
|
|
207
|
+
options?: CacheItemOptions,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (!this.inner.setItem) return;
|
|
210
|
+
const stored: ShellSnapshotItemValue = {
|
|
211
|
+
value,
|
|
212
|
+
handles: options?.handles,
|
|
213
|
+
tags: options?.tags,
|
|
214
|
+
};
|
|
215
|
+
this.record("item", key, stored);
|
|
216
|
+
return this.inner.setItem(key, value, options);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async getShell(
|
|
220
|
+
key: string,
|
|
221
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
222
|
+
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async putShell(
|
|
226
|
+
key: string,
|
|
227
|
+
entry: ShellCacheEntry,
|
|
228
|
+
ttlSeconds?: number,
|
|
229
|
+
swrSeconds?: number,
|
|
230
|
+
tags?: string[],
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
236
|
+
return this.inner.invalidateTags?.(tags);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
|
|
241
|
+
export function getRecordingStore<TEnv>(
|
|
242
|
+
store: SegmentCacheStore<TEnv> | undefined,
|
|
243
|
+
): RecordingShellStore<TEnv> | undefined {
|
|
244
|
+
return store instanceof RecordingShellStore ? store : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A read-through overlay the HIT tail render reads through. For a key present in
|
|
249
|
+
* the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
|
|
250
|
+
* a pinned key must NOT kick SWR background revalidation) so the tail's payload
|
|
251
|
+
* matches the frozen prelude. Every other read falls through to the real store
|
|
252
|
+
* (the holes — masked loaders were never recorded — stay live). ALL writes pass
|
|
253
|
+
* through unchanged: a live hole's loader may legitimately write. The shell
|
|
254
|
+
* family always passes through.
|
|
255
|
+
*/
|
|
256
|
+
export class SeededShellStore<
|
|
257
|
+
TEnv = unknown,
|
|
258
|
+
> implements SegmentCacheStore<TEnv> {
|
|
259
|
+
private readonly items = new Map<string, ShellSnapshotItemValue>();
|
|
260
|
+
private readonly segments = new Map<string, CachedEntryData>();
|
|
261
|
+
private readonly responses = new Map<string, ShellSnapshotResponseValue>();
|
|
262
|
+
|
|
263
|
+
constructor(
|
|
264
|
+
private readonly inner: SegmentCacheStore<TEnv>,
|
|
265
|
+
snapshot: ShellSnapshotRecord[],
|
|
266
|
+
) {
|
|
267
|
+
for (const rec of snapshot) {
|
|
268
|
+
if (rec.family === "item") {
|
|
269
|
+
this.items.set(rec.key, rec.value as ShellSnapshotItemValue);
|
|
270
|
+
} else if (rec.family === "segment") {
|
|
271
|
+
this.segments.set(rec.key, rec.value as CachedEntryData);
|
|
272
|
+
} else {
|
|
273
|
+
this.responses.set(rec.key, rec.value as ShellSnapshotResponseValue);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get defaults(): SegmentCacheStore<TEnv>["defaults"] {
|
|
279
|
+
return this.inner.defaults;
|
|
280
|
+
}
|
|
281
|
+
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
282
|
+
return this.inner.keyGenerator;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
286
|
+
const seeded = this.segments.get(key);
|
|
287
|
+
if (seeded) return { data: seeded, shouldRevalidate: false };
|
|
288
|
+
return this.inner.get(key);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async set(
|
|
292
|
+
key: string,
|
|
293
|
+
data: CachedEntryData,
|
|
294
|
+
ttl: number,
|
|
295
|
+
swr?: number,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
return this.inner.set(key, data, ttl, swr);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async delete(key: string): Promise<boolean> {
|
|
301
|
+
return this.inner.delete(key);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async clear(): Promise<void> {
|
|
305
|
+
return this.inner.clear?.();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getResponse(
|
|
309
|
+
key: string,
|
|
310
|
+
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
311
|
+
const seeded = this.responses.get(key);
|
|
312
|
+
if (seeded) {
|
|
313
|
+
return { response: deserializeResponse(seeded), shouldRevalidate: false };
|
|
314
|
+
}
|
|
315
|
+
return this.inner.getResponse ? this.inner.getResponse(key) : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async putResponse(
|
|
319
|
+
key: string,
|
|
320
|
+
response: Response,
|
|
321
|
+
ttl: number,
|
|
322
|
+
swr?: number,
|
|
323
|
+
tags?: string[],
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
return this.inner.putResponse?.(key, response, ttl, swr, tags);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
329
|
+
const seeded = this.items.get(key);
|
|
330
|
+
if (seeded) {
|
|
331
|
+
return {
|
|
332
|
+
value: seeded.value,
|
|
333
|
+
handles: seeded.handles,
|
|
334
|
+
tags: seeded.tags,
|
|
335
|
+
shouldRevalidate: false,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return this.inner.getItem ? this.inner.getItem(key) : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async setItem(
|
|
342
|
+
key: string,
|
|
343
|
+
value: string,
|
|
344
|
+
options?: CacheItemOptions,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
return this.inner.setItem?.(key, value, options);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async getShell(
|
|
350
|
+
key: string,
|
|
351
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
352
|
+
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async putShell(
|
|
356
|
+
key: string,
|
|
357
|
+
entry: ShellCacheEntry,
|
|
358
|
+
ttlSeconds?: number,
|
|
359
|
+
swrSeconds?: number,
|
|
360
|
+
tags?: string[],
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
366
|
+
return this.inner.invalidateTags?.(tags);
|
|
367
|
+
}
|
|
368
|
+
}
|
package/src/cache/types.ts
CHANGED
|
@@ -240,10 +240,76 @@ export interface ShellCacheEntry {
|
|
|
240
240
|
postponed: string | null;
|
|
241
241
|
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
242
242
|
reactVersion: string;
|
|
243
|
+
/**
|
|
244
|
+
* The initialTheme the CAPTURE render was built with (the derived context's
|
|
245
|
+
* reqCtx.theme). The resume tail must render ThemeProvider with the SAME
|
|
246
|
+
* initialTheme the frozen prelude was rendered with: React resume requires the
|
|
247
|
+
* tree above the holes to match the prerendered tree, and initialTheme is
|
|
248
|
+
* per-request METADATA, not part of the cached segments — a visitor whose
|
|
249
|
+
* theme differs from the capturer's would otherwise produce a divergent resume
|
|
250
|
+
* tree (broken stitching/hydration). The visitor's real theme is applied
|
|
251
|
+
* pre-paint by the FOUC script and re-synced from the cookie post-mount by
|
|
252
|
+
* ThemeProvider.
|
|
253
|
+
*/
|
|
254
|
+
initialTheme?: string;
|
|
255
|
+
/**
|
|
256
|
+
* The CAPTURE DATA SNAPSHOT: every cache-store read-hit and write the capture
|
|
257
|
+
* render performed, in stored/serialized form. Replaying these on a HIT (via
|
|
258
|
+
* the SeededShellStore overlay, for the tail render only) reproduces the
|
|
259
|
+
* shell's cached content byte-identically, so the freshly rendered hydration
|
|
260
|
+
* payload matches the frozen prelude even after the underlying cache entries
|
|
261
|
+
* have drifted (expired, been recomputed, or been tag-invalidated).
|
|
262
|
+
*
|
|
263
|
+
* Optional: an entry captured before this field existed simply has no
|
|
264
|
+
* snapshot and keeps the pre-snapshot behavior (the tail reads live, so any
|
|
265
|
+
* shell-baked cached value that drifted mismatches the prelude). Recapture
|
|
266
|
+
* heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
|
|
267
|
+
*/
|
|
268
|
+
snapshot?: ShellSnapshotRecord[];
|
|
243
269
|
/** Epoch ms when the shell was captured. */
|
|
244
270
|
createdAt: number;
|
|
245
271
|
}
|
|
246
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The cache-store families a shell snapshot pins. Excludes the shell family
|
|
275
|
+
* itself (getShell/putShell) — the snapshot rides INSIDE a shell entry, so
|
|
276
|
+
* recording it would be self-referential.
|
|
277
|
+
*/
|
|
278
|
+
export type ShellSnapshotFamily = "item" | "segment" | "response";
|
|
279
|
+
|
|
280
|
+
/** A serialized cached Response for the response family of a shell snapshot. */
|
|
281
|
+
export interface ShellSnapshotResponseValue {
|
|
282
|
+
status: number;
|
|
283
|
+
/** Client-facing header pairs (per-client signal headers excluded at record). */
|
|
284
|
+
headers: [string, string][];
|
|
285
|
+
/** base64-encoded response body (binary-safe, JSON-serializable). */
|
|
286
|
+
body: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The stored form of an item-family (use cache / loader cache) snapshot value. */
|
|
290
|
+
export interface ShellSnapshotItemValue {
|
|
291
|
+
/** RSC-serialized return value. */
|
|
292
|
+
value: string;
|
|
293
|
+
/** RSC-encoded handle data, if any. */
|
|
294
|
+
handles?: string;
|
|
295
|
+
/** The entry's cache tags. */
|
|
296
|
+
tags?: string[];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One recorded cache-store read-hit or write from the capture render. `value`
|
|
301
|
+
* carries the entry in its stored/serialized shape so it round-trips through a
|
|
302
|
+
* JSON-serializing store (KV, CF, Vercel) with the rest of the ShellCacheEntry:
|
|
303
|
+
* - `item` -> {@link ShellSnapshotItemValue}
|
|
304
|
+
* - `segment` -> {@link CachedEntryData} (already JSON-able)
|
|
305
|
+
* - `response`-> {@link ShellSnapshotResponseValue}
|
|
306
|
+
*/
|
|
307
|
+
export interface ShellSnapshotRecord {
|
|
308
|
+
family: ShellSnapshotFamily;
|
|
309
|
+
key: string;
|
|
310
|
+
value: ShellSnapshotItemValue | CachedEntryData | ShellSnapshotResponseValue;
|
|
311
|
+
}
|
|
312
|
+
|
|
247
313
|
/**
|
|
248
314
|
* Options for setItem() for function-level caching ("use cache").
|
|
249
315
|
*/
|
|
@@ -39,6 +39,7 @@ import type {
|
|
|
39
39
|
CacheItemResult,
|
|
40
40
|
CacheItemOptions,
|
|
41
41
|
ShellCacheEntry,
|
|
42
|
+
ShellSnapshotRecord,
|
|
42
43
|
} from "../types.js";
|
|
43
44
|
import type { RequestContext } from "../../server/request-context.js";
|
|
44
45
|
import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
|
|
@@ -175,6 +176,10 @@ interface VercelShellEnvelope {
|
|
|
175
176
|
e: number;
|
|
176
177
|
/** Tags, preserved so a stale re-stamp keeps them. */
|
|
177
178
|
t?: string[];
|
|
179
|
+
/** initialTheme the capture render was built with (resume theme fidelity). */
|
|
180
|
+
i?: string;
|
|
181
|
+
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
|
|
182
|
+
sn?: ShellSnapshotRecord[];
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
/** Read-path outcome for the debug sink. */
|
|
@@ -796,6 +801,8 @@ export class VercelCacheStore<
|
|
|
796
801
|
prelude: env.p,
|
|
797
802
|
postponed: env.po,
|
|
798
803
|
reactVersion: env.rv,
|
|
804
|
+
initialTheme: env.i,
|
|
805
|
+
snapshot: env.sn,
|
|
799
806
|
createdAt: env.c,
|
|
800
807
|
},
|
|
801
808
|
shouldRevalidate: isStale,
|
|
@@ -827,6 +834,8 @@ export class VercelCacheStore<
|
|
|
827
834
|
s: staleAt,
|
|
828
835
|
e: expiresAt,
|
|
829
836
|
t: safeTags.length > 0 ? safeTags : undefined,
|
|
837
|
+
i: entry.initialTheme,
|
|
838
|
+
sn: entry.snapshot,
|
|
830
839
|
};
|
|
831
840
|
// write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
|
|
832
841
|
// oversized shell prelude is reported and skipped (fail-open to a full
|
|
@@ -1068,7 +1077,7 @@ export class VercelCacheStore<
|
|
|
1068
1077
|
|
|
1069
1078
|
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1070
1079
|
if (!isRecord(raw)) return null;
|
|
1071
|
-
const { p, po, rv, c, s, e, t } = raw;
|
|
1080
|
+
const { p, po, rv, c, s, e, t, i, sn } = raw;
|
|
1072
1081
|
if (typeof p !== "string" || typeof rv !== "string") return null;
|
|
1073
1082
|
if (po !== null && typeof po !== "string") return null;
|
|
1074
1083
|
if (typeof c !== "number") return null;
|
|
@@ -1081,6 +1090,8 @@ export class VercelCacheStore<
|
|
|
1081
1090
|
s,
|
|
1082
1091
|
e,
|
|
1083
1092
|
t: Array.isArray(t) ? (t as string[]) : undefined,
|
|
1093
|
+
i: typeof i === "string" ? i : undefined,
|
|
1094
|
+
sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
|
|
1084
1095
|
};
|
|
1085
1096
|
}
|
|
1086
1097
|
|
package/src/index.rsc.ts
CHANGED
|
@@ -157,6 +157,7 @@ export {
|
|
|
157
157
|
urls,
|
|
158
158
|
type PathHelpers,
|
|
159
159
|
type PathOptions,
|
|
160
|
+
type PartialPrerenderProps,
|
|
160
161
|
type UrlPatterns,
|
|
161
162
|
type IncludeOptions,
|
|
162
163
|
type IncludeItem,
|
|
@@ -219,11 +220,6 @@ export {
|
|
|
219
220
|
type ReadonlyHeaders,
|
|
220
221
|
} from "./server/cookie-store.js";
|
|
221
222
|
|
|
222
|
-
// live() — the deterministic PPR hole primitive. Real (capture-aware)
|
|
223
|
-
// implementation under the react-server condition; index.ts ships a client/SSR
|
|
224
|
-
// passthrough (there is no shell capture off this condition).
|
|
225
|
-
export { live } from "./server/live.js";
|
|
226
|
-
|
|
227
223
|
// Cache tag APIs (server-only)
|
|
228
224
|
// cacheTag: tag the current "use cache" entry at runtime.
|
|
229
225
|
// updateTag: read-your-own-writes invalidation (awaitable, for Server Actions).
|
package/src/index.ts
CHANGED
|
@@ -110,6 +110,7 @@ export type {
|
|
|
110
110
|
TextResponsePathFn,
|
|
111
111
|
RouteResponse,
|
|
112
112
|
ProblemDetails,
|
|
113
|
+
PartialPrerenderProps,
|
|
113
114
|
} from "./urls.js";
|
|
114
115
|
|
|
115
116
|
// Middleware context types
|
|
@@ -228,23 +229,6 @@ export function headers(): never {
|
|
|
228
229
|
throw serverOnlyStubError("headers");
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
/**
|
|
232
|
-
* Client/SSR passthrough for `live()` (the PPR hole primitive). Unlike the
|
|
233
|
-
* cookies()/headers() stubs this is a REAL function: there is no shell capture
|
|
234
|
-
* off the react-server condition, so live() simply runs the thunk (or returns
|
|
235
|
-
* the promise). The capture-aware implementation lives in index.rsc.ts
|
|
236
|
-
* (./server/live.js). See docs/design/ppr-shell-resume.md.
|
|
237
|
-
*/
|
|
238
|
-
export function live<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
239
|
-
export function live<T>(promise: Promise<T>): Promise<T>;
|
|
240
|
-
export function live<T>(
|
|
241
|
-
input: (() => Promise<T> | T) | Promise<T>,
|
|
242
|
-
): Promise<T> {
|
|
243
|
-
return typeof input === "function"
|
|
244
|
-
? Promise.resolve((input as () => Promise<T> | T)())
|
|
245
|
-
: input;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
232
|
/**
|
|
249
233
|
* Client implementation of `invalidateClientCache()`. Unlike the server-only
|
|
250
234
|
* stubs above this is a REAL function under the `default` condition (it marks
|