@rangojs/router 0.0.0-experimental.31 → 0.0.0-experimental.3232cd17
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 +4 -0
- package/README.md +198 -44
- package/dist/bin/rango.js +287 -105
- package/dist/testing/vitest.js +82 -0
- package/dist/vite/index.js +3248 -1117
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +73 -21
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +107 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +245 -21
- package/skills/caching/SKILL.md +302 -6
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +270 -30
- package/skills/host-router/SKILL.md +82 -22
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +49 -5
- package/skills/layout/SKILL.md +35 -9
- package/skills/links/SKILL.md +249 -17
- package/skills/loader/SKILL.md +294 -30
- package/skills/middleware/SKILL.md +52 -13
- package/skills/migrate-nextjs/SKILL.md +584 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +203 -7
- package/skills/prerender/SKILL.md +123 -100
- package/skills/rango/SKILL.md +250 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +97 -5
- package/skills/router-setup/SKILL.md +90 -5
- package/skills/server-actions/SKILL.md +775 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/testing/SKILL.md +129 -0
- package/skills/testing/bindings.md +89 -0
- package/skills/testing/cache-prerender.md +124 -0
- package/skills/testing/client-components.md +122 -0
- package/skills/testing/e2e-parity.md +125 -0
- package/skills/testing/flight.md +92 -0
- package/skills/testing/handles.md +129 -0
- package/skills/testing/loader.md +128 -0
- package/skills/testing/middleware.md +99 -0
- package/skills/testing/render-handler.md +121 -0
- package/skills/testing/response-routes.md +95 -0
- package/skills/testing/reverse-and-types.md +84 -0
- package/skills/testing/server-actions.md +107 -0
- package/skills/testing/server-tree.md +128 -0
- package/skills/testing/setup.md +120 -0
- package/skills/typesafety/SKILL.md +329 -27
- package/skills/use-cache/SKILL.md +36 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/__internal.ts +67 -40
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/action-fence.ts +47 -0
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/cookie-name.ts +140 -0
- package/src/browser/event-controller.ts +86 -147
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/invalidate-client-cache.ts +52 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +148 -19
- package/src/browser/navigation-client.ts +187 -67
- package/src/browser/navigation-store-handle.ts +38 -0
- package/src/browser/navigation-store.ts +76 -67
- package/src/browser/navigation-transaction.ts +18 -66
- package/src/browser/partial-update.ts +123 -94
- package/src/browser/prefetch/cache.ts +214 -36
- package/src/browser/prefetch/fetch.ts +260 -38
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +126 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +158 -76
- package/src/browser/react/Link.tsx +93 -11
- package/src/browser/react/NavigationProvider.tsx +115 -34
- package/src/browser/react/ScrollRestoration.tsx +10 -6
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/filter-segment-order.ts +49 -7
- package/src/browser/react/index.ts +0 -48
- package/src/browser/react/location-state-shared.ts +166 -8
- package/src/browser/react/location-state.ts +39 -14
- package/src/browser/react/use-action.ts +6 -15
- package/src/browser/react/use-handle.ts +23 -69
- package/src/browser/react/use-link-status.ts +0 -4
- package/src/browser/react/use-navigation.ts +22 -5
- package/src/browser/react/use-params.ts +20 -10
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +46 -11
- package/src/browser/react/use-search-params.ts +0 -5
- package/src/browser/react/use-segments.ts +11 -21
- package/src/browser/response-adapter.ts +52 -1
- package/src/browser/rsc-router.tsx +215 -76
- package/src/browser/scroll-restoration.ts +46 -39
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +176 -50
- package/src/browser/types.ts +95 -11
- package/src/browser/validate-redirect-origin.ts +43 -16
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +65 -40
- package/src/build/generate-route-types.ts +5 -0
- package/src/build/index.ts +8 -2
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +137 -32
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +9 -2
- package/src/build/route-types/param-extraction.ts +6 -3
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +278 -96
- package/src/build/route-types/scan-filter.ts +9 -2
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +68 -28
- package/src/cache/cache-runtime.ts +149 -43
- package/src/cache/cache-scope.ts +148 -81
- package/src/cache/cache-tag.ts +98 -0
- package/src/cache/cf/cf-cache-store.ts +2550 -93
- package/src/cache/cf/index.ts +11 -17
- package/src/cache/document-cache.ts +78 -27
- package/src/cache/handle-snapshot.ts +63 -0
- package/src/cache/index.ts +23 -20
- package/src/cache/memory-segment-store.ts +136 -37
- package/src/cache/profile-registry.ts +6 -30
- package/src/cache/read-through-swr.ts +41 -11
- package/src/cache/segment-codec.ts +0 -16
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/taint.ts +55 -0
- package/src/cache/types.ts +33 -100
- package/src/cache/vercel/index.ts +11 -0
- package/src/cache/vercel/vercel-cache-store.ts +799 -0
- package/src/client.rsc.tsx +6 -21
- package/src/client.tsx +108 -290
- package/src/component-utils.ts +19 -0
- package/src/context-var.ts +84 -2
- package/src/debug.ts +2 -2
- package/src/decode-loader-results.ts +36 -0
- package/src/defer.ts +196 -0
- package/src/deps/ssr.ts +0 -1
- package/src/errors.ts +30 -4
- package/src/handle.ts +70 -22
- package/src/handles/MetaTags.tsx +0 -14
- package/src/handles/breadcrumbs.ts +16 -5
- package/src/handles/meta.ts +0 -39
- package/src/host/cookie-handler.ts +0 -36
- package/src/host/errors.ts +0 -24
- package/src/host/index.ts +8 -2
- package/src/host/pattern-matcher.ts +7 -50
- package/src/host/router.ts +107 -99
- package/src/host/testing.ts +40 -27
- package/src/host/types.ts +37 -4
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +137 -22
- package/src/index.rsc.ts +52 -26
- package/src/index.ts +100 -38
- package/src/internal-debug.ts +2 -4
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +20 -13
- package/src/loader.ts +12 -11
- package/src/missing-id-error.ts +68 -0
- package/src/network-error-thrower.tsx +1 -6
- package/src/outlet-context.ts +1 -1
- package/src/outlet-provider.tsx +1 -5
- package/src/prerender/param-hash.ts +10 -11
- package/src/prerender/store.ts +37 -41
- package/src/prerender.ts +198 -82
- package/src/redirect-origin.ts +100 -0
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -15
- package/src/root-error-boundary.tsx +1 -19
- package/src/route-content-wrapper.tsx +7 -72
- package/src/route-definition/dsl-helpers.ts +437 -274
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +113 -37
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +52 -10
- package/src/route-definition/resolve-handler-use.ts +161 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-map-builder.ts +7 -17
- package/src/route-types.ts +37 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +108 -9
- package/src/router/error-handling.ts +13 -17
- package/src/router/find-match.ts +45 -22
- package/src/router/handler-context.ts +83 -41
- package/src/router/intercept-resolution.ts +25 -23
- package/src/router/lazy-includes.ts +19 -53
- package/src/router/loader-resolution.ts +213 -30
- package/src/router/logging.ts +5 -8
- package/src/router/manifest.ts +49 -45
- package/src/router/match-api.ts +121 -205
- package/src/router/match-context.ts +0 -22
- package/src/router/match-handlers.ts +58 -58
- package/src/router/match-middleware/background-revalidation.ts +27 -6
- package/src/router/match-middleware/cache-lookup.ts +205 -249
- package/src/router/match-middleware/cache-store.ts +45 -32
- package/src/router/match-middleware/intercept-resolution.ts +8 -28
- package/src/router/match-middleware/segment-resolution.ts +52 -18
- package/src/router/match-pipelines.ts +1 -42
- package/src/router/match-result.ts +104 -40
- package/src/router/metrics.ts +5 -34
- package/src/router/middleware-types.ts +13 -142
- package/src/router/middleware.ts +173 -143
- package/src/router/navigation-snapshot.ts +131 -0
- package/src/router/params-util.ts +23 -0
- package/src/router/pattern-matching.ts +109 -63
- package/src/router/prerender-match.ts +192 -54
- package/src/router/preview-match.ts +32 -102
- package/src/router/request-classification.ts +276 -0
- package/src/router/revalidation.ts +63 -55
- package/src/router/route-snapshot.ts +244 -0
- package/src/router/router-context.ts +6 -28
- package/src/router/router-interfaces.ts +100 -35
- package/src/router/router-options.ts +91 -11
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +242 -75
- package/src/router/segment-resolution/helpers.ts +64 -25
- package/src/router/segment-resolution/loader-cache.ts +41 -37
- package/src/router/segment-resolution/revalidation.ts +456 -372
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/segment-resolution.ts +4 -1
- package/src/router/segment-wrappers.ts +2 -3
- package/src/router/state-cookie-name.ts +33 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry-otel.ts +0 -20
- package/src/router/telemetry.ts +96 -19
- package/src/router/timeout.ts +0 -20
- package/src/router/trie-matching.ts +91 -46
- package/src/router/types.ts +10 -63
- package/src/router/url-params.ts +44 -0
- package/src/router.ts +134 -43
- package/src/rsc/handler-context.ts +3 -2
- package/src/rsc/handler.ts +492 -383
- package/src/rsc/helpers.ts +162 -46
- package/src/rsc/index.ts +1 -1
- package/src/rsc/json-route-result.ts +38 -0
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +33 -42
- package/src/rsc/origin-guard.ts +39 -25
- package/src/rsc/progressive-enhancement.ts +30 -3
- package/src/rsc/redirect-guard.ts +99 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +90 -63
- package/src/rsc/rsc-rendering.ts +56 -54
- package/src/rsc/runtime-warnings.ts +23 -10
- package/src/rsc/server-action.ts +74 -67
- package/src/rsc/ssr-setup.ts +18 -2
- package/src/rsc/types.ts +25 -6
- package/src/runtime-env.ts +18 -0
- package/src/search-params.ts +4 -20
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +134 -0
- package/src/segment-system.tsx +272 -129
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +309 -61
- package/src/server/cookie-store.ts +80 -5
- package/src/server/handle-store.ts +26 -24
- package/src/server/loader-registry.ts +10 -28
- package/src/server/request-context.ts +348 -128
- package/src/ssr/index.tsx +23 -15
- package/src/static-handler.ts +27 -18
- package/src/testing/cache-status.ts +162 -0
- package/src/testing/collect-handle.ts +40 -0
- package/src/testing/dispatch.ts +618 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +188 -0
- package/src/testing/e2e/index.ts +128 -0
- package/src/testing/e2e/matchers.ts +35 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +387 -0
- package/src/testing/e2e/server.ts +195 -0
- package/src/testing/flight-matchers.ts +97 -0
- package/src/testing/flight-normalize.ts +11 -0
- package/src/testing/flight-runtime.d.ts +57 -0
- package/src/testing/flight-tree.ts +682 -0
- package/src/testing/flight.entry.ts +52 -0
- package/src/testing/flight.ts +232 -0
- package/src/testing/generated-routes.ts +183 -0
- package/src/testing/index.ts +99 -0
- package/src/testing/internal/context.ts +348 -0
- package/src/testing/internal/flight-client-globals.ts +30 -0
- package/src/testing/internal/seed-vars.ts +54 -0
- package/src/testing/render-handler.ts +330 -0
- package/src/testing/render-route.tsx +566 -0
- package/src/testing/run-loader.ts +378 -0
- package/src/testing/run-middleware.ts +205 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +305 -0
- package/src/theme/ThemeProvider.tsx +0 -52
- package/src/theme/ThemeScript.tsx +0 -6
- package/src/theme/constants.ts +0 -12
- package/src/theme/index.ts +0 -7
- package/src/theme/theme-context.ts +1 -5
- package/src/theme/theme-script.ts +0 -14
- package/src/theme/use-theme.ts +0 -3
- package/src/types/boundaries.ts +0 -35
- package/src/types/cache-types.ts +17 -8
- package/src/types/error-types.ts +30 -90
- package/src/types/global-namespace.ts +54 -41
- package/src/types/handler-context.ts +233 -81
- package/src/types/index.ts +1 -10
- package/src/types/loader-types.ts +44 -15
- package/src/types/request-scope.ts +107 -0
- package/src/types/route-config.ts +6 -50
- package/src/types/route-entry.ts +19 -7
- package/src/types/segments.ts +37 -14
- package/src/urls/include-helper.ts +33 -70
- package/src/urls/index.ts +1 -11
- package/src/urls/path-helper-types.ts +58 -11
- package/src/urls/path-helper.ts +57 -111
- package/src/urls/pattern-types.ts +48 -19
- package/src/urls/response-types.ts +25 -22
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -18
- package/src/use-loader.tsx +346 -89
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +36 -38
- package/src/vite/discovery/discover-routers.ts +130 -85
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +192 -99
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +51 -6
- package/src/vite/discovery/virtual-module-codegen.ts +14 -34
- package/src/vite/index.ts +8 -0
- package/src/vite/plugin-types.ts +187 -69
- package/src/vite/plugins/cjs-to-esm.ts +8 -18
- package/src/vite/plugins/client-ref-dedup.ts +16 -11
- package/src/vite/plugins/client-ref-hashing.ts +28 -15
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
- package/src/vite/plugins/expose-action-id.ts +49 -98
- package/src/vite/plugins/expose-id-utils.ts +11 -50
- package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
- package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
- package/src/vite/plugins/expose-internal-ids.ts +554 -317
- package/src/vite/plugins/performance-tracks.ts +89 -0
- package/src/vite/plugins/refresh-cmd.ts +89 -27
- package/src/vite/plugins/use-cache-transform.ts +73 -83
- package/src/vite/plugins/vercel-output.ts +258 -0
- package/src/vite/plugins/version-injector.ts +21 -25
- package/src/vite/plugins/version-plugin.ts +41 -20
- package/src/vite/plugins/virtual-entries.ts +2 -17
- package/src/vite/rango.ts +257 -289
- package/src/vite/router-discovery.ts +930 -140
- package/src/vite/utils/ast-handler-extract.ts +15 -31
- package/src/vite/utils/banner.ts +4 -4
- package/src/vite/utils/bundle-analysis.ts +10 -15
- package/src/vite/utils/client-chunks.ts +184 -0
- package/src/vite/utils/forward-user-plugins.ts +171 -0
- package/src/vite/utils/manifest-utils.ts +4 -59
- package/src/vite/utils/package-resolution.ts +20 -52
- package/src/vite/utils/prerender-utils.ts +27 -29
- package/src/vite/utils/shared-utils.ts +92 -42
- package/src/browser/action-response-classifier.ts +0 -99
- package/src/browser/react/use-client-cache.ts +0 -58
- package/src/browser/shallow.ts +0 -40
- package/src/handles/index.ts +0 -7
- package/src/router/middleware-cookies.ts +0 -55
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel Runtime Cache Store
|
|
3
|
+
*
|
|
4
|
+
* A SegmentCacheStore backed by Vercel's Runtime Cache (`getCache()` from
|
|
5
|
+
* `@vercel/functions`). It is the production store analogue to CFCacheStore, but
|
|
6
|
+
* far smaller: Vercel's Runtime Cache already IS a distributed, tag-aware cache
|
|
7
|
+
* (regional storage, global tag-expire within ~300ms), so none of CFCacheStore's
|
|
8
|
+
* L1/L2 tiering, KV tag-marker comparison, marker memoization, or per-tier
|
|
9
|
+
* timeout budgets are needed here - the platform does that work. What this store
|
|
10
|
+
* adds on top of the raw primitive is the parts Vercel does NOT give us:
|
|
11
|
+
*
|
|
12
|
+
* 1. Stale-while-revalidate. `getCache` has no stale-but-serve: a TTL'd entry
|
|
13
|
+
* simply becomes a miss. We store our own {staleAt, expiresAt} envelope and
|
|
14
|
+
* set the Vercel ttl to (ttl + swr) so the entry survives its SWR window,
|
|
15
|
+
* computing staleness ourselves via the shared cache-policy helpers.
|
|
16
|
+
* 2. A single-keyspace family split. `getCache` is one flat keyspace, whereas
|
|
17
|
+
* the interface has three value families (segments, "use cache" items, full
|
|
18
|
+
* responses). The memory store keeps three separate Maps; here they must be
|
|
19
|
+
* namespaced by a prefix or a set() of a response would clobber a segment
|
|
20
|
+
* cached under the same router key.
|
|
21
|
+
* 3. The platform guardrails: the 2 MB per-item ceiling (oversized writes
|
|
22
|
+
* silently no-op on Vercel, so we skip + report), the per-item tag cap, and
|
|
23
|
+
* cross-deploy non-reconciliation (TTL/tag updates are not reconciled across
|
|
24
|
+
* deployments - bake a build id into `version` or the getCache namespace).
|
|
25
|
+
*
|
|
26
|
+
* Dependency stance: this module imports NOTHING from `@vercel/functions`. The
|
|
27
|
+
* runtime cache handle (and `waitUntil`) are injected through the constructor and
|
|
28
|
+
* typed against the local VercelRuntimeCache shape below, exactly as CFCacheStore
|
|
29
|
+
* takes its `kv`/`ctx` bindings by injection. The consumer passes the real
|
|
30
|
+
* `getCache(...)` result, which satisfies the shape structurally. That keeps the
|
|
31
|
+
* router free of a hard Vercel dependency.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type {
|
|
35
|
+
SegmentCacheStore,
|
|
36
|
+
CachedEntryData,
|
|
37
|
+
CacheDefaults,
|
|
38
|
+
CacheGetResult,
|
|
39
|
+
CacheItemResult,
|
|
40
|
+
CacheItemOptions,
|
|
41
|
+
} from "../types.js";
|
|
42
|
+
import type { RequestContext } from "../../server/request-context.js";
|
|
43
|
+
import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
|
|
44
|
+
import {
|
|
45
|
+
resolveTtl,
|
|
46
|
+
resolveSwrWindow,
|
|
47
|
+
computeExpiration,
|
|
48
|
+
DEFAULT_FUNCTION_TTL,
|
|
49
|
+
} from "../cache-policy.js";
|
|
50
|
+
import { reportCacheError, reportingAsync } from "../cache-error.js";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
|
|
54
|
+
* from `@vercel/functions`. Declared locally so @rangojs/router carries no hard
|
|
55
|
+
* dependency on `@vercel/functions`; the real handle satisfies it structurally.
|
|
56
|
+
*
|
|
57
|
+
* @see https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#getcache
|
|
58
|
+
*/
|
|
59
|
+
export interface VercelRuntimeCache {
|
|
60
|
+
/** Returns the stored value, or a nullish value on miss. Never throws on miss. */
|
|
61
|
+
get(key: string): Promise<unknown>;
|
|
62
|
+
/** Stores a value with optional TTL (seconds), tags, and observability name. */
|
|
63
|
+
set(
|
|
64
|
+
key: string,
|
|
65
|
+
value: unknown,
|
|
66
|
+
options?: { ttl?: number; tags?: string[]; name?: string },
|
|
67
|
+
): Promise<void>;
|
|
68
|
+
/** Removes a value by key. */
|
|
69
|
+
delete(key: string): Promise<void>;
|
|
70
|
+
/** Expires every entry tagged with any of `tag`. Global, ~300ms propagation. */
|
|
71
|
+
expireTag(tag: string | string[]): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Vercel Runtime Cache hard per-item ceiling: 2 MB. Writes above this silently
|
|
76
|
+
* no-op on the platform, so the store measures the serialized envelope and skips
|
|
77
|
+
* (fail-open) entries at or above this size.
|
|
78
|
+
*/
|
|
79
|
+
export const VERCEL_MAX_ITEM_BYTES: number = 2 * 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Per-item tag ceiling. Vercel's docs disagree (the API reference says 128, the
|
|
83
|
+
* Runtime Cache page says 64); 64 is the safe floor. Tags beyond this are dropped
|
|
84
|
+
* with a warning at write time. Does NOT cap invalidateTags() - an invalidation
|
|
85
|
+
* must reach every requested tag.
|
|
86
|
+
*/
|
|
87
|
+
export const VERCEL_MAX_TAGS_PER_ITEM: number = 64;
|
|
88
|
+
|
|
89
|
+
/** Max tag length in UTF-8 bytes accepted by Vercel; longer tags are skipped. */
|
|
90
|
+
export const VERCEL_MAX_TAG_BYTES: number = 256;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Herd-dampening window (ms). On a stale read the store pushes the entry's
|
|
94
|
+
* staleAt forward by this much and re-writes it, so other readers in the same
|
|
95
|
+
* region briefly see it as fresh while one revalidates. Best-effort and
|
|
96
|
+
* non-atomic: `getCache` has no compare-and-set, and storage is regional, so a
|
|
97
|
+
* race can still let two readers both trigger revalidation. Matches the intent of
|
|
98
|
+
* CFCacheStore's MAX_REVALIDATION_INTERVAL without its Cache-API atomicity.
|
|
99
|
+
*/
|
|
100
|
+
const REVALIDATION_LOCK_MS = 30_000;
|
|
101
|
+
|
|
102
|
+
/** Family prefixes that keep the three value tiers from colliding in the single
|
|
103
|
+
* Vercel keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:)
|
|
104
|
+
* become the suffix; `rg:` namespaces every Rango entry. */
|
|
105
|
+
type CacheFamily = "s" | "i" | "r";
|
|
106
|
+
|
|
107
|
+
/** Stored envelope for a segment-tree entry (get/set). */
|
|
108
|
+
interface VercelSegmentEnvelope {
|
|
109
|
+
/** The cached entry data. */
|
|
110
|
+
d: CachedEntryData;
|
|
111
|
+
/** staleAt (ms since epoch). */
|
|
112
|
+
s: number;
|
|
113
|
+
/** expiresAt (ms since epoch). */
|
|
114
|
+
e: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stored envelope for a "use cache" function result (getItem/setItem). */
|
|
118
|
+
interface VercelItemEnvelope {
|
|
119
|
+
/** RSC-serialized value. */
|
|
120
|
+
v: string;
|
|
121
|
+
/** RSC-encoded handle blob. */
|
|
122
|
+
h?: string;
|
|
123
|
+
/** staleAt (ms since epoch). */
|
|
124
|
+
s: number;
|
|
125
|
+
/** expiresAt (ms since epoch). */
|
|
126
|
+
e: number;
|
|
127
|
+
/** Tags, surfaced on read so a hit still contributes to the document tag set. */
|
|
128
|
+
t?: string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Stored envelope for a full Response (getResponse/putResponse). */
|
|
132
|
+
interface VercelResponseEnvelope {
|
|
133
|
+
/** base64-encoded body bytes. */
|
|
134
|
+
b: string;
|
|
135
|
+
/** HTTP status. */
|
|
136
|
+
st: number;
|
|
137
|
+
/** Header entries (per-client signal headers already stripped). */
|
|
138
|
+
hd: [string, string][];
|
|
139
|
+
/** staleAt (ms since epoch). */
|
|
140
|
+
s: number;
|
|
141
|
+
/** expiresAt (ms since epoch). */
|
|
142
|
+
e: number;
|
|
143
|
+
/** Tags, preserved so a stale re-stamp keeps them. */
|
|
144
|
+
t?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read-path outcome for the debug sink. */
|
|
148
|
+
export type VercelCacheReadOutcome =
|
|
149
|
+
| "miss"
|
|
150
|
+
| "fresh"
|
|
151
|
+
| "stale-revalidate"
|
|
152
|
+
| "expired"
|
|
153
|
+
| "corrupt"
|
|
154
|
+
| "error";
|
|
155
|
+
|
|
156
|
+
/** Diagnostic event emitted on every read when `debug` is set. */
|
|
157
|
+
export interface VercelCacheReadDebugEvent {
|
|
158
|
+
op: "get" | "getItem" | "getResponse";
|
|
159
|
+
key: string;
|
|
160
|
+
outcome: VercelCacheReadOutcome;
|
|
161
|
+
staleAt?: number;
|
|
162
|
+
expiresAt?: number;
|
|
163
|
+
shouldRevalidate?: boolean;
|
|
164
|
+
/** Wall-clock ms spent in the backing cache.get(). */
|
|
165
|
+
readMs?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `true` logs each read outcome; a function receives the structured event. */
|
|
169
|
+
export type VercelCacheDebug =
|
|
170
|
+
| boolean
|
|
171
|
+
| ((event: VercelCacheReadDebugEvent) => void);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Options for VercelCacheStore.
|
|
175
|
+
*/
|
|
176
|
+
export interface VercelCacheStoreOptions<TEnv = unknown> {
|
|
177
|
+
/**
|
|
178
|
+
* The Vercel Runtime Cache handle - `getCache()` from `@vercel/functions`.
|
|
179
|
+
* Required. Construct it with a build-hash namespace to bust stale-shaped
|
|
180
|
+
* entries across deployments, since Vercel does not reconcile TTL/tags between
|
|
181
|
+
* deploys:
|
|
182
|
+
*
|
|
183
|
+
* ```ts
|
|
184
|
+
* import { getCache } from "@vercel/functions";
|
|
185
|
+
* new VercelCacheStore({ cache: getCache({ namespace: BUILD_ID }) });
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
cache: VercelRuntimeCache;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `waitUntil` from `@vercel/functions`. Used only to run the stale-read
|
|
192
|
+
* re-stamp (herd dampening) off the response path - the router already
|
|
193
|
+
* backgrounds the actual writes. When omitted, the re-stamp runs detached
|
|
194
|
+
* (fire-and-forget) instead.
|
|
195
|
+
*/
|
|
196
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Default ttl/swr for cache() boundaries when not explicitly specified.
|
|
200
|
+
*/
|
|
201
|
+
defaults?: CacheDefaults;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Custom key generator applied to all cache operations (region/locale/user
|
|
205
|
+
* segmentation). Receives the request context and the default key.
|
|
206
|
+
*/
|
|
207
|
+
keyGenerator?: (
|
|
208
|
+
ctx: RequestContext<TEnv>,
|
|
209
|
+
defaultKey: string,
|
|
210
|
+
) => string | Promise<string>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build/version id folded into every stored key as `v/{version}/...`. A second
|
|
214
|
+
* cross-deploy busting layer in addition to (or instead of) the getCache
|
|
215
|
+
* namespace. Changing it invalidates everything this store wrote previously.
|
|
216
|
+
*/
|
|
217
|
+
version?: string;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Max serialized entry size in bytes before a write is skipped. Defaults to
|
|
221
|
+
* VERCEL_MAX_ITEM_BYTES (2 MB - the platform's hard cap).
|
|
222
|
+
*/
|
|
223
|
+
maxItemBytes?: number;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Human-readable label passed as the Vercel `set({ name })` option for
|
|
227
|
+
* observability in the Vercel dashboard.
|
|
228
|
+
*/
|
|
229
|
+
name?: string;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read diagnostics. `true` logs each read outcome to the console; a function
|
|
233
|
+
* receives the structured VercelCacheReadDebugEvent.
|
|
234
|
+
*/
|
|
235
|
+
debug?: VercelCacheDebug;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
239
|
+
return typeof value === "object" && value !== null;
|
|
240
|
+
}
|
|
241
|
+
|
|
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
|
+
/**
|
|
262
|
+
* Vercel Runtime Cache-backed segment cache store.
|
|
263
|
+
*
|
|
264
|
+
* Suitable for production deployments on Vercel Functions (Node or Edge). The
|
|
265
|
+
* store is best-effort: every read failure degrades to a miss and every write
|
|
266
|
+
* failure to a no-op (reported, never thrown) - the sole exception is
|
|
267
|
+
* invalidateTags(), which rejects on a failed expireTag so an awaited
|
|
268
|
+
* updateTag() surfaces it (read-your-own-writes honesty).
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```ts
|
|
272
|
+
* import { getCache, waitUntil } from "@vercel/functions";
|
|
273
|
+
* import { VercelCacheStore } from "@rangojs/router/cache";
|
|
274
|
+
*
|
|
275
|
+
* export const router = createRouter({
|
|
276
|
+
* cache: () => ({
|
|
277
|
+
* store: new VercelCacheStore({
|
|
278
|
+
* cache: getCache({ namespace: import.meta.env.VERCEL_DEPLOYMENT_ID }),
|
|
279
|
+
* waitUntil,
|
|
280
|
+
* defaults: { ttl: 60, swr: 300 },
|
|
281
|
+
* }),
|
|
282
|
+
* }),
|
|
283
|
+
* });
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export class VercelCacheStore<
|
|
287
|
+
TEnv = unknown,
|
|
288
|
+
> implements SegmentCacheStore<TEnv> {
|
|
289
|
+
readonly defaults?: CacheDefaults;
|
|
290
|
+
readonly keyGenerator?: (
|
|
291
|
+
ctx: RequestContext<TEnv>,
|
|
292
|
+
defaultKey: string,
|
|
293
|
+
) => string | Promise<string>;
|
|
294
|
+
|
|
295
|
+
private readonly cache: VercelRuntimeCache;
|
|
296
|
+
private readonly waitUntil?: (promise: Promise<unknown>) => void;
|
|
297
|
+
private readonly version?: string;
|
|
298
|
+
private readonly maxItemBytes: number;
|
|
299
|
+
private readonly name?: string;
|
|
300
|
+
private readonly debug?: VercelCacheDebug;
|
|
301
|
+
|
|
302
|
+
constructor(options: VercelCacheStoreOptions<TEnv>) {
|
|
303
|
+
if (!options || !options.cache) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"[VercelCacheStore] requires `cache` (the getCache() handle from @vercel/functions)",
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
this.cache = options.cache;
|
|
309
|
+
this.waitUntil = options.waitUntil;
|
|
310
|
+
this.defaults = options.defaults;
|
|
311
|
+
this.keyGenerator = options.keyGenerator;
|
|
312
|
+
this.version = options.version;
|
|
313
|
+
this.maxItemBytes = options.maxItemBytes ?? VERCEL_MAX_ITEM_BYTES;
|
|
314
|
+
this.name = options.name;
|
|
315
|
+
this.debug = options.debug;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// --- Segment family (get/set/delete) ---
|
|
319
|
+
|
|
320
|
+
async get(key: string): Promise<CacheGetResult | null> {
|
|
321
|
+
const storeKey = this.toStoreKey(key, "s");
|
|
322
|
+
const started = Date.now();
|
|
323
|
+
let raw: unknown;
|
|
324
|
+
try {
|
|
325
|
+
raw = await this.cache.get(storeKey);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
reportCacheError(error, "cache-read", "[VercelCacheStore] get");
|
|
328
|
+
this.emitDebug({ op: "get", key, outcome: "error" });
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
const readMs = Date.now() - started;
|
|
332
|
+
|
|
333
|
+
if (raw == null) {
|
|
334
|
+
this.emitDebug({ op: "get", key, outcome: "miss", readMs });
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const env = this.asSegmentEnvelope(raw);
|
|
338
|
+
if (!env) {
|
|
339
|
+
reportCacheError(
|
|
340
|
+
new Error("malformed segment envelope"),
|
|
341
|
+
"cache-corrupt",
|
|
342
|
+
"[VercelCacheStore] get",
|
|
343
|
+
);
|
|
344
|
+
void this.safeDelete(storeKey);
|
|
345
|
+
this.emitDebug({ op: "get", key, outcome: "corrupt", readMs });
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
if (now > env.e) {
|
|
351
|
+
void this.safeDelete(storeKey);
|
|
352
|
+
this.emitDebug({
|
|
353
|
+
op: "get",
|
|
354
|
+
key,
|
|
355
|
+
outcome: "expired",
|
|
356
|
+
staleAt: env.s,
|
|
357
|
+
expiresAt: env.e,
|
|
358
|
+
readMs,
|
|
359
|
+
});
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isStale = env.s > 0 && now > env.s;
|
|
364
|
+
if (isStale) {
|
|
365
|
+
this.markRevalidating(
|
|
366
|
+
storeKey,
|
|
367
|
+
env,
|
|
368
|
+
env.d.tags,
|
|
369
|
+
"[VercelCacheStore] get",
|
|
370
|
+
);
|
|
371
|
+
this.emitDebug({
|
|
372
|
+
op: "get",
|
|
373
|
+
key,
|
|
374
|
+
outcome: "stale-revalidate",
|
|
375
|
+
shouldRevalidate: true,
|
|
376
|
+
staleAt: env.s,
|
|
377
|
+
expiresAt: env.e,
|
|
378
|
+
readMs,
|
|
379
|
+
});
|
|
380
|
+
return { data: env.d, shouldRevalidate: true };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
this.emitDebug({
|
|
384
|
+
op: "get",
|
|
385
|
+
key,
|
|
386
|
+
outcome: "fresh",
|
|
387
|
+
shouldRevalidate: false,
|
|
388
|
+
staleAt: env.s,
|
|
389
|
+
expiresAt: env.e,
|
|
390
|
+
readMs,
|
|
391
|
+
});
|
|
392
|
+
return { data: env.d, shouldRevalidate: false };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async set(
|
|
396
|
+
key: string,
|
|
397
|
+
data: CachedEntryData,
|
|
398
|
+
ttl: number,
|
|
399
|
+
swr?: number,
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
try {
|
|
402
|
+
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
403
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
404
|
+
const env: VercelSegmentEnvelope = { d: data, s: staleAt, e: expiresAt };
|
|
405
|
+
await this.write(
|
|
406
|
+
this.toStoreKey(key, "s"),
|
|
407
|
+
env,
|
|
408
|
+
ttl + swrWindow,
|
|
409
|
+
data.tags,
|
|
410
|
+
"[VercelCacheStore] set",
|
|
411
|
+
);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
reportCacheError(error, "cache-write", "[VercelCacheStore] set");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async delete(key: string): Promise<boolean> {
|
|
418
|
+
try {
|
|
419
|
+
await this.cache.delete(this.toStoreKey(key, "s"));
|
|
420
|
+
// Vercel's delete returns void; we cannot know whether the key existed.
|
|
421
|
+
// The router uses the boolean only for self-heal eviction, so report success.
|
|
422
|
+
return true;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
reportCacheError(error, "cache-delete", "[VercelCacheStore] delete");
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Response family (getResponse/putResponse) ---
|
|
430
|
+
|
|
431
|
+
async getResponse(
|
|
432
|
+
key: string,
|
|
433
|
+
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
434
|
+
const storeKey = this.toStoreKey(key, "r");
|
|
435
|
+
let raw: unknown;
|
|
436
|
+
try {
|
|
437
|
+
raw = await this.cache.get(storeKey);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
reportCacheError(error, "cache-read", "[VercelCacheStore] getResponse");
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
if (raw == null) return null;
|
|
443
|
+
const env = this.asResponseEnvelope(raw);
|
|
444
|
+
if (!env) {
|
|
445
|
+
reportCacheError(
|
|
446
|
+
new Error("malformed response envelope"),
|
|
447
|
+
"cache-corrupt",
|
|
448
|
+
"[VercelCacheStore] getResponse",
|
|
449
|
+
);
|
|
450
|
+
void this.safeDelete(storeKey);
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
if (now > env.e) {
|
|
456
|
+
void this.safeDelete(storeKey);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const isStale = env.s > 0 && now > env.s;
|
|
461
|
+
if (isStale) {
|
|
462
|
+
this.markRevalidating(
|
|
463
|
+
storeKey,
|
|
464
|
+
env,
|
|
465
|
+
env.t,
|
|
466
|
+
"[VercelCacheStore] getResponse",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
const response = new Response(base64ToBuffer(env.b), {
|
|
470
|
+
status: env.st,
|
|
471
|
+
headers: new Headers(env.hd),
|
|
472
|
+
});
|
|
473
|
+
return { response, shouldRevalidate: isStale };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async putResponse(
|
|
477
|
+
key: string,
|
|
478
|
+
response: Response,
|
|
479
|
+
ttl: number,
|
|
480
|
+
swr?: number,
|
|
481
|
+
tags?: string[],
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
try {
|
|
484
|
+
const body = await response.clone().arrayBuffer();
|
|
485
|
+
const headers: [string, string][] = [];
|
|
486
|
+
response.headers.forEach((value, name) => {
|
|
487
|
+
if (isPerClientSignalHeader(name)) return;
|
|
488
|
+
headers.push([name, value]);
|
|
489
|
+
});
|
|
490
|
+
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
491
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
492
|
+
const env: VercelResponseEnvelope = {
|
|
493
|
+
b: bufferToBase64(body),
|
|
494
|
+
st: response.status,
|
|
495
|
+
hd: headers,
|
|
496
|
+
s: staleAt,
|
|
497
|
+
e: expiresAt,
|
|
498
|
+
t: tags,
|
|
499
|
+
};
|
|
500
|
+
await this.write(
|
|
501
|
+
this.toStoreKey(key, "r"),
|
|
502
|
+
env,
|
|
503
|
+
ttl + swrWindow,
|
|
504
|
+
tags,
|
|
505
|
+
"[VercelCacheStore] putResponse",
|
|
506
|
+
);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
reportCacheError(error, "cache-write", "[VercelCacheStore] putResponse");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// --- Item family ("use cache" - getItem/setItem) ---
|
|
513
|
+
|
|
514
|
+
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
515
|
+
const storeKey = this.toStoreKey(key, "i");
|
|
516
|
+
const started = Date.now();
|
|
517
|
+
let raw: unknown;
|
|
518
|
+
try {
|
|
519
|
+
raw = await this.cache.get(storeKey);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
reportCacheError(error, "cache-read", "[VercelCacheStore] getItem");
|
|
522
|
+
this.emitDebug({ op: "getItem", key, outcome: "error" });
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
const readMs = Date.now() - started;
|
|
526
|
+
|
|
527
|
+
if (raw == null) {
|
|
528
|
+
this.emitDebug({ op: "getItem", key, outcome: "miss", readMs });
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
const env = this.asItemEnvelope(raw);
|
|
532
|
+
if (!env) {
|
|
533
|
+
reportCacheError(
|
|
534
|
+
new Error("malformed item envelope"),
|
|
535
|
+
"cache-corrupt",
|
|
536
|
+
"[VercelCacheStore] getItem",
|
|
537
|
+
);
|
|
538
|
+
void this.safeDelete(storeKey);
|
|
539
|
+
this.emitDebug({ op: "getItem", key, outcome: "corrupt", readMs });
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
if (now > env.e) {
|
|
545
|
+
void this.safeDelete(storeKey);
|
|
546
|
+
this.emitDebug({ op: "getItem", key, outcome: "expired", readMs });
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const isStale = env.s > 0 && now > env.s;
|
|
551
|
+
if (isStale) {
|
|
552
|
+
this.markRevalidating(storeKey, env, env.t, "[VercelCacheStore] getItem");
|
|
553
|
+
}
|
|
554
|
+
this.emitDebug({
|
|
555
|
+
op: "getItem",
|
|
556
|
+
key,
|
|
557
|
+
outcome: isStale ? "stale-revalidate" : "fresh",
|
|
558
|
+
shouldRevalidate: isStale,
|
|
559
|
+
staleAt: env.s,
|
|
560
|
+
expiresAt: env.e,
|
|
561
|
+
readMs,
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
value: env.v,
|
|
565
|
+
handles: env.h,
|
|
566
|
+
shouldRevalidate: isStale,
|
|
567
|
+
tags: env.t,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async setItem(
|
|
572
|
+
key: string,
|
|
573
|
+
value: string,
|
|
574
|
+
options?: CacheItemOptions,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
try {
|
|
577
|
+
const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
578
|
+
const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
|
|
579
|
+
const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
|
|
580
|
+
const env: VercelItemEnvelope = {
|
|
581
|
+
v: value,
|
|
582
|
+
h: options?.handles,
|
|
583
|
+
s: staleAt,
|
|
584
|
+
e: expiresAt,
|
|
585
|
+
t: options?.tags,
|
|
586
|
+
};
|
|
587
|
+
await this.write(
|
|
588
|
+
this.toStoreKey(key, "i"),
|
|
589
|
+
env,
|
|
590
|
+
ttl + swrWindow,
|
|
591
|
+
options?.tags,
|
|
592
|
+
"[VercelCacheStore] setItem",
|
|
593
|
+
);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
reportCacheError(error, "cache-write", "[VercelCacheStore] setItem");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --- Tags ---
|
|
600
|
+
|
|
601
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
602
|
+
if (!tags || tags.length === 0) return;
|
|
603
|
+
// No per-item cap here: an invalidation must reach every requested tag.
|
|
604
|
+
const safe = this.validateTags(tags, "[VercelCacheStore] invalidateTags");
|
|
605
|
+
if (safe.length === 0) return;
|
|
606
|
+
try {
|
|
607
|
+
await this.cache.expireTag(safe);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
// The one deliberate throw: a failed durable invalidation must reject so an
|
|
610
|
+
// awaited updateTag() surfaces it instead of reporting false success.
|
|
611
|
+
reportCacheError(
|
|
612
|
+
error,
|
|
613
|
+
"cache-invalidate",
|
|
614
|
+
"[VercelCacheStore] invalidateTags",
|
|
615
|
+
);
|
|
616
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- Internals ---
|
|
621
|
+
|
|
622
|
+
private toStoreKey(key: string, family: CacheFamily): string {
|
|
623
|
+
const versionPrefix = this.version ? `v/${this.version}/` : "";
|
|
624
|
+
return `${versionPrefix}rg:${family}:${key}`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private async write(
|
|
628
|
+
storeKey: string,
|
|
629
|
+
value: unknown,
|
|
630
|
+
totalTtlSeconds: number,
|
|
631
|
+
tags: string[] | undefined,
|
|
632
|
+
label: string,
|
|
633
|
+
): Promise<void> {
|
|
634
|
+
if (!this.withinSizeLimit(value, label)) return;
|
|
635
|
+
const safeTags = this.clampTagsForWrite(tags, label);
|
|
636
|
+
const options: { ttl: number; tags?: string[]; name?: string } = {
|
|
637
|
+
ttl: Math.max(1, Math.ceil(totalTtlSeconds)),
|
|
638
|
+
};
|
|
639
|
+
if (safeTags.length > 0) options.tags = safeTags;
|
|
640
|
+
if (this.name) options.name = this.name;
|
|
641
|
+
await this.cache.set(storeKey, value, options);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Stale-read herd dampening: push staleAt forward by REVALIDATION_LOCK_MS
|
|
646
|
+
* (clamped to the hard expiry) and re-write the same envelope under the
|
|
647
|
+
* remaining lifetime, so concurrent same-region readers see it as fresh while
|
|
648
|
+
* one revalidates. Best-effort and non-blocking; never throws.
|
|
649
|
+
*/
|
|
650
|
+
private markRevalidating<E extends { s: number; e: number }>(
|
|
651
|
+
storeKey: string,
|
|
652
|
+
env: E,
|
|
653
|
+
tags: string[] | undefined,
|
|
654
|
+
label: string,
|
|
655
|
+
): void {
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
const remainingSeconds = Math.ceil((env.e - now) / 1000);
|
|
658
|
+
if (remainingSeconds <= 0) return;
|
|
659
|
+
const locked: E = {
|
|
660
|
+
...env,
|
|
661
|
+
s: Math.min(now + REVALIDATION_LOCK_MS, env.e),
|
|
662
|
+
};
|
|
663
|
+
const task = (): Promise<void> =>
|
|
664
|
+
this.write(storeKey, locked, remainingSeconds, tags, label);
|
|
665
|
+
if (this.waitUntil) {
|
|
666
|
+
this.waitUntil(reportingAsync(task, "cache-write", label));
|
|
667
|
+
} else {
|
|
668
|
+
void reportingAsync(task, "cache-write", label);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private withinSizeLimit(value: unknown, label: string): boolean {
|
|
673
|
+
try {
|
|
674
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value)).length;
|
|
675
|
+
if (bytes >= this.maxItemBytes) {
|
|
676
|
+
reportCacheError(
|
|
677
|
+
new Error(
|
|
678
|
+
`entry is ${bytes}B, at/above the ${this.maxItemBytes}B cap; not cached`,
|
|
679
|
+
),
|
|
680
|
+
"cache-write",
|
|
681
|
+
label,
|
|
682
|
+
);
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
// If the value cannot be measured, allow the write (best-effort).
|
|
687
|
+
}
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Drop tags Vercel rejects (commas, over-length). Used by invalidateTags. */
|
|
692
|
+
private validateTags(tags: string[], label: string): string[] {
|
|
693
|
+
const encoder = new TextEncoder();
|
|
694
|
+
const out: string[] = [];
|
|
695
|
+
for (const tag of tags) {
|
|
696
|
+
if (tag.includes(",")) {
|
|
697
|
+
reportCacheError(
|
|
698
|
+
new Error(`tag "${tag}" contains a comma; skipped`),
|
|
699
|
+
"cache-invalidate",
|
|
700
|
+
label,
|
|
701
|
+
);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (encoder.encode(tag).length > VERCEL_MAX_TAG_BYTES) {
|
|
705
|
+
reportCacheError(
|
|
706
|
+
new Error(`tag exceeds ${VERCEL_MAX_TAG_BYTES} bytes; skipped`),
|
|
707
|
+
"cache-invalidate",
|
|
708
|
+
label,
|
|
709
|
+
);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
out.push(tag);
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** validateTags + the per-item tag cap. Used on the write path. */
|
|
718
|
+
private clampTagsForWrite(
|
|
719
|
+
tags: string[] | undefined,
|
|
720
|
+
label: string,
|
|
721
|
+
): string[] {
|
|
722
|
+
if (!tags || tags.length === 0) return [];
|
|
723
|
+
const valid = this.validateTags(tags, label);
|
|
724
|
+
if (valid.length > VERCEL_MAX_TAGS_PER_ITEM) {
|
|
725
|
+
reportCacheError(
|
|
726
|
+
new Error(
|
|
727
|
+
`entry has ${valid.length} tags, over the ${VERCEL_MAX_TAGS_PER_ITEM}-tag cap; ` +
|
|
728
|
+
`keeping the first ${VERCEL_MAX_TAGS_PER_ITEM}`,
|
|
729
|
+
),
|
|
730
|
+
"cache-write",
|
|
731
|
+
label,
|
|
732
|
+
);
|
|
733
|
+
return valid.slice(0, VERCEL_MAX_TAGS_PER_ITEM);
|
|
734
|
+
}
|
|
735
|
+
return valid;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
private async safeDelete(storeKey: string): Promise<void> {
|
|
739
|
+
try {
|
|
740
|
+
await this.cache.delete(storeKey);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
reportCacheError(error, "cache-delete", "[VercelCacheStore] evict");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private emitDebug(event: VercelCacheReadDebugEvent): void {
|
|
747
|
+
const sink = this.debug;
|
|
748
|
+
if (!sink) return;
|
|
749
|
+
try {
|
|
750
|
+
if (typeof sink === "function") sink(event);
|
|
751
|
+
else console.log("[VercelCacheStore]", event);
|
|
752
|
+
} catch {
|
|
753
|
+
// The debug sink must never break the cache path.
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private asSegmentEnvelope(raw: unknown): VercelSegmentEnvelope | null {
|
|
758
|
+
if (!isRecord(raw)) return null;
|
|
759
|
+
const { d, s, e } = raw;
|
|
760
|
+
if (
|
|
761
|
+
!isRecord(d) ||
|
|
762
|
+
!Array.isArray((d as Record<string, unknown>).segments)
|
|
763
|
+
) {
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
if (typeof s !== "number" || typeof e !== "number") return null;
|
|
767
|
+
return { d: d as unknown as CachedEntryData, s, e };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private asItemEnvelope(raw: unknown): VercelItemEnvelope | null {
|
|
771
|
+
if (!isRecord(raw)) return null;
|
|
772
|
+
const { v, h, s, e, t } = raw;
|
|
773
|
+
if (typeof v !== "string") return null;
|
|
774
|
+
if (typeof s !== "number" || typeof e !== "number") return null;
|
|
775
|
+
return {
|
|
776
|
+
v,
|
|
777
|
+
h: typeof h === "string" ? h : undefined,
|
|
778
|
+
s,
|
|
779
|
+
e,
|
|
780
|
+
t: Array.isArray(t) ? (t as string[]) : undefined,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private asResponseEnvelope(raw: unknown): VercelResponseEnvelope | null {
|
|
785
|
+
if (!isRecord(raw)) return null;
|
|
786
|
+
const { b, st, hd, s, e, t } = raw;
|
|
787
|
+
if (typeof b !== "string" || typeof st !== "number") return null;
|
|
788
|
+
if (!Array.isArray(hd)) return null;
|
|
789
|
+
if (typeof s !== "number" || typeof e !== "number") return null;
|
|
790
|
+
return {
|
|
791
|
+
b,
|
|
792
|
+
st,
|
|
793
|
+
hd: hd as [string, string][],
|
|
794
|
+
s,
|
|
795
|
+
e,
|
|
796
|
+
t: Array.isArray(t) ? (t as string[]) : undefined,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
}
|