@rangojs/router 0.0.0-experimental.146 → 0.0.0-experimental.148
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/bin/rango.js +7 -0
- package/dist/vite/index.js +823 -235
- package/package.json +6 -1
- package/skills/mime-routes/SKILL.md +25 -17
- package/skills/ppr/SKILL.md +20 -10
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +11 -2
- package/src/cache/cf/cf-cache-store.ts +60 -2
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/types.ts +14 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/index.rsc.ts +6 -0
- package/src/prerender/build-shell-capture.ts +253 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/content-negotiation.ts +47 -5
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/metrics.ts +17 -2
- package/src/router/prerender-match.ts +21 -0
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +13 -0
- package/src/router.ts +5 -0
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/handler.ts +4 -2
- package/src/rsc/rsc-rendering.ts +131 -23
- package/src/rsc/shell-build-manifest.ts +274 -0
- package/src/rsc/shell-capture.ts +486 -63
- package/src/rsc/shell-serve.ts +44 -0
- package/src/rsc/ssr-setup.ts +54 -22
- package/src/segment-fragments.ts +124 -0
- package/src/server/context.ts +1 -0
- package/src/server/request-context.ts +65 -11
- package/src/ssr/index.tsx +47 -9
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/urls/pattern-types.ts +27 -0
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +397 -0
- package/src/vite/discovery/state.ts +44 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/rango.ts +1 -0
- package/src/vite/router-discovery.ts +310 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.0.0-experimental.
|
|
3
|
+
"version": "0.0.0-experimental.148",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -128,6 +128,11 @@
|
|
|
128
128
|
"types": "./src/build/index.ts",
|
|
129
129
|
"import": "./src/build/index.ts"
|
|
130
130
|
},
|
|
131
|
+
"./build/shell-capture": {
|
|
132
|
+
"types": "./src/prerender/build-shell-capture.ts",
|
|
133
|
+
"react-server": "./src/prerender/build-shell-capture.ts",
|
|
134
|
+
"import": "./src/prerender/build-shell-capture.ts"
|
|
135
|
+
},
|
|
131
136
|
"./host": {
|
|
132
137
|
"types": "./src/host/index.ts",
|
|
133
138
|
"react-server": "./src/host/index.ts",
|
|
@@ -41,26 +41,33 @@ When an API client requests the same URL (`Accept: application/json`), the JSON
|
|
|
41
41
|
1. **Q-value priority** — higher `q` wins (`Accept: application/json;q=0.9, text/html;q=1.0` serves RSC)
|
|
42
42
|
2. **Client order tiebreaker** — when q-values are equal, the type listed first in Accept wins (matches Express/Hono behavior)
|
|
43
43
|
3. **Specific MIME match** — the variant whose MIME type appears in Accept wins
|
|
44
|
-
4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins)
|
|
44
|
+
4. **Wildcard / empty Accept** — `*/*` and missing Accept fall back to route definition order (the first-defined variant wins); when the RSC route wins this way, it serves the HTML document
|
|
45
45
|
5. **All responses** on a negotiated URL get `Vary: Accept` header, including the RSC side
|
|
46
46
|
|
|
47
|
-
RSC participates as a
|
|
48
|
-
|
|
47
|
+
RSC participates as a candidate alongside response-type variants under two MIME
|
|
48
|
+
types: `text/html` (the document — its canonical representation) and
|
|
49
|
+
`text/x-component` (the RSC flight wire format). There is no special
|
|
50
|
+
short-circuit — RSC follows the same negotiation rules as other types.
|
|
49
51
|
|
|
50
52
|
The MIME mapping used for matching:
|
|
51
53
|
|
|
52
|
-
| Tag | MIME type
|
|
53
|
-
| -------------------- |
|
|
54
|
-
| RSC (plain `path()`) | `text/html`
|
|
55
|
-
| `json` | `application/json`
|
|
56
|
-
| `text` | `text/plain`
|
|
57
|
-
| `xml` | `application/xml`
|
|
58
|
-
| `html` | `text/html`
|
|
59
|
-
| `md` | `text/markdown`
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
the
|
|
54
|
+
| Tag | MIME type |
|
|
55
|
+
| -------------------- | -------------------------------------- |
|
|
56
|
+
| RSC (plain `path()`) | `text/html` **and** `text/x-component` |
|
|
57
|
+
| `json` | `application/json` |
|
|
58
|
+
| `text` | `text/plain` |
|
|
59
|
+
| `xml` | `application/xml` |
|
|
60
|
+
| `html` | `text/html` |
|
|
61
|
+
| `md` | `text/markdown` |
|
|
62
|
+
|
|
63
|
+
Which representation an RSC win renders is decided by the same Accept header:
|
|
64
|
+
the flight wire format is **explicit opt-in only** (`Accept: text/x-component`,
|
|
65
|
+
or the internal `_rsc_*`/`__rsc` transport params the client runtime sends).
|
|
66
|
+
Everything else — browsers, `curl` (`*/*`), a missing Accept header, mismatched
|
|
67
|
+
types like `application/json` on a URL with no JSON variant — gets the HTML
|
|
68
|
+
document. A generic HTTP client never sees the wire format by accident, and an
|
|
69
|
+
explicit `Accept: text/x-component` selects the RSC flight stream even on a
|
|
70
|
+
route where a response variant is defined first.
|
|
64
71
|
|
|
65
72
|
Tags `image`, `stream`, and `any` are pass-through and do not participate in Accept matching.
|
|
66
73
|
|
|
@@ -77,11 +84,12 @@ export const urlpatterns = urls(({ path }) => [
|
|
|
77
84
|
]);
|
|
78
85
|
```
|
|
79
86
|
|
|
80
|
-
- `Accept: text/html` — RSC page
|
|
87
|
+
- `Accept: text/html` — RSC page (HTML document)
|
|
81
88
|
- `Accept: application/json` — JSON handler
|
|
82
89
|
- `Accept: text/plain` — text handler
|
|
83
90
|
- `Accept: application/xml` — XML handler
|
|
84
|
-
- `Accept: */*` — RSC page (the primary, since it was registered first)
|
|
91
|
+
- `Accept: */*` — RSC page as HTML (the primary, since it was registered first)
|
|
92
|
+
- `Accept: text/x-component` — RSC page as the flight wire format
|
|
85
93
|
|
|
86
94
|
## Wildcard Routes
|
|
87
95
|
|
package/skills/ppr/SKILL.md
CHANGED
|
@@ -171,13 +171,12 @@ auth middleware anywhere (global or route DSL) and it guards PPR for free.
|
|
|
171
171
|
|
|
172
172
|
## Verifying it works
|
|
173
173
|
|
|
174
|
-
The header exists on DOCUMENT responses only. A bare `curl`
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
wrong request shape:
|
|
174
|
+
The header exists on DOCUMENT responses only. A bare `curl` gets the HTML
|
|
175
|
+
document (Flight is explicit-opt-in via `Accept: text/x-component`), so it
|
|
176
|
+
sees the header directly; only an explicit Flight request shape lacks it:
|
|
178
177
|
|
|
179
178
|
```
|
|
180
|
-
curl -s -D - -o /dev/null
|
|
179
|
+
curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-shell
|
|
181
180
|
```
|
|
182
181
|
|
|
183
182
|
- First document GET: `MISS`, plus a background capture.
|
|
@@ -193,6 +192,16 @@ curl -s -D - -o /dev/null -H "Accept: text/html" https://app.example.com/product
|
|
|
193
192
|
- A ppr-declared route that CANNOT be honored (missing shell store family,
|
|
194
193
|
per-request nonce) serves plain axis 1 with NO header and warns once per
|
|
195
194
|
key — no header + a declared `ppr` means look for that warning.
|
|
195
|
+
- On Cloudflare, `CFCacheStore` WITHOUT a KV namespace has an inert shell
|
|
196
|
+
family (the shell tier is KV-only): every ppr route stays `MISS` forever.
|
|
197
|
+
The store warns once per isolate — bind KV
|
|
198
|
+
(`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
|
|
199
|
+
- Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
|
|
200
|
+
logs one line per capture attempt/skip (outcome, durations, prelude and
|
|
201
|
+
snapshot bytes, backoff state); pass a function to receive each
|
|
202
|
+
`ShellCaptureDebugEvent` instead. In dev, with `debugPerformance` on, the
|
|
203
|
+
last capture outcome for a key also rides the next document GET's
|
|
204
|
+
`Server-Timing` as `ppr-capture;desc="…"`.
|
|
196
205
|
|
|
197
206
|
## The hole doctrine (encode this in your head)
|
|
198
207
|
|
|
@@ -505,11 +514,12 @@ path(
|
|
|
505
514
|
);
|
|
506
515
|
```
|
|
507
516
|
|
|
508
|
-
| Field
|
|
509
|
-
|
|
|
510
|
-
| `ttl`
|
|
511
|
-
| `swr`
|
|
512
|
-
| `tags`
|
|
517
|
+
| Field | Default | Notes |
|
|
518
|
+
| ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
519
|
+
| `ttl` | `300` | shell freshness window in seconds (`ppr: true` uses the default) |
|
|
520
|
+
| `swr` | — | stale window: serve the stale shell + background recapture |
|
|
521
|
+
| `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
|
|
522
|
+
| `maxSnapshotBytes` | 8 MiB | cap on the entry's capture data snapshot; over it the snapshot is skipped (shell still stored, warned once per key) so the entry stays under store limits |
|
|
513
523
|
|
|
514
524
|
The shell store is always the app-level `createRouter({ cache })` store; the
|
|
515
525
|
default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
|
|
@@ -615,8 +615,22 @@ export function createEventController(
|
|
|
615
615
|
const arbitration = arb;
|
|
616
616
|
arbitration.inflight++;
|
|
617
617
|
|
|
618
|
-
// Track if this action started while
|
|
619
|
-
|
|
618
|
+
// Track if this action started while another was genuinely in-flight.
|
|
619
|
+
// Completed entries don't count: complete()/fail() ran, so the prior
|
|
620
|
+
// action's response was fully processed and applied — a request dispatched
|
|
621
|
+
// after that point is strictly ordered behind the prior action's server
|
|
622
|
+
// execution, and there is no skipped render or order uncertainty for
|
|
623
|
+
// consolidation to repair. Completed entries still linger in the map for
|
|
624
|
+
// the 100ms doSettle window (useAction reads) and, on a slow connection,
|
|
625
|
+
// while the Flight stream drains its EOF after complete(). Counting them
|
|
626
|
+
// latched hadAnyConcurrentActions for back-to-back sequential actions,
|
|
627
|
+
// which made the LAST action classify as consolidation-needed; the
|
|
628
|
+
// consolidation refetch omits every concurrently-revalidated segment id
|
|
629
|
+
// from _rsc_segments, so the server re-ran gated loaders as "new-segment"
|
|
630
|
+
// — bypassing revalidate(({ isAction }) => ...) on a plain GET (#675).
|
|
631
|
+
const hadConcurrent = [...inflightActions.values()].some(
|
|
632
|
+
(a) => !a.completed,
|
|
633
|
+
);
|
|
620
634
|
if (hadConcurrent) {
|
|
621
635
|
hadAnyConcurrentActions = true;
|
|
622
636
|
}
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from "./types.js";
|
|
23
23
|
import type { EventController } from "./event-controller.js";
|
|
24
24
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
25
|
+
import { expandSegmentFragments } from "../segment-fragments.js";
|
|
25
26
|
import { initRangoState } from "./rango-state.js";
|
|
26
27
|
import { registerNavigationStore } from "./navigation-store-handle.js";
|
|
27
28
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
@@ -162,6 +163,16 @@ export async function initBrowserApp(
|
|
|
162
163
|
const initialPayload =
|
|
163
164
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
164
165
|
|
|
166
|
+
// Shell-HIT documents carry replayed segments as VERBATIM stored fragments
|
|
167
|
+
// (segment-fragments.ts, issue #700); expand them through the browser
|
|
168
|
+
// deserializer BEFORE any consumer reads the segments (store seed,
|
|
169
|
+
// renderSegments, history cache). Non-HIT payloads have no envelopes and pay
|
|
170
|
+
// one field scan. The SSR resume pass ran the same expansion (ssr-root.tsx),
|
|
171
|
+
// so the hydrated tree matches the server-rendered one by construction.
|
|
172
|
+
await expandSegmentFragments(initialPayload.metadata?.segments, (stream) =>
|
|
173
|
+
deps.createFromReadableStream(stream),
|
|
174
|
+
);
|
|
175
|
+
|
|
165
176
|
// Extract themeConfig and initialTheme from payload if not explicitly provided
|
|
166
177
|
// This allows virtual entries to work without importing the router
|
|
167
178
|
const effectiveThemeConfig =
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -285,10 +285,19 @@ export class CacheScope {
|
|
|
285
285
|
// partial: evict the entry (self-heal - the re-render re-caches under the
|
|
286
286
|
// same key) and report it as corruption, distinct from a transient infra
|
|
287
287
|
// error (handled by the outer catch).
|
|
288
|
+
//
|
|
289
|
+
// Shell-HIT tail (_shellFragmentPayload, issue #700): skip the decode and
|
|
290
|
+
// carry the stored fragment strings verbatim — the payload consumers
|
|
291
|
+
// expand them (segment-fragments.ts). Read off the ambient context at the
|
|
292
|
+
// same point the cache key was resolved (getDefaultRouteCacheKey), so the
|
|
293
|
+
// flag shares fate with the key: a disrupted ALS already missed the
|
|
294
|
+
// seeded record and degraded to the full tail.
|
|
288
295
|
let segments: ResolvedSegment[];
|
|
289
296
|
try {
|
|
290
|
-
const
|
|
291
|
-
segments =
|
|
297
|
+
const codec = await import("./segment-codec.js");
|
|
298
|
+
segments = _getRequestContext()?._shellFragmentPayload
|
|
299
|
+
? await codec.fragmentSegments(cached.segments)
|
|
300
|
+
: await codec.deserializeSegments(cached.segments);
|
|
292
301
|
} catch (error) {
|
|
293
302
|
reportCacheError(
|
|
294
303
|
error,
|
|
@@ -137,6 +137,13 @@ const warnedNoKvReadInvalidation = new Set<string>();
|
|
|
137
137
|
*/
|
|
138
138
|
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Stores (by namespace) already warned about the shell family being inert
|
|
142
|
+
* (getShell/putShell no-op without a KV namespace), so a ppr route hitting the
|
|
143
|
+
* silent fail-open warns once per isolate instead of on every request.
|
|
144
|
+
*/
|
|
145
|
+
const warnedShellFamilyInert = new Set<string>();
|
|
146
|
+
|
|
140
147
|
/**
|
|
141
148
|
* Stores (by namespace) already warned that tag invalidation is writing KV
|
|
142
149
|
* markers with no expiry (tagInvalidationTtl unset), so the unbounded-growth
|
|
@@ -227,6 +234,13 @@ interface KVShellEnvelope {
|
|
|
227
234
|
i?: string;
|
|
228
235
|
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
|
|
229
236
|
sn?: import("../types.js").ShellSnapshotRecord[];
|
|
237
|
+
/**
|
|
238
|
+
* ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
|
|
239
|
+
* handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
|
|
240
|
+
* here silently fast-pathed handler-live entries after a KV round trip —
|
|
241
|
+
* their holes only a handler re-run can fill.
|
|
242
|
+
*/
|
|
243
|
+
lh?: boolean;
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
/**
|
|
@@ -1635,6 +1649,27 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1635
1649
|
// still applies: shell entries carry tags/taggedAt and are checked against the
|
|
1636
1650
|
// same KV markers isGloballyInvalidated() reads for every other tier.
|
|
1637
1651
|
|
|
1652
|
+
/**
|
|
1653
|
+
* Warn once per isolate that the shell family is inert: getShell/putShell
|
|
1654
|
+
* are ONLY called for routes that declared the `ppr` path option, so firing
|
|
1655
|
+
* here (not in the constructor) scopes the warning to apps that actually
|
|
1656
|
+
* use PPR — a KV-less CFCacheStore is a perfectly fine config otherwise.
|
|
1657
|
+
* Without it, the correctness-first fail-open (issue #651) is invisible:
|
|
1658
|
+
* every ppr route is a permanent MISS with zero diagnostics.
|
|
1659
|
+
* @internal
|
|
1660
|
+
*/
|
|
1661
|
+
private warnShellFamilyInertOnce(): void {
|
|
1662
|
+
this.warnOncePerNamespace(
|
|
1663
|
+
warnedShellFamilyInert,
|
|
1664
|
+
`[CFCacheStore] a ppr route resolved to this store, but no KV namespace ` +
|
|
1665
|
+
`is configured, so the shell family (getShell/putShell) is a no-op: ` +
|
|
1666
|
+
`every ppr route stays a permanent shell MISS (the page still serves ` +
|
|
1667
|
+
`via a full render). Bind a KV namespace and pass it — ` +
|
|
1668
|
+
`new CFCacheStore({ ctx, kv: env.CACHE_KV }) — or use a shell-capable ` +
|
|
1669
|
+
`store via createRouter({ cache }).`,
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1638
1673
|
/**
|
|
1639
1674
|
* Get a cached PPR shell entry by key from KV (no L1). Applies the KV read
|
|
1640
1675
|
* budget, corrupt-entry eviction, hard-expiry, and tag invalidation exactly
|
|
@@ -1645,7 +1680,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1645
1680
|
async getShell(
|
|
1646
1681
|
key: string,
|
|
1647
1682
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
1648
|
-
if (!this.kv)
|
|
1683
|
+
if (!this.kv) {
|
|
1684
|
+
this.warnShellFamilyInertOnce();
|
|
1685
|
+
return null;
|
|
1686
|
+
}
|
|
1649
1687
|
try {
|
|
1650
1688
|
const kvKey = this.toKVKey(`shell:${key}`);
|
|
1651
1689
|
const { value: envelope, timedOut } =
|
|
@@ -1678,6 +1716,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1678
1716
|
buildVersion: envelope.bv,
|
|
1679
1717
|
initialTheme: envelope.i,
|
|
1680
1718
|
snapshot: envelope.sn,
|
|
1719
|
+
handlerLiveHoles: envelope.lh,
|
|
1681
1720
|
createdAt: envelope.c,
|
|
1682
1721
|
},
|
|
1683
1722
|
shouldRevalidate,
|
|
@@ -1701,7 +1740,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1701
1740
|
tags?: string[],
|
|
1702
1741
|
): Promise<void> {
|
|
1703
1742
|
// KV-only tier: needs a KV namespace and waitUntil (writes are non-blocking).
|
|
1704
|
-
if (!this.kv
|
|
1743
|
+
if (!this.kv) {
|
|
1744
|
+
this.warnShellFamilyInertOnce();
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
if (!this.waitUntil) return;
|
|
1705
1748
|
try {
|
|
1706
1749
|
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
1707
1750
|
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
|
@@ -1745,6 +1788,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1745
1788
|
ta: taggedAt,
|
|
1746
1789
|
i: entry.initialTheme,
|
|
1747
1790
|
sn: entry.snapshot,
|
|
1791
|
+
lh: entry.handlerLiveHoles,
|
|
1748
1792
|
};
|
|
1749
1793
|
return this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1750
1794
|
expirationTtl: totalTtl,
|
|
@@ -2260,6 +2304,20 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2260
2304
|
* silently reporting success while other requests/colos serve stale data. The
|
|
2261
2305
|
* eager purge still fires for the whole batch first (it is additive).
|
|
2262
2306
|
*/
|
|
2307
|
+
/**
|
|
2308
|
+
* Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince):
|
|
2309
|
+
* a baked shell entry is immutable in the build manifest, so eviction is
|
|
2310
|
+
* answered by the SAME KV tag markers updateTag() writes, compared against
|
|
2311
|
+
* the entry's build-time createdAt. Thin public wrapper over the private
|
|
2312
|
+
* envelope check (identical semantics: marker >= since, fail open).
|
|
2313
|
+
*/
|
|
2314
|
+
async isTagsInvalidatedSince(
|
|
2315
|
+
tags: string[],
|
|
2316
|
+
sinceMs: number,
|
|
2317
|
+
): Promise<boolean> {
|
|
2318
|
+
return this.isGloballyInvalidated(tags, sinceMs);
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2263
2321
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
2264
2322
|
if (tags.length === 0) return;
|
|
2265
2323
|
const invalidatedAt = Date.now();
|
|
@@ -30,6 +30,8 @@ const ITEM_CACHE_REGISTRY_KEY = "__rsc_router_item_cache_registry__";
|
|
|
30
30
|
const SHELL_CACHE_REGISTRY_KEY = "__rsc_router_shell_cache_registry__";
|
|
31
31
|
const TAG_INDEX_REGISTRY_KEY = "__rsc_router_tag_index_registry__";
|
|
32
32
|
const KEY_TAGS_REGISTRY_KEY = "__rsc_router_key_tags_registry__";
|
|
33
|
+
const TAG_INVALIDATED_AT_REGISTRY_KEY =
|
|
34
|
+
"__rsc_router_tag_invalidated_at_registry__";
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
37
|
* Get or create a named Map from a globalThis-backed registry.
|
|
@@ -194,6 +196,14 @@ export class MemorySegmentCacheStore<
|
|
|
194
196
|
private tagIndex: Map<string, Set<string>>;
|
|
195
197
|
/** prefixed cache key -> set of tags (reverse index for O(tags) unregister) */
|
|
196
198
|
private keyTags: Map<string, Set<string>>;
|
|
199
|
+
/**
|
|
200
|
+
* tag -> epoch ms of its latest invalidateTags() call. The build-shell
|
|
201
|
+
* read-through's isTagsInvalidatedSince gate: baked shell entries are
|
|
202
|
+
* immutable in the build manifest, so eviction is answered by comparing
|
|
203
|
+
* these markers against the entry's build-time createdAt. Per-isolate,
|
|
204
|
+
* like every other map here — matching this store's tag semantics.
|
|
205
|
+
*/
|
|
206
|
+
private tagInvalidatedAt: Map<string, number>;
|
|
197
207
|
readonly defaults?: CacheDefaults;
|
|
198
208
|
readonly keyGenerator?: (
|
|
199
209
|
ctx: RequestContext<TEnv>,
|
|
@@ -228,6 +238,10 @@ export class MemorySegmentCacheStore<
|
|
|
228
238
|
KEY_TAGS_REGISTRY_KEY,
|
|
229
239
|
options.name,
|
|
230
240
|
);
|
|
241
|
+
this.tagInvalidatedAt = getNamedMap<number>(
|
|
242
|
+
TAG_INVALIDATED_AT_REGISTRY_KEY,
|
|
243
|
+
options.name,
|
|
244
|
+
);
|
|
231
245
|
} else {
|
|
232
246
|
this.cache = new Map<string, CachedEntryData>();
|
|
233
247
|
this.responseCache = new Map<string, CachedResponseEntry>();
|
|
@@ -235,6 +249,7 @@ export class MemorySegmentCacheStore<
|
|
|
235
249
|
this.shellCache = new Map<string, CachedShellEntry>();
|
|
236
250
|
this.tagIndex = new Map<string, Set<string>>();
|
|
237
251
|
this.keyTags = new Map<string, Set<string>>();
|
|
252
|
+
this.tagInvalidatedAt = new Map<string, number>();
|
|
238
253
|
}
|
|
239
254
|
this.defaults = options?.defaults;
|
|
240
255
|
this.keyGenerator = options?.keyGenerator;
|
|
@@ -457,8 +472,25 @@ export class MemorySegmentCacheStore<
|
|
|
457
472
|
}
|
|
458
473
|
}
|
|
459
474
|
|
|
475
|
+
async isTagsInvalidatedSince(
|
|
476
|
+
tags: string[],
|
|
477
|
+
sinceMs: number,
|
|
478
|
+
): Promise<boolean> {
|
|
479
|
+
for (const tag of tags) {
|
|
480
|
+
const at = this.tagInvalidatedAt.get(tag);
|
|
481
|
+
// >= so a same-millisecond invalidation wins (freshness over staleness),
|
|
482
|
+
// matching the CF marker comparison.
|
|
483
|
+
if (at !== undefined && at >= sinceMs) return true;
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
460
488
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
489
|
+
const invalidatedAt = Date.now();
|
|
461
490
|
for (const tag of tags) {
|
|
491
|
+
// Marker first: build-shell entries evict by marker comparison even when
|
|
492
|
+
// no runtime entry currently carries the tag (tagIndex miss below).
|
|
493
|
+
this.tagInvalidatedAt.set(tag, invalidatedAt);
|
|
462
494
|
const keys = this.tagIndex.get(tag);
|
|
463
495
|
if (!keys || keys.size === 0) continue;
|
|
464
496
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { ResolvedSegment } from "../types.js";
|
|
12
12
|
import type { SerializedSegmentData } from "./types.js";
|
|
13
13
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
14
|
+
import { segmentFragment } from "../segment-fragments.js";
|
|
14
15
|
import {
|
|
15
16
|
renderToReadableStream,
|
|
16
17
|
createTemporaryReferenceSet,
|
|
@@ -206,6 +207,52 @@ export async function serializeSegments(
|
|
|
206
207
|
);
|
|
207
208
|
}
|
|
208
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Build ResolvedSegments that carry the STORED fragment strings verbatim
|
|
212
|
+
* instead of decoding them (PPR fast-path payload splice, issue #700; see
|
|
213
|
+
* segment-fragments.ts). The ReactNode fields (component/layout/loading)
|
|
214
|
+
* become {@link segmentFragment} envelopes the outer Flight render serializes
|
|
215
|
+
* as a string copy; the CONSUMER (SSR resume + browser hydration) expands them
|
|
216
|
+
* through its own deserializer. Loader data fields are NOT enveloped — they
|
|
217
|
+
* are consumer data of any shape (a marker there could collide) and decode
|
|
218
|
+
* server-side exactly as deserializeSegments does; on the doc/prerender
|
|
219
|
+
* records this path serves they are absent in practice (loaders are never
|
|
220
|
+
* cached with the route record).
|
|
221
|
+
*
|
|
222
|
+
* The loading "null" sentinel decodes here (not on the consumer): loading:null
|
|
223
|
+
* must survive as null, and shipping the raw sentinel would change the
|
|
224
|
+
* deduplicateLoaderSegments loading-presence check.
|
|
225
|
+
*/
|
|
226
|
+
export async function fragmentSegments(
|
|
227
|
+
data: SerializedSegmentData[],
|
|
228
|
+
): Promise<ResolvedSegment[]> {
|
|
229
|
+
return Promise.all(
|
|
230
|
+
data.map(async (item): Promise<ResolvedSegment> => {
|
|
231
|
+
const loadingIsNullSentinel = item.encodedLoading === "null";
|
|
232
|
+
const [loaderData, loaderDataPromise] = await Promise.all([
|
|
233
|
+
rscDeserialize(item.encodedLoaderData),
|
|
234
|
+
rscDeserialize(item.encodedLoaderDataPromise),
|
|
235
|
+
]);
|
|
236
|
+
return {
|
|
237
|
+
...item.metadata,
|
|
238
|
+
// Envelopes ride ReactNode-typed fields; the consumer expansion pass
|
|
239
|
+
// (segment-fragments.ts) replaces them before any render reads them.
|
|
240
|
+
component: segmentFragment(item.encoded) as unknown,
|
|
241
|
+
layout: item.encodedLayout
|
|
242
|
+
? (segmentFragment(item.encodedLayout) as unknown)
|
|
243
|
+
: undefined,
|
|
244
|
+
loading: loadingIsNullSentinel
|
|
245
|
+
? null
|
|
246
|
+
: item.encodedLoading !== undefined
|
|
247
|
+
? (segmentFragment(item.encodedLoading) as unknown)
|
|
248
|
+
: undefined,
|
|
249
|
+
loaderData,
|
|
250
|
+
loaderDataPromise,
|
|
251
|
+
} as ResolvedSegment;
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
209
256
|
/**
|
|
210
257
|
* Deserialize segments from storage.
|
|
211
258
|
* Reconstructs ResolvedSegment objects from RSC-serialized data.
|
package/src/cache/types.ts
CHANGED
|
@@ -196,6 +196,20 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
196
196
|
* @param tags - The cache tags to invalidate
|
|
197
197
|
*/
|
|
198
198
|
invalidateTags?(tags: string[]): Promise<void>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* True when ANY of `tags` was invalidated (invalidateTags/updateTag) at or
|
|
202
|
+
* after `sinceMs` (>= so a same-millisecond invalidation wins, favouring
|
|
203
|
+
* freshness). Consulted by the build-time shell read-through
|
|
204
|
+
* (rsc/shell-build-manifest.ts): a baked shell entry is immutable in the
|
|
205
|
+
* build manifest, so "was it evicted" is answered by the store's tag
|
|
206
|
+
* markers against the entry's createdAt rather than by deleting anything.
|
|
207
|
+
* Optional: without it, TAGGED build entries are not served (untagged ones
|
|
208
|
+
* are unaffected — they are evictable only by deploy/buildVersion anyway).
|
|
209
|
+
* Fail open to `false` on marker-read errors: a transient store fault must
|
|
210
|
+
* degrade to "still valid", the same posture as the envelope tag checks.
|
|
211
|
+
*/
|
|
212
|
+
isTagsInvalidatedSince?(tags: string[], sinceMs: number): Promise<boolean>;
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
/**
|
|
@@ -127,7 +127,18 @@ const REVALIDATION_LOCK_MS = 30_000;
|
|
|
127
127
|
/** Family prefixes that keep the value tiers from colliding in the single Vercel
|
|
128
128
|
* keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:) become
|
|
129
129
|
* the suffix; `rg:` namespaces every Rango entry. `h` is the PPR shell tier. */
|
|
130
|
-
type CacheFamily = "s" | "i" | "r" | "h";
|
|
130
|
+
type CacheFamily = "s" | "i" | "r" | "h" | "tm";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* TTL for tag-invalidation marker entries ("tm" family), written by
|
|
134
|
+
* invalidateTags for the build-shell read-through's isTagsInvalidatedSince
|
|
135
|
+
* gate. The platform's expireTag() DELETES tagged entries (no queryable
|
|
136
|
+
* history), so the markers are rango's own record of "tag X was invalidated
|
|
137
|
+
* at T". One year: markers must outlive any build's shell entries (which the
|
|
138
|
+
* buildVersion gate retires on the next deploy anyway); an expired marker
|
|
139
|
+
* would silently resurrect an updateTag()-evicted build shell.
|
|
140
|
+
*/
|
|
141
|
+
const TAG_MARKER_TTL_SECONDS = 365 * 24 * 60 * 60;
|
|
131
142
|
|
|
132
143
|
/** Stored envelope for a segment-tree entry (get/set). */
|
|
133
144
|
interface VercelSegmentEnvelope {
|
|
@@ -191,6 +202,13 @@ interface VercelShellEnvelope {
|
|
|
191
202
|
i?: string;
|
|
192
203
|
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
|
|
193
204
|
sn?: ShellSnapshotRecord[];
|
|
205
|
+
/**
|
|
206
|
+
* ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
|
|
207
|
+
* handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
|
|
208
|
+
* here silently fast-pathed handler-live entries after a store round trip —
|
|
209
|
+
* their holes only a handler re-run can fill.
|
|
210
|
+
*/
|
|
211
|
+
lh?: boolean;
|
|
194
212
|
}
|
|
195
213
|
|
|
196
214
|
/** Read-path outcome for the debug sink. */
|
|
@@ -781,6 +799,7 @@ export class VercelCacheStore<
|
|
|
781
799
|
buildVersion: env.bv,
|
|
782
800
|
initialTheme: env.i,
|
|
783
801
|
snapshot: env.sn,
|
|
802
|
+
handlerLiveHoles: env.lh,
|
|
784
803
|
createdAt: env.c,
|
|
785
804
|
},
|
|
786
805
|
shouldRevalidate,
|
|
@@ -815,6 +834,7 @@ export class VercelCacheStore<
|
|
|
815
834
|
t: safeTags.length > 0 ? safeTags : undefined,
|
|
816
835
|
i: entry.initialTheme,
|
|
817
836
|
sn: entry.snapshot,
|
|
837
|
+
lh: entry.handlerLiveHoles,
|
|
818
838
|
};
|
|
819
839
|
// write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
|
|
820
840
|
// oversized shell prelude is reported and skipped (fail-open to a full
|
|
@@ -833,6 +853,40 @@ export class VercelCacheStore<
|
|
|
833
853
|
|
|
834
854
|
// --- Tags ---
|
|
835
855
|
|
|
856
|
+
/**
|
|
857
|
+
* Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince).
|
|
858
|
+
* The platform's expireTag() DELETES tagged entries and keeps no queryable
|
|
859
|
+
* history, so invalidateTags() below writes its own "tm" marker entries and
|
|
860
|
+
* this compares them against the baked entry's build-time createdAt
|
|
861
|
+
* (>= so a same-millisecond invalidation wins). Fails open to `false` on
|
|
862
|
+
* read errors — the same posture as the CF marker check.
|
|
863
|
+
*/
|
|
864
|
+
async isTagsInvalidatedSince(
|
|
865
|
+
tags: string[],
|
|
866
|
+
sinceMs: number,
|
|
867
|
+
): Promise<boolean> {
|
|
868
|
+
try {
|
|
869
|
+
const markers = await Promise.all(
|
|
870
|
+
tags.map((tag) => this.cache.get(this.toStoreKey(tag, "tm"))),
|
|
871
|
+
);
|
|
872
|
+
for (const raw of markers) {
|
|
873
|
+
if (raw == null) continue;
|
|
874
|
+
const decoded = this.decodeRaw(raw) as { at?: unknown } | null;
|
|
875
|
+
const at =
|
|
876
|
+
decoded && typeof decoded.at === "number" ? decoded.at : null;
|
|
877
|
+
if (at !== null && at >= sinceMs) return true;
|
|
878
|
+
}
|
|
879
|
+
return false;
|
|
880
|
+
} catch (error) {
|
|
881
|
+
reportCacheError(
|
|
882
|
+
error,
|
|
883
|
+
"cache-read",
|
|
884
|
+
"[VercelCacheStore] tag invalidation check",
|
|
885
|
+
);
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
836
890
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
837
891
|
if (!tags || tags.length === 0) return;
|
|
838
892
|
// No per-item cap here: an invalidation must reach every requested tag.
|
|
@@ -842,6 +896,20 @@ export class VercelCacheStore<
|
|
|
842
896
|
"cache-invalidate",
|
|
843
897
|
);
|
|
844
898
|
if (safe.length === 0) return;
|
|
899
|
+
// Marker writes FIRST, and strict: isTagsInvalidatedSince() is how an
|
|
900
|
+
// updateTag() reaches a build-time shell entry (expireTag cannot delete
|
|
901
|
+
// what lives in the build manifest), so a failed marker write must reject
|
|
902
|
+
// like a failed expireTag — silently resolving would report success while
|
|
903
|
+
// the baked shell keeps serving.
|
|
904
|
+
await Promise.all(
|
|
905
|
+
safe.map((tag) =>
|
|
906
|
+
this.cache.set(
|
|
907
|
+
this.toStoreKey(tag, "tm"),
|
|
908
|
+
JSON.stringify({ at: Date.now() }),
|
|
909
|
+
{ ttl: TAG_MARKER_TTL_SECONDS },
|
|
910
|
+
),
|
|
911
|
+
),
|
|
912
|
+
);
|
|
845
913
|
try {
|
|
846
914
|
await this.cache.expireTag(safe);
|
|
847
915
|
} catch (error) {
|
|
@@ -1095,7 +1163,7 @@ export class VercelCacheStore<
|
|
|
1095
1163
|
|
|
1096
1164
|
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1097
1165
|
if (!isRecord(raw)) return null;
|
|
1098
|
-
const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
|
|
1166
|
+
const { p, po, rv, bv, c, s, e, t, i, sn, lh } = raw;
|
|
1099
1167
|
if (typeof p !== "string" || typeof rv !== "string") return null;
|
|
1100
1168
|
if (po !== null && typeof po !== "string") return null;
|
|
1101
1169
|
if (typeof c !== "number") return null;
|
|
@@ -1111,6 +1179,7 @@ export class VercelCacheStore<
|
|
|
1111
1179
|
t: Array.isArray(t) ? (t as string[]) : undefined,
|
|
1112
1180
|
i: typeof i === "string" ? i : undefined,
|
|
1113
1181
|
sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
|
|
1182
|
+
lh: lh === true ? true : undefined,
|
|
1114
1183
|
};
|
|
1115
1184
|
}
|
|
1116
1185
|
|
package/src/index.rsc.ts
CHANGED
|
@@ -88,6 +88,12 @@ export type {
|
|
|
88
88
|
OriginCheckPhase,
|
|
89
89
|
} from "./rsc/origin-guard.js";
|
|
90
90
|
|
|
91
|
+
// PPR shell-capture debug sink types (RangoOptions.debugShellCapture)
|
|
92
|
+
export type {
|
|
93
|
+
ShellCaptureDebug,
|
|
94
|
+
ShellCaptureDebugEvent,
|
|
95
|
+
} from "./rsc/shell-capture.js";
|
|
96
|
+
|
|
91
97
|
// Server-side createLoader and redirect
|
|
92
98
|
export {
|
|
93
99
|
createLoader,
|