@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2
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/AGENTS.md +6 -10
- package/README.md +289 -938
- package/dist/bin/rango.js +271 -46
- package/dist/vite/index.js +673 -193
- package/package.json +10 -8
- package/skills/api-client/SKILL.md +1 -1
- package/skills/breadcrumbs/SKILL.md +31 -14
- package/skills/cache-guide/SKILL.md +5 -2
- package/skills/caching/SKILL.md +59 -4
- package/skills/catalog.json +271 -0
- package/skills/comparison/SKILL.md +50 -0
- package/skills/comparison/agents/openai.yaml +4 -0
- package/skills/comparison/references/framework-comparison.md +837 -0
- package/skills/composability/SKILL.md +83 -2
- package/skills/debug-manifest/SKILL.md +1 -1
- package/skills/defer-hydration/SKILL.md +235 -0
- package/skills/document-cache/SKILL.md +9 -1
- package/skills/fonts/SKILL.md +1 -1
- package/skills/handler-use/SKILL.md +8 -8
- package/skills/hooks/SKILL.md +54 -892
- package/skills/hooks/data.md +273 -0
- package/skills/hooks/handle-and-actions.md +103 -0
- package/skills/hooks/navigation.md +110 -0
- package/skills/hooks/outlets.md +41 -0
- package/skills/hooks/state.md +228 -0
- package/skills/hooks/urls.md +135 -0
- package/skills/host-router/SKILL.md +4 -4
- package/skills/i18n/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +46 -14
- package/skills/layout/SKILL.md +27 -10
- package/skills/links/SKILL.md +1 -1
- package/skills/loader/SKILL.md +23 -1
- package/skills/middleware/SKILL.md +7 -3
- package/skills/migrate-nextjs/SKILL.md +167 -6
- package/skills/migrate-react-router/SKILL.md +59 -677
- package/skills/migrate-react-router/cloudflare-workers.md +129 -0
- package/skills/migrate-react-router/component-migration.md +196 -0
- package/skills/migrate-react-router/data-and-actions.md +225 -0
- package/skills/migrate-react-router/route-mapping.md +271 -0
- package/skills/mime-routes/SKILL.md +1 -1
- package/skills/observability/SKILL.md +9 -1
- package/skills/parallel/SKILL.md +23 -4
- package/skills/ppr/SKILL.md +622 -0
- package/skills/prerender/SKILL.md +28 -18
- package/skills/rango/SKILL.md +84 -25
- package/skills/response-routes/SKILL.md +15 -1
- package/skills/route/SKILL.md +71 -4
- package/skills/router-setup/SKILL.md +14 -3
- package/skills/scripts/SKILL.md +1 -1
- package/skills/server-actions/SKILL.md +3 -2
- package/skills/shell-manifest/SKILL.md +185 -0
- package/skills/streams-and-websockets/SKILL.md +1 -1
- package/skills/tailwind/SKILL.md +1 -1
- package/skills/testing/SKILL.md +2 -1
- package/skills/testing/handles.md +4 -2
- package/skills/testing/render-handler.md +15 -14
- package/skills/testing/reverse-and-types.md +8 -7
- package/skills/theme/SKILL.md +1 -1
- package/skills/typesafety/SKILL.md +45 -919
- package/skills/typesafety/env-and-bindings.md +254 -0
- package/skills/typesafety/generated-files-and-cli.md +335 -0
- package/skills/typesafety/params-and-search.md +153 -0
- package/skills/typesafety/route-types.md +209 -0
- package/skills/use-cache/SKILL.md +30 -3
- package/skills/vercel/SKILL.md +1 -1
- package/skills/view-transitions/SKILL.md +44 -1
- package/src/browser/event-controller.ts +62 -10
- package/src/browser/logging.ts +28 -0
- package/src/browser/merge-segment-loaders.ts +6 -4
- package/src/browser/navigation-bridge.ts +65 -16
- package/src/browser/navigation-client.ts +32 -2
- package/src/browser/navigation-store.ts +128 -14
- package/src/browser/network-error-handler.ts +34 -7
- package/src/browser/partial-update.ts +76 -17
- package/src/browser/prefetch/cache.ts +51 -11
- package/src/browser/prefetch/fetch.ts +59 -21
- package/src/browser/prefetch/queue.ts +19 -4
- package/src/browser/react/Link.tsx +13 -3
- package/src/browser/react/NavigationProvider.tsx +108 -4
- package/src/browser/response-adapter.ts +38 -9
- package/src/browser/rsc-router.tsx +54 -4
- package/src/browser/scroll-restoration.ts +7 -5
- package/src/browser/segment-reconciler.ts +31 -21
- package/src/browser/server-action-bridge.ts +22 -10
- package/src/browser/types.ts +54 -1
- package/src/build/generate-manifest.ts +155 -131
- package/src/build/index.ts +3 -1
- package/src/build/route-trie.ts +35 -7
- package/src/build/route-types/include-resolution.ts +347 -47
- package/src/build/runtime-discovery.ts +4 -1
- package/src/cache/cache-key-utils.ts +29 -0
- package/src/cache/cache-runtime.ts +262 -71
- package/src/cache/cache-scope.ts +2 -17
- package/src/cache/cache-tag.ts +60 -14
- package/src/cache/cf/cf-cache-store.ts +243 -20
- package/src/cache/document-cache.ts +54 -21
- package/src/cache/index.ts +1 -0
- package/src/cache/memory-segment-store.ts +110 -3
- package/src/cache/profile-registry.ts +15 -0
- package/src/cache/read-through-swr.ts +15 -1
- package/src/cache/segment-codec.ts +4 -4
- package/src/cache/shell-snapshot.ts +417 -0
- package/src/cache/types.ts +158 -0
- package/src/cache/vercel/vercel-cache-store.ts +401 -124
- package/src/client.rsc.tsx +0 -3
- package/src/client.tsx +0 -3
- package/src/cloudflare/tracing.ts +7 -8
- package/src/defer.ts +11 -22
- package/src/handle.ts +37 -15
- package/src/handles/MetaTags.tsx +16 -82
- package/src/handles/breadcrumbs.ts +12 -14
- package/src/handles/deferred-resolution.ts +127 -0
- package/src/handles/is-thenable.ts +7 -8
- package/src/handles/meta.ts +7 -44
- package/src/host/errors.ts +15 -0
- package/src/host/index.ts +1 -0
- package/src/index.rsc.ts +8 -2
- package/src/index.ts +19 -13
- package/src/internal-debug.ts +11 -8
- package/src/prerender.ts +17 -4
- package/src/redirect-origin.ts +14 -0
- package/src/render-error-thrower.tsx +20 -0
- package/src/route-content-wrapper.tsx +12 -5
- package/src/route-definition/dsl-helpers.ts +21 -32
- package/src/route-definition/helper-factories.ts +0 -2
- package/src/route-definition/helpers-types.ts +43 -43
- package/src/route-definition/index.ts +1 -2
- package/src/route-definition/resolve-handler-use.ts +0 -1
- package/src/route-definition/use-item-types.ts +3 -6
- package/src/route-map-builder.ts +41 -4
- package/src/route-types.ts +0 -5
- package/src/router/find-match.ts +86 -8
- package/src/router/instrument.ts +9 -4
- package/src/router/lazy-includes.ts +72 -12
- package/src/router/loader-resolution.ts +14 -2
- package/src/router/manifest.ts +56 -11
- package/src/router/match-api.ts +76 -32
- package/src/router/match-handlers.ts +181 -135
- package/src/router/match-middleware/background-revalidation.ts +40 -23
- package/src/router/match-middleware/cache-store.ts +39 -24
- package/src/router/match-result.ts +35 -15
- package/src/router/middleware.ts +64 -38
- package/src/router/navigation-snapshot.ts +7 -5
- package/src/router/parse-pattern.ts +115 -0
- package/src/router/pattern-matching.ts +53 -64
- package/src/router/prefetch-limits.ts +37 -0
- package/src/router/prerender-match.ts +11 -5
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +23 -8
- package/src/router/route-snapshot.ts +14 -2
- package/src/router/router-context.ts +3 -1
- package/src/router/router-interfaces.ts +32 -1
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/fresh.ts +39 -3
- package/src/router/segment-resolution/loader-cache.ts +93 -2
- package/src/router/segment-resolution/loader-mask.ts +60 -0
- package/src/router/segment-resolution/loader-snapshot.ts +259 -0
- package/src/router/segment-resolution/mask-nested.ts +83 -0
- package/src/router/segment-resolution/revalidation.ts +3 -0
- package/src/router/segment-resolution/view-transition-default.ts +35 -15
- package/src/router/substitute-pattern-params.ts +54 -35
- package/src/router/telemetry-otel.ts +6 -8
- package/src/router/telemetry.ts +9 -1
- package/src/router/tracing.ts +14 -5
- package/src/router/trie-matching.ts +19 -11
- package/src/router/url-params.ts +13 -0
- package/src/router.ts +47 -16
- package/src/rsc/full-payload.ts +70 -0
- package/src/rsc/handler.ts +60 -33
- package/src/rsc/manifest-init.ts +1 -1
- package/src/rsc/nonce.ts +10 -1
- package/src/rsc/progressive-enhancement.ts +61 -4
- package/src/rsc/redirect-guard.ts +2 -1
- package/src/rsc/rsc-rendering.ts +429 -37
- package/src/rsc/server-action.ts +25 -2
- package/src/rsc/shell-capture.ts +1190 -0
- package/src/rsc/shell-serve.ts +181 -0
- package/src/rsc/transition-gate.ts +89 -0
- package/src/rsc/types.ts +30 -0
- package/src/segment-loader-promise.ts +18 -0
- package/src/segment-system.tsx +149 -14
- package/src/server/context.ts +67 -9
- package/src/server/cookie-store.ts +73 -1
- package/src/server/loader-registry.ts +13 -1
- package/src/server/request-context.ts +169 -10
- package/src/ssr/index.tsx +462 -178
- package/src/ssr/inject-rsc-eager.ts +167 -0
- package/src/ssr/ssr-root.tsx +228 -0
- package/src/testing/collect-handle.ts +14 -8
- package/src/testing/dispatch.ts +152 -40
- package/src/testing/generated-routes.ts +27 -11
- package/src/testing/index.ts +6 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/render-route.tsx +13 -10
- package/src/testing/run-transition-when.ts +164 -0
- package/src/theme/ThemeProvider.tsx +36 -26
- package/src/types/handler-context.ts +1 -1
- package/src/types/index.ts +2 -0
- package/src/types/route-config.ts +19 -7
- package/src/types/segments.ts +100 -0
- package/src/urls/include-helper.ts +10 -8
- package/src/urls/include-provider.ts +71 -0
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +44 -12
- package/src/urls/path-helper.ts +5 -0
- package/src/urls/pattern-types.ts +36 -0
- package/src/urls/type-extraction.ts +43 -18
- package/src/urls/urls-function.ts +0 -1
- package/src/vercel/tracing.ts +7 -7
- package/src/vite/discovery/dev-prerender-cache.ts +117 -0
- package/src/vite/discovery/discover-routers.ts +1 -1
- package/src/vite/discovery/discovery-errors.ts +61 -0
- package/src/vite/index.ts +7 -0
- package/src/vite/inject-client-debug.ts +88 -0
- package/src/vite/plugins/vercel-output.ts +114 -25
- package/src/vite/plugins/version-injector.ts +22 -7
- package/src/vite/plugins/virtual-entries.ts +80 -22
- package/src/vite/rango.ts +29 -19
- package/src/vite/router-discovery.ts +171 -43
- package/src/vite/utils/prerender-utils.ts +17 -4
- package/src/vite/utils/shared-utils.ts +47 -0
- package/src/network-error-thrower.tsx +0 -18
|
@@ -0,0 +1,417 @@
|
|
|
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
|
+
ShellSnapshotLoaderValue,
|
|
37
|
+
} from "./types.js";
|
|
38
|
+
import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
|
|
39
|
+
import { isPerClientSignalHeader } from "../browser/cookie-name.js";
|
|
40
|
+
|
|
41
|
+
/** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
|
|
42
|
+
function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
|
|
43
|
+
return `${family}\u0000${key}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Serialize a Response to the snapshot's stored shape (base64 body). */
|
|
47
|
+
async function serializeResponse(
|
|
48
|
+
response: Response,
|
|
49
|
+
): Promise<ShellSnapshotResponseValue> {
|
|
50
|
+
const body = await response.clone().arrayBuffer();
|
|
51
|
+
const headers: [string, string][] = [];
|
|
52
|
+
response.headers.forEach((value, name) => {
|
|
53
|
+
// Mirror putResponse: per-client signal headers never enter a shared entry.
|
|
54
|
+
if (isPerClientSignalHeader(name)) return;
|
|
55
|
+
headers.push([name, value]);
|
|
56
|
+
});
|
|
57
|
+
return { status: response.status, headers, body: bufferToBase64(body) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Rebuild a live Response from a snapshot's stored response shape. */
|
|
61
|
+
function deserializeResponse(value: ShellSnapshotResponseValue): Response {
|
|
62
|
+
return new Response(base64ToBuffer(value.body), {
|
|
63
|
+
status: value.status,
|
|
64
|
+
headers: new Headers(value.headers),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A store wrapper the CAPTURE render reads through. Every call passes through to
|
|
70
|
+
* the underlying store unchanged; for the item/segment/response families it also
|
|
71
|
+
* RECORDS, last-write-wins per (family, key):
|
|
72
|
+
* - read-hits (get/getItem/getResponse returning non-null) — the value that
|
|
73
|
+
* fed the shell,
|
|
74
|
+
* - writes (set/setItem/putResponse) — the value a MISS computed and baked.
|
|
75
|
+
* The shell family (getShell/putShell) is never recorded (the snapshot rides
|
|
76
|
+
* inside a shell entry). Reads that MISS are not recorded (a miss produced no
|
|
77
|
+
* shell content; if the render then computed and wrote, that write is recorded).
|
|
78
|
+
*
|
|
79
|
+
* Deferred writes: cache writes run under waitUntil (fire-and-forget on Node,
|
|
80
|
+
* executionContext on workerd), so their setItem/set calls — hence their records
|
|
81
|
+
* — may land after the shell has quiesced. The capture collects those write
|
|
82
|
+
* promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
|
|
83
|
+
* draining, so a MISS-at-capture value is still pinned.
|
|
84
|
+
*/
|
|
85
|
+
export class RecordingShellStore<
|
|
86
|
+
TEnv = unknown,
|
|
87
|
+
> implements SegmentCacheStore<TEnv> {
|
|
88
|
+
private readonly records = new Map<string, ShellSnapshotRecord>();
|
|
89
|
+
private readonly writes: Promise<unknown>[] = [];
|
|
90
|
+
|
|
91
|
+
constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
|
|
92
|
+
|
|
93
|
+
get defaults(): SegmentCacheStore<TEnv>["defaults"] {
|
|
94
|
+
return this.inner.defaults;
|
|
95
|
+
}
|
|
96
|
+
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
97
|
+
return this.inner.keyGenerator;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private record(
|
|
101
|
+
family: ShellSnapshotRecord["family"],
|
|
102
|
+
key: string,
|
|
103
|
+
value: ShellSnapshotRecord["value"],
|
|
104
|
+
): void {
|
|
105
|
+
this.records.set(recordKey(family, key), { family, key, value });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Track a deferred cache-write promise so the capture can await it pre-drain. */
|
|
109
|
+
trackWrite(p: Promise<unknown>): void {
|
|
110
|
+
this.writes.push(p);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Await the tracked deferred writes so their records are present before drain.
|
|
115
|
+
* Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
|
|
116
|
+
* cacheRoute path schedules its actual store.set in a second waitUntil while the
|
|
117
|
+
* first is running), so each awaited batch may enqueue more. Loop until the
|
|
118
|
+
* queue empties or the deadline passes. Bounded: a pathologically slow write
|
|
119
|
+
* must never stall the capture task, so a key that does not settle in time is
|
|
120
|
+
* left unpinned (it drifts, the pre-snapshot behavior) rather than hanging.
|
|
121
|
+
*/
|
|
122
|
+
async settleWrites(timeoutMs: number): Promise<void> {
|
|
123
|
+
const deadline = Date.now() + timeoutMs;
|
|
124
|
+
while (this.writes.length > 0) {
|
|
125
|
+
const remaining = deadline - Date.now();
|
|
126
|
+
if (remaining <= 0) return;
|
|
127
|
+
// Take the current batch; new writes scheduled while awaiting accumulate in
|
|
128
|
+
// this.writes and are drained on the next iteration.
|
|
129
|
+
const batch = this.writes.splice(0);
|
|
130
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
131
|
+
const guard = new Promise<void>((resolve) => {
|
|
132
|
+
timer = setTimeout(resolve, remaining);
|
|
133
|
+
(timer as { unref?: () => void }).unref?.();
|
|
134
|
+
});
|
|
135
|
+
await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
|
|
136
|
+
if (timer) clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
|
|
141
|
+
drainSnapshot(): ShellSnapshotRecord[] | undefined {
|
|
142
|
+
return this.records.size > 0 ? [...this.records.values()] : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
146
|
+
const result = await this.inner.get(key);
|
|
147
|
+
if (result) this.record("segment", key, result.data);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async set(
|
|
152
|
+
key: string,
|
|
153
|
+
data: CachedEntryData,
|
|
154
|
+
ttl: number,
|
|
155
|
+
swr?: number,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
this.record("segment", key, data);
|
|
158
|
+
return this.inner.set(key, data, ttl, swr);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async delete(key: string): Promise<boolean> {
|
|
162
|
+
return this.inner.delete(key);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async clear(): Promise<void> {
|
|
166
|
+
return this.inner.clear?.();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getResponse(
|
|
170
|
+
key: string,
|
|
171
|
+
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
172
|
+
if (!this.inner.getResponse) return null;
|
|
173
|
+
const result = await this.inner.getResponse(key);
|
|
174
|
+
if (result)
|
|
175
|
+
this.record("response", key, await serializeResponse(result.response));
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async putResponse(
|
|
180
|
+
key: string,
|
|
181
|
+
response: Response,
|
|
182
|
+
ttl: number,
|
|
183
|
+
swr?: number,
|
|
184
|
+
tags?: string[],
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
if (!this.inner.putResponse) return;
|
|
187
|
+
this.record("response", key, await serializeResponse(response));
|
|
188
|
+
return this.inner.putResponse(key, response, ttl, swr, tags);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
192
|
+
if (!this.inner.getItem) return null;
|
|
193
|
+
const result = await this.inner.getItem(key);
|
|
194
|
+
if (result) {
|
|
195
|
+
const value: ShellSnapshotItemValue = {
|
|
196
|
+
value: result.value,
|
|
197
|
+
handles: result.handles,
|
|
198
|
+
tags: result.tags,
|
|
199
|
+
};
|
|
200
|
+
this.record("item", key, value);
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async setItem(
|
|
206
|
+
key: string,
|
|
207
|
+
value: string,
|
|
208
|
+
options?: CacheItemOptions,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
if (!this.inner.setItem) return;
|
|
211
|
+
const stored: ShellSnapshotItemValue = {
|
|
212
|
+
value,
|
|
213
|
+
handles: options?.handles,
|
|
214
|
+
tags: options?.tags,
|
|
215
|
+
};
|
|
216
|
+
this.record("item", key, stored);
|
|
217
|
+
return this.inner.setItem(key, value, options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async getShell(
|
|
221
|
+
key: string,
|
|
222
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
223
|
+
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async putShell(
|
|
227
|
+
key: string,
|
|
228
|
+
entry: ShellCacheEntry,
|
|
229
|
+
ttlSeconds?: number,
|
|
230
|
+
swrSeconds?: number,
|
|
231
|
+
tags?: string[],
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
237
|
+
return this.inner.invalidateTags?.(tags);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
|
|
242
|
+
export function getRecordingStore<TEnv>(
|
|
243
|
+
store: SegmentCacheStore<TEnv> | undefined,
|
|
244
|
+
): RecordingShellStore<TEnv> | undefined {
|
|
245
|
+
return store instanceof RecordingShellStore ? store : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Materialize the loader-family seed from a shell snapshot for a HIT's tail
|
|
250
|
+
* render: Flight-deserialize each recorded (promise-elided) bake-lane
|
|
251
|
+
* container into a segment-key -> container Map, which serveShellHit assigns
|
|
252
|
+
* to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
|
|
253
|
+
* Lives here so every snapshot family is decoded in this module (the
|
|
254
|
+
* item/segment/response families via {@link SeededShellStore}); the loader
|
|
255
|
+
* family is not a store read, so it seeds the context instead of a store.
|
|
256
|
+
*
|
|
257
|
+
* Deserializations run in parallel; a record that fails to decode is skipped
|
|
258
|
+
* (that loader drifts — the pre-snapshot behavior — instead of failing the
|
|
259
|
+
* HIT). Returns undefined when the snapshot carries no loader records, without
|
|
260
|
+
* touching the Flight codec (kept lazy for cold paths and non-RSC configs).
|
|
261
|
+
*/
|
|
262
|
+
export async function buildShellLoaderSeed(
|
|
263
|
+
snapshot: ShellSnapshotRecord[],
|
|
264
|
+
): Promise<Map<string, unknown> | undefined> {
|
|
265
|
+
const loaderRecords: ShellSnapshotRecord[] = [];
|
|
266
|
+
for (const rec of snapshot) {
|
|
267
|
+
if (rec.family === "loader") loaderRecords.push(rec);
|
|
268
|
+
}
|
|
269
|
+
if (loaderRecords.length === 0) return undefined;
|
|
270
|
+
|
|
271
|
+
const { deserializeResult } = await import("./segment-codec.js");
|
|
272
|
+
const entries = await Promise.all(
|
|
273
|
+
loaderRecords.map(async (rec): Promise<[string, unknown] | null> => {
|
|
274
|
+
try {
|
|
275
|
+
return [
|
|
276
|
+
rec.key,
|
|
277
|
+
await deserializeResult(
|
|
278
|
+
(rec.value as ShellSnapshotLoaderValue).value,
|
|
279
|
+
),
|
|
280
|
+
];
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}),
|
|
285
|
+
);
|
|
286
|
+
const seed = new Map<string, unknown>();
|
|
287
|
+
for (const entry of entries) {
|
|
288
|
+
if (entry) seed.set(entry[0], entry[1]);
|
|
289
|
+
}
|
|
290
|
+
return seed.size > 0 ? seed : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* A read-through overlay the HIT tail render reads through. For a key present in
|
|
295
|
+
* the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
|
|
296
|
+
* a pinned key must NOT kick SWR background revalidation) so the tail's payload
|
|
297
|
+
* matches the frozen prelude. Every other read falls through to the real store
|
|
298
|
+
* (the holes — masked loaders were never recorded — stay live). ALL writes pass
|
|
299
|
+
* through unchanged: a live hole's loader may legitimately write. The shell
|
|
300
|
+
* family always passes through.
|
|
301
|
+
*/
|
|
302
|
+
export class SeededShellStore<
|
|
303
|
+
TEnv = unknown,
|
|
304
|
+
> implements SegmentCacheStore<TEnv> {
|
|
305
|
+
private readonly items = new Map<string, ShellSnapshotItemValue>();
|
|
306
|
+
private readonly segments = new Map<string, CachedEntryData>();
|
|
307
|
+
private readonly responses = new Map<string, ShellSnapshotResponseValue>();
|
|
308
|
+
|
|
309
|
+
constructor(
|
|
310
|
+
private readonly inner: SegmentCacheStore<TEnv>,
|
|
311
|
+
snapshot: ShellSnapshotRecord[],
|
|
312
|
+
) {
|
|
313
|
+
for (const rec of snapshot) {
|
|
314
|
+
if (rec.family === "item") {
|
|
315
|
+
this.items.set(rec.key, rec.value as ShellSnapshotItemValue);
|
|
316
|
+
} else if (rec.family === "segment") {
|
|
317
|
+
this.segments.set(rec.key, rec.value as CachedEntryData);
|
|
318
|
+
} else if (rec.family === "response") {
|
|
319
|
+
this.responses.set(rec.key, rec.value as ShellSnapshotResponseValue);
|
|
320
|
+
}
|
|
321
|
+
// "loader" family records are not store reads — serveShellHit seeds them
|
|
322
|
+
// onto the tail context (_shellLoaderSeed) for the resolveLoaderData
|
|
323
|
+
// overlay instead.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
get defaults(): SegmentCacheStore<TEnv>["defaults"] {
|
|
328
|
+
return this.inner.defaults;
|
|
329
|
+
}
|
|
330
|
+
get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
|
|
331
|
+
return this.inner.keyGenerator;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
335
|
+
const seeded = this.segments.get(key);
|
|
336
|
+
if (seeded) return { data: seeded, shouldRevalidate: false };
|
|
337
|
+
return this.inner.get(key);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async set(
|
|
341
|
+
key: string,
|
|
342
|
+
data: CachedEntryData,
|
|
343
|
+
ttl: number,
|
|
344
|
+
swr?: number,
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
return this.inner.set(key, data, ttl, swr);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async delete(key: string): Promise<boolean> {
|
|
350
|
+
return this.inner.delete(key);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async clear(): Promise<void> {
|
|
354
|
+
return this.inner.clear?.();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async getResponse(
|
|
358
|
+
key: string,
|
|
359
|
+
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
360
|
+
const seeded = this.responses.get(key);
|
|
361
|
+
if (seeded) {
|
|
362
|
+
return { response: deserializeResponse(seeded), shouldRevalidate: false };
|
|
363
|
+
}
|
|
364
|
+
return this.inner.getResponse ? this.inner.getResponse(key) : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async putResponse(
|
|
368
|
+
key: string,
|
|
369
|
+
response: Response,
|
|
370
|
+
ttl: number,
|
|
371
|
+
swr?: number,
|
|
372
|
+
tags?: string[],
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
return this.inner.putResponse?.(key, response, ttl, swr, tags);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
378
|
+
const seeded = this.items.get(key);
|
|
379
|
+
if (seeded) {
|
|
380
|
+
return {
|
|
381
|
+
value: seeded.value,
|
|
382
|
+
handles: seeded.handles,
|
|
383
|
+
tags: seeded.tags,
|
|
384
|
+
shouldRevalidate: false,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return this.inner.getItem ? this.inner.getItem(key) : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async setItem(
|
|
391
|
+
key: string,
|
|
392
|
+
value: string,
|
|
393
|
+
options?: CacheItemOptions,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
return this.inner.setItem?.(key, value, options);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getShell(
|
|
399
|
+
key: string,
|
|
400
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
401
|
+
return this.inner.getShell ? this.inner.getShell(key) : null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async putShell(
|
|
405
|
+
key: string,
|
|
406
|
+
entry: ShellCacheEntry,
|
|
407
|
+
ttlSeconds?: number,
|
|
408
|
+
swrSeconds?: number,
|
|
409
|
+
tags?: string[],
|
|
410
|
+
): Promise<void> {
|
|
411
|
+
return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
415
|
+
return this.inner.invalidateTags?.(tags);
|
|
416
|
+
}
|
|
417
|
+
}
|
package/src/cache/types.ts
CHANGED
|
@@ -136,6 +136,39 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
136
136
|
tags?: string[],
|
|
137
137
|
): Promise<void>;
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Get a cached PPR shell entry by key.
|
|
141
|
+
* Returns the stored prelude/postponed pair (see ShellCacheEntry) and whether
|
|
142
|
+
* it should be revalidated (SWR). Used by the shell-cache middleware to serve
|
|
143
|
+
* a cached HTML shell and resume fizz for just the live holes.
|
|
144
|
+
*
|
|
145
|
+
* Optional: a store that does not implement the shell family disables the
|
|
146
|
+
* shell-cache middleware (it fails open to the normal HTML render path).
|
|
147
|
+
*/
|
|
148
|
+
getShell?(
|
|
149
|
+
key: string,
|
|
150
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null>;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Store a PPR shell entry with TTL and optional SWR window.
|
|
154
|
+
* The prelude bytes and postponed state are version- and generation-coupled
|
|
155
|
+
* and travel together in a single entry (they must never mix across a React
|
|
156
|
+
* upgrade — the reactVersion field on the entry gates that at read time).
|
|
157
|
+
* @param key - Cache key
|
|
158
|
+
* @param entry - The shell prelude/postponed/version/createdAt bundle
|
|
159
|
+
* @param ttlSeconds - Time-to-live in seconds
|
|
160
|
+
* @param swrSeconds - Optional stale-while-revalidate window in seconds
|
|
161
|
+
* @param tags - Optional cache tags for invalidation (participates in
|
|
162
|
+
* invalidateTags via the same tag machinery as the item family)
|
|
163
|
+
*/
|
|
164
|
+
putShell?(
|
|
165
|
+
key: string,
|
|
166
|
+
entry: ShellCacheEntry,
|
|
167
|
+
ttlSeconds?: number,
|
|
168
|
+
swrSeconds?: number,
|
|
169
|
+
tags?: string[],
|
|
170
|
+
): Promise<void>;
|
|
171
|
+
|
|
139
172
|
/**
|
|
140
173
|
* Get a cached function result by key.
|
|
141
174
|
* Returns the serialized value, optional handle data, and staleness flag.
|
|
@@ -186,6 +219,131 @@ export interface CacheItemResult {
|
|
|
186
219
|
tags?: string[];
|
|
187
220
|
}
|
|
188
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A cached PPR (Partial Pre-rendering) shell entry.
|
|
224
|
+
*
|
|
225
|
+
* One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
|
|
226
|
+
* and React's postponed state — because the pair is version- and
|
|
227
|
+
* generation-coupled and must never be mixed across a React upgrade or a build
|
|
228
|
+
* change. The reactVersion and buildVersion fields are the read-time gates that
|
|
229
|
+
* enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
|
|
230
|
+
* whose reactVersion differs from the running React, or whose buildVersion
|
|
231
|
+
* differs from the running build, as a miss (the postponed blob encodes hole
|
|
232
|
+
* positions against one exact tree; resuming it against a different React or a
|
|
233
|
+
* different app build tree-mismatches inside resume(), AFTER the 200 + prelude
|
|
234
|
+
* are committed — an unrecoverable broken serve).
|
|
235
|
+
*/
|
|
236
|
+
export interface ShellCacheEntry {
|
|
237
|
+
/** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
|
|
238
|
+
prelude: string;
|
|
239
|
+
/**
|
|
240
|
+
* JSON.stringify of React's postponed state, or null when the shell settled
|
|
241
|
+
* with no holes (the DATA variant — served without a fizz resume).
|
|
242
|
+
*/
|
|
243
|
+
postponed: string | null;
|
|
244
|
+
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
245
|
+
reactVersion: string;
|
|
246
|
+
/**
|
|
247
|
+
* Build version captured at prerender time (the RSC handler's `version` —
|
|
248
|
+
* the `@rangojs/router:version` build stamp by default, bumped per build and
|
|
249
|
+
* on dev RSC-module edits). The second read-time gate: a persistent shared
|
|
250
|
+
* store (KV/runtime-cache) survives deploys, and an app-code change that
|
|
251
|
+
* keeps the same React version would otherwise leave a stale-build
|
|
252
|
+
* prelude+postponed live under the same key. Optional only for entries
|
|
253
|
+
* stored before the field existed — those are treated as a miss and the
|
|
254
|
+
* recapture re-stamps them (pre-release, no compat shim).
|
|
255
|
+
*/
|
|
256
|
+
buildVersion?: string;
|
|
257
|
+
/**
|
|
258
|
+
* The initialTheme the CAPTURE render was built with (the derived context's
|
|
259
|
+
* reqCtx.theme). The resume tail must render ThemeProvider with the SAME
|
|
260
|
+
* initialTheme the frozen prelude was rendered with: React resume requires the
|
|
261
|
+
* tree above the holes to match the prerendered tree, and initialTheme is
|
|
262
|
+
* per-request METADATA, not part of the cached segments — a visitor whose
|
|
263
|
+
* theme differs from the capturer's would otherwise produce a divergent resume
|
|
264
|
+
* tree (broken stitching/hydration). The visitor's real theme is applied
|
|
265
|
+
* pre-paint by the FOUC script and re-synced from the cookie post-mount by
|
|
266
|
+
* ThemeProvider.
|
|
267
|
+
*/
|
|
268
|
+
initialTheme?: string;
|
|
269
|
+
/**
|
|
270
|
+
* The CAPTURE DATA SNAPSHOT: every cache-store read-hit and write the capture
|
|
271
|
+
* render performed, in stored/serialized form. Replaying these on a HIT (via
|
|
272
|
+
* the SeededShellStore overlay, for the tail render only) reproduces the
|
|
273
|
+
* shell's cached content byte-identically, so the freshly rendered hydration
|
|
274
|
+
* payload matches the frozen prelude even after the underlying cache entries
|
|
275
|
+
* have drifted (expired, been recomputed, or been tag-invalidated).
|
|
276
|
+
*
|
|
277
|
+
* Optional: an entry captured before this field existed simply has no
|
|
278
|
+
* snapshot and keeps the pre-snapshot behavior (the tail reads live, so any
|
|
279
|
+
* shell-baked cached value that drifted mismatches the prelude). Recapture
|
|
280
|
+
* heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
|
|
281
|
+
*/
|
|
282
|
+
snapshot?: ShellSnapshotRecord[];
|
|
283
|
+
/** Epoch ms when the shell was captured. */
|
|
284
|
+
createdAt: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The families a shell snapshot pins. The item/segment/response families are
|
|
289
|
+
* cache-store reads/writes (recorded by RecordingShellStore); the loader family
|
|
290
|
+
* pins the settled CONTAINER of a bake-lane loader (a loader on an entry with
|
|
291
|
+
* no renderable loading(), executed during capture — see
|
|
292
|
+
* docs/design/loader-container-bake.md). Excludes the shell family itself
|
|
293
|
+
* (getShell/putShell) — the snapshot rides INSIDE a shell entry, so recording
|
|
294
|
+
* it would be self-referential.
|
|
295
|
+
*/
|
|
296
|
+
export type ShellSnapshotFamily = "item" | "segment" | "response" | "loader";
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The stored form of a loader-family snapshot value: the bake-lane loader's
|
|
300
|
+
* settled container, Flight-serialized AFTER eliding every still-pending nested
|
|
301
|
+
* promise to a hole marker (the marker paths are holes, not shell material; on
|
|
302
|
+
* a HIT the overlay re-slots the fresh run's promises there). Flight (not JSON)
|
|
303
|
+
* so typed values (Date/Map) survive the round trip.
|
|
304
|
+
*/
|
|
305
|
+
export interface ShellSnapshotLoaderValue {
|
|
306
|
+
/** RSC-serialized elided container (see loader-snapshot.ts). */
|
|
307
|
+
value: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A serialized cached Response for the response family of a shell snapshot. */
|
|
311
|
+
export interface ShellSnapshotResponseValue {
|
|
312
|
+
status: number;
|
|
313
|
+
/** Client-facing header pairs (per-client signal headers excluded at record). */
|
|
314
|
+
headers: [string, string][];
|
|
315
|
+
/** base64-encoded response body (binary-safe, JSON-serializable). */
|
|
316
|
+
body: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The stored form of an item-family (use cache / loader cache) snapshot value. */
|
|
320
|
+
export interface ShellSnapshotItemValue {
|
|
321
|
+
/** RSC-serialized return value. */
|
|
322
|
+
value: string;
|
|
323
|
+
/** RSC-encoded handle data, if any. */
|
|
324
|
+
handles?: string;
|
|
325
|
+
/** The entry's cache tags. */
|
|
326
|
+
tags?: string[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* One recorded cache-store read-hit or write from the capture render. `value`
|
|
331
|
+
* carries the entry in its stored/serialized shape so it round-trips through a
|
|
332
|
+
* JSON-serializing store (KV, CF, Vercel) with the rest of the ShellCacheEntry:
|
|
333
|
+
* - `item` -> {@link ShellSnapshotItemValue}
|
|
334
|
+
* - `segment` -> {@link CachedEntryData} (already JSON-able)
|
|
335
|
+
* - `response`-> {@link ShellSnapshotResponseValue}
|
|
336
|
+
*/
|
|
337
|
+
export interface ShellSnapshotRecord {
|
|
338
|
+
family: ShellSnapshotFamily;
|
|
339
|
+
key: string;
|
|
340
|
+
value:
|
|
341
|
+
| ShellSnapshotItemValue
|
|
342
|
+
| CachedEntryData
|
|
343
|
+
| ShellSnapshotResponseValue
|
|
344
|
+
| ShellSnapshotLoaderValue;
|
|
345
|
+
}
|
|
346
|
+
|
|
189
347
|
/**
|
|
190
348
|
* Options for setItem() for function-level caching ("use cache").
|
|
191
349
|
*/
|