@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
|
@@ -38,6 +38,8 @@ import type {
|
|
|
38
38
|
CacheGetResult,
|
|
39
39
|
CacheItemResult,
|
|
40
40
|
CacheItemOptions,
|
|
41
|
+
ShellCacheEntry,
|
|
42
|
+
ShellSnapshotRecord,
|
|
41
43
|
} from "../types.js";
|
|
42
44
|
import type { RequestContext } from "../../server/request-context.js";
|
|
43
45
|
import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
|
|
@@ -48,6 +50,16 @@ import {
|
|
|
48
50
|
DEFAULT_FUNCTION_TTL,
|
|
49
51
|
} from "../cache-policy.js";
|
|
50
52
|
import { reportCacheError, reportingAsync } from "../cache-error.js";
|
|
53
|
+
import type { CacheErrorCategory } from "../cache-error.js";
|
|
54
|
+
// Reuse the CF store's binary-safe base64 helpers. bufferToBase64 caps each
|
|
55
|
+
// String.fromCharCode batch at 8192 and uses .apply (never a spread), so a large
|
|
56
|
+
// Response/PPR-shell body cannot blow the JS argument-count ceiling (~65k) and
|
|
57
|
+
// throw RangeError inside putResponse/putShell - which the outer try/catch would
|
|
58
|
+
// swallow as a cache-write degrade, silently never caching the entry. Output is
|
|
59
|
+
// byte-identical to a per-byte encoder (chunk size does not affect base64), so
|
|
60
|
+
// this is a robustness fix, not a format change. Do NOT reintroduce a local
|
|
61
|
+
// spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
|
|
62
|
+
import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
|
|
51
63
|
|
|
52
64
|
/**
|
|
53
65
|
* Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
|
|
@@ -89,6 +101,19 @@ export const VERCEL_MAX_TAGS_PER_ITEM: number = 128;
|
|
|
89
101
|
/** Max tag length in UTF-8 bytes accepted by Vercel; longer tags are skipped. */
|
|
90
102
|
export const VERCEL_MAX_TAG_BYTES: number = 256;
|
|
91
103
|
|
|
104
|
+
/**
|
|
105
|
+
* URL metacharacters a tag must not contain. The official `@vercel/functions`
|
|
106
|
+
* client interpolates the tag straight into `revalidate?tags=${tag}` with NO
|
|
107
|
+
* URL-encoding (and also sends it in a request header), so a tag carrying one of
|
|
108
|
+
* these is stored intact on write but silently mangled server-side on
|
|
109
|
+
* invalidate: `&` starts a new query param, `?` a query, `#` truncates as a
|
|
110
|
+
* fragment, `%` is read as a (bad) percent-escape, `,` splits the tag list. The
|
|
111
|
+
* entry then never clears while `updateTag()`/`expireTag()` report success -
|
|
112
|
+
* the exact false-success the store's one deliberate throw exists to prevent.
|
|
113
|
+
* Such tags are dropped symmetrically on both the write and invalidate paths.
|
|
114
|
+
*/
|
|
115
|
+
const VERCEL_UNSAFE_TAG_CHARS = /[,&#%?]/;
|
|
116
|
+
|
|
92
117
|
/**
|
|
93
118
|
* Herd-dampening window (ms). On a stale read the store pushes the entry's
|
|
94
119
|
* staleAt forward by this much and re-writes it, so other readers in the same
|
|
@@ -99,10 +124,10 @@ export const VERCEL_MAX_TAG_BYTES: number = 256;
|
|
|
99
124
|
*/
|
|
100
125
|
const REVALIDATION_LOCK_MS = 30_000;
|
|
101
126
|
|
|
102
|
-
/** Family prefixes that keep the
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
type CacheFamily = "s" | "i" | "r";
|
|
127
|
+
/** Family prefixes that keep the value tiers from colliding in the single Vercel
|
|
128
|
+
* keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:) become
|
|
129
|
+
* the suffix; `rg:` namespaces every Rango entry. `h` is the PPR shell tier. */
|
|
130
|
+
type CacheFamily = "s" | "i" | "r" | "h";
|
|
106
131
|
|
|
107
132
|
/** Stored envelope for a segment-tree entry (get/set). */
|
|
108
133
|
interface VercelSegmentEnvelope {
|
|
@@ -140,10 +165,34 @@ interface VercelResponseEnvelope {
|
|
|
140
165
|
s: number;
|
|
141
166
|
/** expiresAt (ms since epoch). */
|
|
142
167
|
e: number;
|
|
143
|
-
/** Tags, preserved so a
|
|
168
|
+
/** Tags, preserved so a background revalidation re-write keeps them. */
|
|
144
169
|
t?: string[];
|
|
145
170
|
}
|
|
146
171
|
|
|
172
|
+
/** Stored envelope for a PPR shell entry (getShell/putShell). */
|
|
173
|
+
interface VercelShellEnvelope {
|
|
174
|
+
/** base64-encoded prelude bytes. */
|
|
175
|
+
p: string;
|
|
176
|
+
/** postponed state JSON, or null (DATA variant). */
|
|
177
|
+
po: string | null;
|
|
178
|
+
/** React.version at capture. */
|
|
179
|
+
rv: string;
|
|
180
|
+
/** Build version at capture (ShellCacheEntry.buildVersion). */
|
|
181
|
+
bv?: string;
|
|
182
|
+
/** createdAt (ms since epoch). */
|
|
183
|
+
c: number;
|
|
184
|
+
/** staleAt (ms since epoch). */
|
|
185
|
+
s: number;
|
|
186
|
+
/** expiresAt (ms since epoch). */
|
|
187
|
+
e: number;
|
|
188
|
+
/** Tags, preserved so a background revalidation re-write keeps them. */
|
|
189
|
+
t?: string[];
|
|
190
|
+
/** initialTheme the capture render was built with (resume theme fidelity). */
|
|
191
|
+
i?: string;
|
|
192
|
+
/** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
|
|
193
|
+
sn?: ShellSnapshotRecord[];
|
|
194
|
+
}
|
|
195
|
+
|
|
147
196
|
/** Read-path outcome for the debug sink. */
|
|
148
197
|
export type VercelCacheReadOutcome =
|
|
149
198
|
| "miss"
|
|
@@ -155,7 +204,7 @@ export type VercelCacheReadOutcome =
|
|
|
155
204
|
|
|
156
205
|
/** Diagnostic event emitted on every read when `debug` is set. */
|
|
157
206
|
export interface VercelCacheReadDebugEvent {
|
|
158
|
-
op: "get" | "getItem" | "getResponse";
|
|
207
|
+
op: "get" | "getItem" | "getResponse" | "getShell";
|
|
159
208
|
key: string;
|
|
160
209
|
outcome: VercelCacheReadOutcome;
|
|
161
210
|
staleAt?: number;
|
|
@@ -188,9 +237,9 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
|
|
|
188
237
|
cache: VercelRuntimeCache;
|
|
189
238
|
|
|
190
239
|
/**
|
|
191
|
-
* `waitUntil` from `@vercel/functions`. Used only to run the stale-read
|
|
192
|
-
*
|
|
193
|
-
* backgrounds the actual writes. When omitted, the
|
|
240
|
+
* `waitUntil` from `@vercel/functions`. Used only to run the stale-read lock
|
|
241
|
+
* write (herd dampening) off the response path - the router already
|
|
242
|
+
* backgrounds the actual writes. When omitted, the lock write runs detached
|
|
194
243
|
* (fire-and-forget) instead.
|
|
195
244
|
*/
|
|
196
245
|
waitUntil?: (promise: Promise<unknown>) => void;
|
|
@@ -239,25 +288,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
239
288
|
return typeof value === "object" && value !== null;
|
|
240
289
|
}
|
|
241
290
|
|
|
242
|
-
/** Encode binary body bytes to base64 in chunks (avoids call-stack blowups). */
|
|
243
|
-
function bufferToBase64(buffer: ArrayBuffer): string {
|
|
244
|
-
const bytes = new Uint8Array(buffer);
|
|
245
|
-
let binary = "";
|
|
246
|
-
const CHUNK = 0x8000;
|
|
247
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
248
|
-
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
249
|
-
}
|
|
250
|
-
return btoa(binary);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Decode a base64 body back into bytes. */
|
|
254
|
-
function base64ToBuffer(b64: string): ArrayBuffer {
|
|
255
|
-
const binary = atob(b64);
|
|
256
|
-
const bytes = new Uint8Array(binary.length);
|
|
257
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
258
|
-
return bytes.buffer;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
291
|
/**
|
|
262
292
|
* Vercel Runtime Cache-backed segment cache store.
|
|
263
293
|
*
|
|
@@ -276,6 +306,16 @@ function base64ToBuffer(b64: string): ArrayBuffer {
|
|
|
276
306
|
* that propagates expireTag rejections does get the strict guarantee, which is
|
|
277
307
|
* what the unit suite exercises.)
|
|
278
308
|
*
|
|
309
|
+
* Key hashing / family isolation. The store namespaces its three value tiers as
|
|
310
|
+
* `rg:{s|i|r}:{key}` so segment, `"use cache"`, and response entries occupy
|
|
311
|
+
* disjoint keyspaces. That separation is only as strong as the cache's
|
|
312
|
+
* `keyHashFunction`: `getCache`'s default is djb2, which folds every key to 32
|
|
313
|
+
* bits (8 hex chars), so at a large live-key count a collision can let one
|
|
314
|
+
* entry's body be served for another key (same-family) or be misread as corrupt
|
|
315
|
+
* (cross-family). For deployments with many cached keys, pass a wider hash so
|
|
316
|
+
* the family split is real isolation, e.g. `getCache({ keyHashFunction })` with
|
|
317
|
+
* a sha256-based function.
|
|
318
|
+
*
|
|
279
319
|
* @example
|
|
280
320
|
* ```ts
|
|
281
321
|
* import { getCache, waitUntil } from "@vercel/functions";
|
|
@@ -284,7 +324,9 @@ function base64ToBuffer(b64: string): ArrayBuffer {
|
|
|
284
324
|
* export const router = createRouter({
|
|
285
325
|
* cache: () => ({
|
|
286
326
|
* store: new VercelCacheStore({
|
|
287
|
-
*
|
|
327
|
+
* // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
|
|
328
|
+
* // would be undefined here) so cross-deploy busting via `version` works.
|
|
329
|
+
* cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
|
|
288
330
|
* waitUntil,
|
|
289
331
|
* defaults: { ttl: 60, swr: 300 },
|
|
290
332
|
* }),
|
|
@@ -343,7 +385,7 @@ export class VercelCacheStore<
|
|
|
343
385
|
this.emitDebug({ op: "get", key, outcome: "miss", readMs });
|
|
344
386
|
return null;
|
|
345
387
|
}
|
|
346
|
-
const env = this.asSegmentEnvelope(raw);
|
|
388
|
+
const env = this.asSegmentEnvelope(this.decodeRaw(raw));
|
|
347
389
|
if (!env) {
|
|
348
390
|
reportCacheError(
|
|
349
391
|
new Error("malformed segment envelope"),
|
|
@@ -370,35 +412,19 @@ export class VercelCacheStore<
|
|
|
370
412
|
}
|
|
371
413
|
|
|
372
414
|
const isStale = env.s > 0 && now > env.s;
|
|
373
|
-
|
|
374
|
-
this.
|
|
375
|
-
|
|
376
|
-
env,
|
|
377
|
-
env.d.tags,
|
|
378
|
-
"[VercelCacheStore] get",
|
|
379
|
-
);
|
|
380
|
-
this.emitDebug({
|
|
381
|
-
op: "get",
|
|
382
|
-
key,
|
|
383
|
-
outcome: "stale-revalidate",
|
|
384
|
-
shouldRevalidate: true,
|
|
385
|
-
staleAt: env.s,
|
|
386
|
-
expiresAt: env.e,
|
|
387
|
-
readMs,
|
|
388
|
-
});
|
|
389
|
-
return { data: env.d, shouldRevalidate: true };
|
|
390
|
-
}
|
|
391
|
-
|
|
415
|
+
const shouldRevalidate = isStale
|
|
416
|
+
? await this.claimRevalidation(storeKey, env.e, "[VercelCacheStore] get")
|
|
417
|
+
: false;
|
|
392
418
|
this.emitDebug({
|
|
393
419
|
op: "get",
|
|
394
420
|
key,
|
|
395
|
-
outcome: "fresh",
|
|
396
|
-
shouldRevalidate
|
|
421
|
+
outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
|
|
422
|
+
shouldRevalidate,
|
|
397
423
|
staleAt: env.s,
|
|
398
424
|
expiresAt: env.e,
|
|
399
425
|
readMs,
|
|
400
426
|
});
|
|
401
|
-
return { data: env.d, shouldRevalidate
|
|
427
|
+
return { data: env.d, shouldRevalidate };
|
|
402
428
|
}
|
|
403
429
|
|
|
404
430
|
async set(
|
|
@@ -410,12 +436,22 @@ export class VercelCacheStore<
|
|
|
410
436
|
try {
|
|
411
437
|
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
412
438
|
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
413
|
-
|
|
439
|
+
// Embed the CLAMPED tags, like putResponse/setItem: the segment family
|
|
440
|
+
// carries its tags inside `d`, and a raw list would resurface dropped
|
|
441
|
+
// tags into recordRequestTags on every hit.
|
|
442
|
+
const safeTags = this.clampTagsForWrite(
|
|
443
|
+
data.tags,
|
|
444
|
+
"[VercelCacheStore] set",
|
|
445
|
+
);
|
|
446
|
+
const d: CachedEntryData = data.tags
|
|
447
|
+
? { ...data, tags: safeTags.length > 0 ? safeTags : undefined }
|
|
448
|
+
: data;
|
|
449
|
+
const env: VercelSegmentEnvelope = { d, s: staleAt, e: expiresAt };
|
|
414
450
|
await this.write(
|
|
415
451
|
this.toStoreKey(key, "s"),
|
|
416
452
|
env,
|
|
417
453
|
ttl + swrWindow,
|
|
418
|
-
|
|
454
|
+
safeTags,
|
|
419
455
|
"[VercelCacheStore] set",
|
|
420
456
|
);
|
|
421
457
|
} catch (error) {
|
|
@@ -456,7 +492,7 @@ export class VercelCacheStore<
|
|
|
456
492
|
this.emitDebug({ op: "getResponse", key, outcome: "miss", readMs });
|
|
457
493
|
return null;
|
|
458
494
|
}
|
|
459
|
-
const env = this.asResponseEnvelope(raw);
|
|
495
|
+
const env = this.asResponseEnvelope(this.decodeRaw(raw));
|
|
460
496
|
if (!env) {
|
|
461
497
|
reportCacheError(
|
|
462
498
|
new Error("malformed response envelope"),
|
|
@@ -482,11 +518,11 @@ export class VercelCacheStore<
|
|
|
482
518
|
return null;
|
|
483
519
|
}
|
|
484
520
|
|
|
485
|
-
// Reconstruct the Response BEFORE
|
|
486
|
-
// invalid base64 from base64ToBuffer, or bad header entries) would
|
|
487
|
-
// throw out of getResponse — breaking the fail-open contract — and
|
|
488
|
-
//
|
|
489
|
-
// a corrupt entry: report, evict, and miss.
|
|
521
|
+
// Reconstruct the Response BEFORE claiming revalidation. A corrupt body
|
|
522
|
+
// (e.g. invalid base64 from base64ToBuffer, or bad header entries) would
|
|
523
|
+
// otherwise throw out of getResponse — breaking the fail-open contract — and
|
|
524
|
+
// a stale read would have written a lock for an entry it then evicts. Treat a
|
|
525
|
+
// reconstruction failure as a corrupt entry: report, evict, and miss.
|
|
490
526
|
let response: Response;
|
|
491
527
|
try {
|
|
492
528
|
response = new Response(base64ToBuffer(env.b), {
|
|
@@ -505,24 +541,23 @@ export class VercelCacheStore<
|
|
|
505
541
|
}
|
|
506
542
|
|
|
507
543
|
const isStale = env.s > 0 && now > env.s;
|
|
508
|
-
|
|
509
|
-
this.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
544
|
+
const shouldRevalidate = isStale
|
|
545
|
+
? await this.claimRevalidation(
|
|
546
|
+
storeKey,
|
|
547
|
+
env.e,
|
|
548
|
+
"[VercelCacheStore] getResponse",
|
|
549
|
+
)
|
|
550
|
+
: false;
|
|
516
551
|
this.emitDebug({
|
|
517
552
|
op: "getResponse",
|
|
518
553
|
key,
|
|
519
|
-
outcome:
|
|
520
|
-
shouldRevalidate
|
|
554
|
+
outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
|
|
555
|
+
shouldRevalidate,
|
|
521
556
|
staleAt: env.s,
|
|
522
557
|
expiresAt: env.e,
|
|
523
558
|
readMs,
|
|
524
559
|
});
|
|
525
|
-
return { response, shouldRevalidate
|
|
560
|
+
return { response, shouldRevalidate };
|
|
526
561
|
}
|
|
527
562
|
|
|
528
563
|
async putResponse(
|
|
@@ -541,19 +576,28 @@ export class VercelCacheStore<
|
|
|
541
576
|
});
|
|
542
577
|
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
543
578
|
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
579
|
+
// Embed the CLAMPED tags in the envelope, not the raw list: tags dropped
|
|
580
|
+
// on write (unsafe/over-cap) would otherwise resurface on a hit and be
|
|
581
|
+
// merged into an upstream document's tag set, letting it claim a tag this
|
|
582
|
+
// entry can't actually be invalidated by. write() re-clamps (idempotent
|
|
583
|
+
// and silent on an already-clean list).
|
|
584
|
+
const safeTags = this.clampTagsForWrite(
|
|
585
|
+
tags,
|
|
586
|
+
"[VercelCacheStore] putResponse",
|
|
587
|
+
);
|
|
544
588
|
const env: VercelResponseEnvelope = {
|
|
545
589
|
b: bufferToBase64(body),
|
|
546
590
|
st: response.status,
|
|
547
591
|
hd: headers,
|
|
548
592
|
s: staleAt,
|
|
549
593
|
e: expiresAt,
|
|
550
|
-
t:
|
|
594
|
+
t: safeTags.length > 0 ? safeTags : undefined,
|
|
551
595
|
};
|
|
552
596
|
await this.write(
|
|
553
597
|
this.toStoreKey(key, "r"),
|
|
554
598
|
env,
|
|
555
599
|
ttl + swrWindow,
|
|
556
|
-
|
|
600
|
+
safeTags,
|
|
557
601
|
"[VercelCacheStore] putResponse",
|
|
558
602
|
);
|
|
559
603
|
} catch (error) {
|
|
@@ -580,7 +624,7 @@ export class VercelCacheStore<
|
|
|
580
624
|
this.emitDebug({ op: "getItem", key, outcome: "miss", readMs });
|
|
581
625
|
return null;
|
|
582
626
|
}
|
|
583
|
-
const env = this.asItemEnvelope(raw);
|
|
627
|
+
const env = this.asItemEnvelope(this.decodeRaw(raw));
|
|
584
628
|
if (!env) {
|
|
585
629
|
reportCacheError(
|
|
586
630
|
new Error("malformed item envelope"),
|
|
@@ -595,19 +639,30 @@ export class VercelCacheStore<
|
|
|
595
639
|
const now = Date.now();
|
|
596
640
|
if (now > env.e) {
|
|
597
641
|
void this.safeDelete(storeKey);
|
|
598
|
-
this.emitDebug({
|
|
642
|
+
this.emitDebug({
|
|
643
|
+
op: "getItem",
|
|
644
|
+
key,
|
|
645
|
+
outcome: "expired",
|
|
646
|
+
staleAt: env.s,
|
|
647
|
+
expiresAt: env.e,
|
|
648
|
+
readMs,
|
|
649
|
+
});
|
|
599
650
|
return null;
|
|
600
651
|
}
|
|
601
652
|
|
|
602
653
|
const isStale = env.s > 0 && now > env.s;
|
|
603
|
-
|
|
604
|
-
this.
|
|
605
|
-
|
|
654
|
+
const shouldRevalidate = isStale
|
|
655
|
+
? await this.claimRevalidation(
|
|
656
|
+
storeKey,
|
|
657
|
+
env.e,
|
|
658
|
+
"[VercelCacheStore] getItem",
|
|
659
|
+
)
|
|
660
|
+
: false;
|
|
606
661
|
this.emitDebug({
|
|
607
662
|
op: "getItem",
|
|
608
663
|
key,
|
|
609
|
-
outcome:
|
|
610
|
-
shouldRevalidate
|
|
664
|
+
outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
|
|
665
|
+
shouldRevalidate,
|
|
611
666
|
staleAt: env.s,
|
|
612
667
|
expiresAt: env.e,
|
|
613
668
|
readMs,
|
|
@@ -615,7 +670,7 @@ export class VercelCacheStore<
|
|
|
615
670
|
return {
|
|
616
671
|
value: env.v,
|
|
617
672
|
handles: env.h,
|
|
618
|
-
shouldRevalidate
|
|
673
|
+
shouldRevalidate,
|
|
619
674
|
tags: env.t,
|
|
620
675
|
};
|
|
621
676
|
}
|
|
@@ -629,18 +684,24 @@ export class VercelCacheStore<
|
|
|
629
684
|
const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
630
685
|
const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
|
|
631
686
|
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
687
|
+
// Store the clamped tags (see putResponse) so dropped tags don't resurface
|
|
688
|
+
// on a hit; write() re-clamps idempotently.
|
|
689
|
+
const safeTags = this.clampTagsForWrite(
|
|
690
|
+
options?.tags,
|
|
691
|
+
"[VercelCacheStore] setItem",
|
|
692
|
+
);
|
|
632
693
|
const env: VercelItemEnvelope = {
|
|
633
694
|
v: value,
|
|
634
695
|
h: options?.handles,
|
|
635
696
|
s: staleAt,
|
|
636
697
|
e: expiresAt,
|
|
637
|
-
t:
|
|
698
|
+
t: safeTags.length > 0 ? safeTags : undefined,
|
|
638
699
|
};
|
|
639
700
|
await this.write(
|
|
640
701
|
this.toStoreKey(key, "i"),
|
|
641
702
|
env,
|
|
642
703
|
ttl + swrWindow,
|
|
643
|
-
|
|
704
|
+
safeTags,
|
|
644
705
|
"[VercelCacheStore] setItem",
|
|
645
706
|
);
|
|
646
707
|
} catch (error) {
|
|
@@ -648,12 +709,138 @@ export class VercelCacheStore<
|
|
|
648
709
|
}
|
|
649
710
|
}
|
|
650
711
|
|
|
712
|
+
// --- Shell family (PPR shell resume - getShell/putShell) ---
|
|
713
|
+
|
|
714
|
+
async getShell(
|
|
715
|
+
key: string,
|
|
716
|
+
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
717
|
+
const storeKey = this.toStoreKey(key, "h");
|
|
718
|
+
const started = Date.now();
|
|
719
|
+
let raw: unknown;
|
|
720
|
+
try {
|
|
721
|
+
raw = await this.cache.get(storeKey);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
reportCacheError(error, "cache-read", "[VercelCacheStore] getShell");
|
|
724
|
+
this.emitDebug({ op: "getShell", key, outcome: "error" });
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const readMs = Date.now() - started;
|
|
728
|
+
|
|
729
|
+
if (raw == null) {
|
|
730
|
+
this.emitDebug({ op: "getShell", key, outcome: "miss", readMs });
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
const env = this.asShellEnvelope(this.decodeRaw(raw));
|
|
734
|
+
if (!env) {
|
|
735
|
+
reportCacheError(
|
|
736
|
+
new Error("malformed shell envelope"),
|
|
737
|
+
"cache-corrupt",
|
|
738
|
+
"[VercelCacheStore] getShell",
|
|
739
|
+
);
|
|
740
|
+
void this.safeDelete(storeKey);
|
|
741
|
+
this.emitDebug({ op: "getShell", key, outcome: "corrupt", readMs });
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
if (now > env.e) {
|
|
747
|
+
void this.safeDelete(storeKey);
|
|
748
|
+
this.emitDebug({
|
|
749
|
+
op: "getShell",
|
|
750
|
+
key,
|
|
751
|
+
outcome: "expired",
|
|
752
|
+
staleAt: env.s,
|
|
753
|
+
expiresAt: env.e,
|
|
754
|
+
readMs,
|
|
755
|
+
});
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const isStale = env.s > 0 && now > env.s;
|
|
760
|
+
const shouldRevalidate = isStale
|
|
761
|
+
? await this.claimRevalidation(
|
|
762
|
+
storeKey,
|
|
763
|
+
env.e,
|
|
764
|
+
"[VercelCacheStore] getShell",
|
|
765
|
+
)
|
|
766
|
+
: false;
|
|
767
|
+
this.emitDebug({
|
|
768
|
+
op: "getShell",
|
|
769
|
+
key,
|
|
770
|
+
outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
|
|
771
|
+
shouldRevalidate,
|
|
772
|
+
staleAt: env.s,
|
|
773
|
+
expiresAt: env.e,
|
|
774
|
+
readMs,
|
|
775
|
+
});
|
|
776
|
+
return {
|
|
777
|
+
entry: {
|
|
778
|
+
prelude: env.p,
|
|
779
|
+
postponed: env.po,
|
|
780
|
+
reactVersion: env.rv,
|
|
781
|
+
buildVersion: env.bv,
|
|
782
|
+
initialTheme: env.i,
|
|
783
|
+
snapshot: env.sn,
|
|
784
|
+
createdAt: env.c,
|
|
785
|
+
},
|
|
786
|
+
shouldRevalidate,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async putShell(
|
|
791
|
+
key: string,
|
|
792
|
+
entry: ShellCacheEntry,
|
|
793
|
+
ttlSeconds?: number,
|
|
794
|
+
swrSeconds?: number,
|
|
795
|
+
tags?: string[],
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
try {
|
|
798
|
+
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
799
|
+
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
|
800
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
801
|
+
// Store the CLAMPED tags (see putResponse/setItem) so dropped tags don't
|
|
802
|
+
// resurface on a hit; write() re-clamps idempotently.
|
|
803
|
+
const safeTags = this.clampTagsForWrite(
|
|
804
|
+
tags,
|
|
805
|
+
"[VercelCacheStore] putShell",
|
|
806
|
+
);
|
|
807
|
+
const env: VercelShellEnvelope = {
|
|
808
|
+
p: entry.prelude,
|
|
809
|
+
po: entry.postponed,
|
|
810
|
+
rv: entry.reactVersion,
|
|
811
|
+
bv: entry.buildVersion,
|
|
812
|
+
c: entry.createdAt,
|
|
813
|
+
s: staleAt,
|
|
814
|
+
e: expiresAt,
|
|
815
|
+
t: safeTags.length > 0 ? safeTags : undefined,
|
|
816
|
+
i: entry.initialTheme,
|
|
817
|
+
sn: entry.snapshot,
|
|
818
|
+
};
|
|
819
|
+
// write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
|
|
820
|
+
// oversized shell prelude is reported and skipped (fail-open to a full
|
|
821
|
+
// render), never silently no-op'd on the platform.
|
|
822
|
+
await this.write(
|
|
823
|
+
this.toStoreKey(key, "h"),
|
|
824
|
+
env,
|
|
825
|
+
ttl + swrWindow,
|
|
826
|
+
safeTags,
|
|
827
|
+
"[VercelCacheStore] putShell",
|
|
828
|
+
);
|
|
829
|
+
} catch (error) {
|
|
830
|
+
reportCacheError(error, "cache-write", "[VercelCacheStore] putShell");
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
651
834
|
// --- Tags ---
|
|
652
835
|
|
|
653
836
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
654
837
|
if (!tags || tags.length === 0) return;
|
|
655
838
|
// No per-item cap here: an invalidation must reach every requested tag.
|
|
656
|
-
const safe = this.validateTags(
|
|
839
|
+
const safe = this.validateTags(
|
|
840
|
+
tags,
|
|
841
|
+
"[VercelCacheStore] invalidateTags",
|
|
842
|
+
"cache-invalidate",
|
|
843
|
+
);
|
|
657
844
|
if (safe.length === 0) return;
|
|
658
845
|
try {
|
|
659
846
|
await this.cache.expireTag(safe);
|
|
@@ -675,6 +862,12 @@ export class VercelCacheStore<
|
|
|
675
862
|
|
|
676
863
|
// --- Internals ---
|
|
677
864
|
|
|
865
|
+
// The `rg:{family}:` prefix keeps the three value tiers in disjoint
|
|
866
|
+
// keyspaces, but the isolation is only as strong as the cache's
|
|
867
|
+
// keyHashFunction: djb2 (getCache's default) folds this to 32 bits, so at a
|
|
868
|
+
// large live-key count a same-family collision can serve the wrong body and a
|
|
869
|
+
// cross-family one reads as corrupt. See the class doc for passing a wider
|
|
870
|
+
// hash (sha256) when many keys are live.
|
|
678
871
|
private toStoreKey(key: string, family: CacheFamily): string {
|
|
679
872
|
const versionPrefix = this.version ? `v/${this.version}/` : "";
|
|
680
873
|
return `${versionPrefix}rg:${family}:${key}`;
|
|
@@ -687,72 +880,135 @@ export class VercelCacheStore<
|
|
|
687
880
|
tags: string[] | undefined,
|
|
688
881
|
label: string,
|
|
689
882
|
): Promise<void> {
|
|
690
|
-
|
|
883
|
+
// Serialize the envelope exactly ONCE: the same string measures the entry
|
|
884
|
+
// against the size cap AND is what we hand the platform. The Vercel client
|
|
885
|
+
// JSON-serializes whatever value it is given, so passing the raw object here
|
|
886
|
+
// would walk and stringify the (potentially large) envelope a SECOND time.
|
|
887
|
+
// Entries written this way are JSON strings; the read path (decodeRaw)
|
|
888
|
+
// parses them back, and still accepts the legacy object shape written before
|
|
889
|
+
// this change.
|
|
890
|
+
let serialized: string | undefined;
|
|
891
|
+
try {
|
|
892
|
+
serialized = JSON.stringify(value);
|
|
893
|
+
} catch {
|
|
894
|
+
// Unserializable value: fall back to letting the platform serialize it
|
|
895
|
+
// (best-effort — matches the prior "cannot measure -> allow the write").
|
|
896
|
+
serialized = undefined;
|
|
897
|
+
}
|
|
898
|
+
if (serialized !== undefined && !this.withinSizeLimit(serialized, label)) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
691
901
|
const safeTags = this.clampTagsForWrite(tags, label);
|
|
692
902
|
const options: { ttl: number; tags?: string[]; name?: string } = {
|
|
693
903
|
ttl: Math.max(1, Math.ceil(totalTtlSeconds)),
|
|
694
904
|
};
|
|
695
905
|
if (safeTags.length > 0) options.tags = safeTags;
|
|
696
906
|
if (this.name) options.name = this.name;
|
|
697
|
-
await this.cache.set(storeKey, value, options);
|
|
907
|
+
await this.cache.set(storeKey, serialized ?? value, options);
|
|
698
908
|
}
|
|
699
909
|
|
|
700
910
|
/**
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
911
|
+
* Normalize a value read back from the cache. Entries written by this store
|
|
912
|
+
* are pre-serialized JSON strings (see write()); legacy entries (written
|
|
913
|
+
* before that change, or by a client that deserializes for us) come back as
|
|
914
|
+
* objects. Parse strings, pass objects through, and treat a non-JSON string
|
|
915
|
+
* as corrupt (returns undefined, so the caller reports + evicts).
|
|
705
916
|
*/
|
|
706
|
-
private
|
|
917
|
+
private decodeRaw(raw: unknown): unknown {
|
|
918
|
+
if (typeof raw !== "string") return raw;
|
|
919
|
+
try {
|
|
920
|
+
return JSON.parse(raw);
|
|
921
|
+
} catch {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Stale-read herd dampening via a tiny companion lock key. On a stale read,
|
|
928
|
+
* check `{storeKey}:lock`; if absent, claim it (write a short-TTL marker) and
|
|
929
|
+
* return true so THIS reader triggers revalidation. If present, another
|
|
930
|
+
* same-region reader already claimed it, so return false and serve the stale
|
|
931
|
+
* entry as fresh without piling on. Best-effort and non-blocking; never throws.
|
|
932
|
+
*
|
|
933
|
+
* Only the STALE path pays the extra lock read; the fresh-hit path takes no
|
|
934
|
+
* lock round trip. Unlike the prior whole-envelope re-stamp, a stale read now
|
|
935
|
+
* writes only the tiny lock, never the (potentially large) payload.
|
|
936
|
+
*
|
|
937
|
+
* Non-atomic (getCache has no compare-and-set): two readers can both find no
|
|
938
|
+
* lock and both revalidate. The window is REVALIDATION_LOCK_MS wide, after
|
|
939
|
+
* which the lock expires and the entry is re-claimed (or a fresh write has
|
|
940
|
+
* superseded it). A read-compare would not close the race without CAS.
|
|
941
|
+
*/
|
|
942
|
+
private async claimRevalidation(
|
|
707
943
|
storeKey: string,
|
|
708
|
-
|
|
709
|
-
tags: string[] | undefined,
|
|
944
|
+
expiresAt: number,
|
|
710
945
|
label: string,
|
|
711
|
-
):
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
946
|
+
): Promise<boolean> {
|
|
947
|
+
const lockKey = `${storeKey}:lock`;
|
|
948
|
+
let lock: unknown;
|
|
949
|
+
try {
|
|
950
|
+
lock = await this.cache.get(lockKey);
|
|
951
|
+
} catch {
|
|
952
|
+
// A lock-read failure must not break the read path: treat as unlocked.
|
|
953
|
+
lock = null;
|
|
954
|
+
}
|
|
955
|
+
if (lock != null) return false;
|
|
956
|
+
|
|
957
|
+
const remainingSeconds = Math.ceil((expiresAt - Date.now()) / 1000);
|
|
958
|
+
const lockTtl = Math.max(
|
|
959
|
+
1,
|
|
960
|
+
Math.min(Math.ceil(REVALIDATION_LOCK_MS / 1000), remainingSeconds),
|
|
961
|
+
);
|
|
719
962
|
const task = (): Promise<void> =>
|
|
720
|
-
this.
|
|
963
|
+
this.cache.set(lockKey, 1, { ttl: lockTtl });
|
|
721
964
|
if (this.waitUntil) {
|
|
722
965
|
this.waitUntil(reportingAsync(task, "cache-write", label));
|
|
723
966
|
} else {
|
|
724
967
|
void reportingAsync(task, "cache-write", label);
|
|
725
968
|
}
|
|
969
|
+
return true;
|
|
726
970
|
}
|
|
727
971
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
}
|
|
741
|
-
} catch {
|
|
742
|
-
// If the value cannot be measured, allow the write (best-effort).
|
|
972
|
+
/** Measure a pre-serialized entry against the per-item size cap (see write). */
|
|
973
|
+
private withinSizeLimit(serialized: string, label: string): boolean {
|
|
974
|
+
const bytes = new TextEncoder().encode(serialized).length;
|
|
975
|
+
if (bytes >= this.maxItemBytes) {
|
|
976
|
+
reportCacheError(
|
|
977
|
+
new Error(
|
|
978
|
+
`entry is ${bytes}B, at/above the ${this.maxItemBytes}B cap; not cached`,
|
|
979
|
+
),
|
|
980
|
+
"cache-write",
|
|
981
|
+
label,
|
|
982
|
+
);
|
|
983
|
+
return false;
|
|
743
984
|
}
|
|
744
985
|
return true;
|
|
745
986
|
}
|
|
746
987
|
|
|
747
|
-
/**
|
|
748
|
-
|
|
988
|
+
/**
|
|
989
|
+
* Drop tags Vercel cannot round-trip: over-length tags, and tags carrying a
|
|
990
|
+
* URL metacharacter the `@vercel/functions` client does not encode (see
|
|
991
|
+
* VERCEL_UNSAFE_TAG_CHARS). Runs on BOTH paths - the write path
|
|
992
|
+
* (clampTagsForWrite) and invalidateTags - so a tag that would silently fail
|
|
993
|
+
* invalidation is never stored in the first place. `category` reports the
|
|
994
|
+
* drop under the caller's real operation (write vs invalidate).
|
|
995
|
+
*/
|
|
996
|
+
private validateTags(
|
|
997
|
+
tags: string[],
|
|
998
|
+
label: string,
|
|
999
|
+
category: CacheErrorCategory,
|
|
1000
|
+
): string[] {
|
|
749
1001
|
const encoder = new TextEncoder();
|
|
750
1002
|
const out: string[] = [];
|
|
751
1003
|
for (const tag of tags) {
|
|
752
|
-
|
|
1004
|
+
const unsafe = VERCEL_UNSAFE_TAG_CHARS.exec(tag);
|
|
1005
|
+
if (unsafe) {
|
|
753
1006
|
reportCacheError(
|
|
754
|
-
new Error(
|
|
755
|
-
|
|
1007
|
+
new Error(
|
|
1008
|
+
`tag "${tag}" contains an unencodable URL character "${unsafe[0]}"; ` +
|
|
1009
|
+
`skipped (it would not round-trip through Vercel tag invalidation)`,
|
|
1010
|
+
),
|
|
1011
|
+
category,
|
|
756
1012
|
label,
|
|
757
1013
|
);
|
|
758
1014
|
continue;
|
|
@@ -760,7 +1016,7 @@ export class VercelCacheStore<
|
|
|
760
1016
|
if (encoder.encode(tag).length > VERCEL_MAX_TAG_BYTES) {
|
|
761
1017
|
reportCacheError(
|
|
762
1018
|
new Error(`tag exceeds ${VERCEL_MAX_TAG_BYTES} bytes; skipped`),
|
|
763
|
-
|
|
1019
|
+
category,
|
|
764
1020
|
label,
|
|
765
1021
|
);
|
|
766
1022
|
continue;
|
|
@@ -776,7 +1032,7 @@ export class VercelCacheStore<
|
|
|
776
1032
|
label: string,
|
|
777
1033
|
): string[] {
|
|
778
1034
|
if (!tags || tags.length === 0) return [];
|
|
779
|
-
const valid = this.validateTags(tags, label);
|
|
1035
|
+
const valid = this.validateTags(tags, label, "cache-write");
|
|
780
1036
|
if (valid.length > VERCEL_MAX_TAGS_PER_ITEM) {
|
|
781
1037
|
reportCacheError(
|
|
782
1038
|
new Error(
|
|
@@ -837,6 +1093,27 @@ export class VercelCacheStore<
|
|
|
837
1093
|
};
|
|
838
1094
|
}
|
|
839
1095
|
|
|
1096
|
+
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1097
|
+
if (!isRecord(raw)) return null;
|
|
1098
|
+
const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
|
|
1099
|
+
if (typeof p !== "string" || typeof rv !== "string") return null;
|
|
1100
|
+
if (po !== null && typeof po !== "string") return null;
|
|
1101
|
+
if (typeof c !== "number") return null;
|
|
1102
|
+
if (typeof s !== "number" || typeof e !== "number") return null;
|
|
1103
|
+
return {
|
|
1104
|
+
p,
|
|
1105
|
+
po: po as string | null,
|
|
1106
|
+
rv,
|
|
1107
|
+
bv: typeof bv === "string" ? bv : undefined,
|
|
1108
|
+
c,
|
|
1109
|
+
s,
|
|
1110
|
+
e,
|
|
1111
|
+
t: Array.isArray(t) ? (t as string[]) : undefined,
|
|
1112
|
+
i: typeof i === "string" ? i : undefined,
|
|
1113
|
+
sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
840
1117
|
private asResponseEnvelope(raw: unknown): VercelResponseEnvelope | null {
|
|
841
1118
|
if (!isRecord(raw)) return null;
|
|
842
1119
|
const { b, st, hd, s, e, t } = raw;
|