@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +8 -6
- package/skills/caching/SKILL.md +146 -0
- package/skills/migrate-nextjs/SKILL.md +35 -15
- package/skills/rango/SKILL.md +15 -15
- package/skills/use-cache/SKILL.md +9 -7
- package/src/cache/cache-error.ts +104 -0
- package/src/cache/cache-policy.ts +95 -1
- package/src/cache/cache-runtime.ts +79 -13
- package/src/cache/cache-scope.ts +55 -4
- package/src/cache/cache-tag.ts +135 -0
- package/src/cache/cf/cf-cache-store.ts +2058 -223
- package/src/cache/cf/index.ts +15 -1
- package/src/cache/document-cache.ts +63 -7
- package/src/cache/index.ts +17 -0
- package/src/cache/memory-segment-store.ts +158 -14
- package/src/cache/tag-invalidation.ts +230 -0
- package/src/cache/types.ts +27 -0
- package/src/index.rsc.ts +7 -0
- package/src/index.ts +12 -0
- package/src/router/prerender-match.ts +2 -0
- package/src/router/segment-resolution/loader-cache.ts +8 -17
- package/src/rsc/handler.ts +10 -1
- package/src/rsc/response-route-handler.ts +8 -1
- package/src/server/request-context.ts +36 -2
- package/src/types/cache-types.ts +13 -4
- package/src/types/error-types.ts +5 -1
- package/dist/__internal.d.ts +0 -83
- package/dist/__internal.d.ts.map +0 -1
- package/dist/__internal.js +0 -19
- package/dist/__internal.js.map +0 -1
- package/dist/__mocks__/version.d.ts +0 -7
- package/dist/__mocks__/version.d.ts.map +0 -1
- package/dist/__mocks__/version.js +0 -7
- package/dist/__mocks__/version.js.map +0 -1
- package/dist/__tests__/client-href.test.d.ts +0 -2
- package/dist/__tests__/client-href.test.d.ts.map +0 -1
- package/dist/__tests__/client-href.test.js +0 -74
- package/dist/__tests__/client-href.test.js.map +0 -1
- package/dist/__tests__/component-utils.test.d.ts +0 -2
- package/dist/__tests__/component-utils.test.d.ts.map +0 -1
- package/dist/__tests__/component-utils.test.js +0 -51
- package/dist/__tests__/component-utils.test.js.map +0 -1
- package/dist/__tests__/event-controller.test.d.ts +0 -2
- package/dist/__tests__/event-controller.test.d.ts.map +0 -1
- package/dist/__tests__/event-controller.test.js +0 -538
- package/dist/__tests__/event-controller.test.js.map +0 -1
- package/dist/__tests__/helpers/route-tree.d.ts +0 -118
- package/dist/__tests__/helpers/route-tree.d.ts.map +0 -1
- package/dist/__tests__/helpers/route-tree.js +0 -374
- package/dist/__tests__/helpers/route-tree.js.map +0 -1
- package/dist/__tests__/match-result.test.d.ts +0 -2
- package/dist/__tests__/match-result.test.d.ts.map +0 -1
- package/dist/__tests__/match-result.test.js +0 -154
- package/dist/__tests__/match-result.test.js.map +0 -1
- package/dist/__tests__/navigation-store.test.d.ts +0 -2
- package/dist/__tests__/navigation-store.test.d.ts.map +0 -1
- package/dist/__tests__/navigation-store.test.js +0 -440
- package/dist/__tests__/navigation-store.test.js.map +0 -1
- package/dist/__tests__/partial-update.test.d.ts +0 -2
- package/dist/__tests__/partial-update.test.d.ts.map +0 -1
- package/dist/__tests__/partial-update.test.js +0 -1009
- package/dist/__tests__/partial-update.test.js.map +0 -1
- package/dist/__tests__/reverse-types.test.d.ts +0 -8
- package/dist/__tests__/reverse-types.test.d.ts.map +0 -1
- package/dist/__tests__/reverse-types.test.js +0 -656
- package/dist/__tests__/reverse-types.test.js.map +0 -1
- package/dist/__tests__/route-definition.test.d.ts +0 -2
- package/dist/__tests__/route-definition.test.d.ts.map +0 -1
- package/dist/__tests__/route-definition.test.js +0 -55
- package/dist/__tests__/route-definition.test.js.map +0 -1
- package/dist/__tests__/router-helpers.test.d.ts +0 -2
- package/dist/__tests__/router-helpers.test.d.ts.map +0 -1
- package/dist/__tests__/router-helpers.test.js +0 -377
- package/dist/__tests__/router-helpers.test.js.map +0 -1
- package/dist/__tests__/router-integration-2.test.d.ts +0 -2
- package/dist/__tests__/router-integration-2.test.d.ts.map +0 -1
- package/dist/__tests__/router-integration-2.test.js +0 -426
- package/dist/__tests__/router-integration-2.test.js.map +0 -1
- package/dist/__tests__/router-integration.test.d.ts +0 -2
- package/dist/__tests__/router-integration.test.d.ts.map +0 -1
- package/dist/__tests__/router-integration.test.js +0 -1051
- package/dist/__tests__/router-integration.test.js.map +0 -1
- package/dist/__tests__/search-params.test.d.ts +0 -5
- package/dist/__tests__/search-params.test.d.ts.map +0 -1
- package/dist/__tests__/search-params.test.js +0 -306
- package/dist/__tests__/search-params.test.js.map +0 -1
- package/dist/__tests__/segment-system.test.d.ts +0 -2
- package/dist/__tests__/segment-system.test.d.ts.map +0 -1
- package/dist/__tests__/segment-system.test.js +0 -627
- package/dist/__tests__/segment-system.test.js.map +0 -1
- package/dist/__tests__/static-handler-types.test.d.ts +0 -8
- package/dist/__tests__/static-handler-types.test.d.ts.map +0 -1
- package/dist/__tests__/static-handler-types.test.js +0 -63
- package/dist/__tests__/static-handler-types.test.js.map +0 -1
- package/dist/__tests__/urls.test.d.ts +0 -2
- package/dist/__tests__/urls.test.d.ts.map +0 -1
- package/dist/__tests__/urls.test.js +0 -421
- package/dist/__tests__/urls.test.js.map +0 -1
- package/dist/__tests__/use-mount.test.d.ts +0 -2
- package/dist/__tests__/use-mount.test.d.ts.map +0 -1
- package/dist/__tests__/use-mount.test.js +0 -35
- package/dist/__tests__/use-mount.test.js.map +0 -1
- package/dist/bin/rango.d.ts +0 -2
- package/dist/bin/rango.d.ts.map +0 -1
- package/dist/bin/rango.js.map +0 -1
- package/dist/browser/event-controller.d.ts +0 -191
- package/dist/browser/event-controller.d.ts.map +0 -1
- package/dist/browser/event-controller.js +0 -559
- package/dist/browser/event-controller.js.map +0 -1
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.d.ts.map +0 -1
- package/dist/browser/index.js +0 -14
- package/dist/browser/index.js.map +0 -1
- package/dist/browser/link-interceptor.d.ts +0 -38
- package/dist/browser/link-interceptor.d.ts.map +0 -1
- package/dist/browser/link-interceptor.js +0 -99
- package/dist/browser/link-interceptor.js.map +0 -1
- package/dist/browser/logging.d.ts +0 -10
- package/dist/browser/logging.d.ts.map +0 -1
- package/dist/browser/logging.js +0 -29
- package/dist/browser/logging.js.map +0 -1
- package/dist/browser/lru-cache.d.ts +0 -17
- package/dist/browser/lru-cache.d.ts.map +0 -1
- package/dist/browser/lru-cache.js +0 -50
- package/dist/browser/lru-cache.js.map +0 -1
- package/dist/browser/merge-segment-loaders.d.ts +0 -39
- package/dist/browser/merge-segment-loaders.d.ts.map +0 -1
- package/dist/browser/merge-segment-loaders.js +0 -102
- package/dist/browser/merge-segment-loaders.js.map +0 -1
- package/dist/browser/navigation-bridge.d.ts +0 -102
- package/dist/browser/navigation-bridge.d.ts.map +0 -1
- package/dist/browser/navigation-bridge.js +0 -708
- package/dist/browser/navigation-bridge.js.map +0 -1
- package/dist/browser/navigation-client.d.ts +0 -25
- package/dist/browser/navigation-client.d.ts.map +0 -1
- package/dist/browser/navigation-client.js +0 -157
- package/dist/browser/navigation-client.js.map +0 -1
- package/dist/browser/navigation-store.d.ts +0 -101
- package/dist/browser/navigation-store.d.ts.map +0 -1
- package/dist/browser/navigation-store.js +0 -625
- package/dist/browser/navigation-store.js.map +0 -1
- package/dist/browser/partial-update.d.ts +0 -75
- package/dist/browser/partial-update.d.ts.map +0 -1
- package/dist/browser/partial-update.js +0 -426
- package/dist/browser/partial-update.js.map +0 -1
- package/dist/browser/react/Link.d.ts +0 -86
- package/dist/browser/react/Link.d.ts.map +0 -1
- package/dist/browser/react/Link.js +0 -128
- package/dist/browser/react/Link.js.map +0 -1
- package/dist/browser/react/NavigationProvider.d.ts +0 -63
- package/dist/browser/react/NavigationProvider.d.ts.map +0 -1
- package/dist/browser/react/NavigationProvider.js +0 -216
- package/dist/browser/react/NavigationProvider.js.map +0 -1
- package/dist/browser/react/ScrollRestoration.d.ts +0 -75
- package/dist/browser/react/ScrollRestoration.d.ts.map +0 -1
- package/dist/browser/react/ScrollRestoration.js +0 -57
- package/dist/browser/react/ScrollRestoration.js.map +0 -1
- package/dist/browser/react/context.d.ts +0 -46
- package/dist/browser/react/context.d.ts.map +0 -1
- package/dist/browser/react/context.js +0 -10
- package/dist/browser/react/context.js.map +0 -1
- package/dist/browser/react/index.d.ts +0 -11
- package/dist/browser/react/index.d.ts.map +0 -1
- package/dist/browser/react/index.js +0 -22
- package/dist/browser/react/index.js.map +0 -1
- package/dist/browser/react/location-state-shared.d.ts +0 -63
- package/dist/browser/react/location-state-shared.d.ts.map +0 -1
- package/dist/browser/react/location-state-shared.js +0 -81
- package/dist/browser/react/location-state-shared.js.map +0 -1
- package/dist/browser/react/location-state.d.ts +0 -23
- package/dist/browser/react/location-state.d.ts.map +0 -1
- package/dist/browser/react/location-state.js +0 -29
- package/dist/browser/react/location-state.js.map +0 -1
- package/dist/browser/react/mount-context.d.ts +0 -24
- package/dist/browser/react/mount-context.d.ts.map +0 -1
- package/dist/browser/react/mount-context.js +0 -24
- package/dist/browser/react/mount-context.js.map +0 -1
- package/dist/browser/react/use-action.d.ts +0 -64
- package/dist/browser/react/use-action.d.ts.map +0 -1
- package/dist/browser/react/use-action.js +0 -134
- package/dist/browser/react/use-action.js.map +0 -1
- package/dist/browser/react/use-client-cache.d.ts +0 -41
- package/dist/browser/react/use-client-cache.d.ts.map +0 -1
- package/dist/browser/react/use-client-cache.js +0 -39
- package/dist/browser/react/use-client-cache.js.map +0 -1
- package/dist/browser/react/use-handle.d.ts +0 -31
- package/dist/browser/react/use-handle.d.ts.map +0 -1
- package/dist/browser/react/use-handle.js +0 -144
- package/dist/browser/react/use-handle.js.map +0 -1
- package/dist/browser/react/use-href.d.ts +0 -33
- package/dist/browser/react/use-href.d.ts.map +0 -1
- package/dist/browser/react/use-href.js +0 -39
- package/dist/browser/react/use-href.js.map +0 -1
- package/dist/browser/react/use-link-status.d.ts +0 -37
- package/dist/browser/react/use-link-status.d.ts.map +0 -1
- package/dist/browser/react/use-link-status.js +0 -99
- package/dist/browser/react/use-link-status.js.map +0 -1
- package/dist/browser/react/use-mount.d.ts +0 -25
- package/dist/browser/react/use-mount.d.ts.map +0 -1
- package/dist/browser/react/use-mount.js +0 -30
- package/dist/browser/react/use-mount.js.map +0 -1
- package/dist/browser/react/use-navigation.d.ts +0 -27
- package/dist/browser/react/use-navigation.d.ts.map +0 -1
- package/dist/browser/react/use-navigation.js +0 -87
- package/dist/browser/react/use-navigation.js.map +0 -1
- package/dist/browser/react/use-segments.d.ts +0 -38
- package/dist/browser/react/use-segments.d.ts.map +0 -1
- package/dist/browser/react/use-segments.js +0 -130
- package/dist/browser/react/use-segments.js.map +0 -1
- package/dist/browser/request-controller.d.ts +0 -26
- package/dist/browser/request-controller.d.ts.map +0 -1
- package/dist/browser/request-controller.js +0 -147
- package/dist/browser/request-controller.js.map +0 -1
- package/dist/browser/rsc-router.d.ts +0 -129
- package/dist/browser/rsc-router.d.ts.map +0 -1
- package/dist/browser/rsc-router.js +0 -195
- package/dist/browser/rsc-router.js.map +0 -1
- package/dist/browser/scroll-restoration.d.ts +0 -93
- package/dist/browser/scroll-restoration.d.ts.map +0 -1
- package/dist/browser/scroll-restoration.js +0 -321
- package/dist/browser/scroll-restoration.js.map +0 -1
- package/dist/browser/segment-structure-assert.d.ts +0 -17
- package/dist/browser/segment-structure-assert.d.ts.map +0 -1
- package/dist/browser/segment-structure-assert.js +0 -59
- package/dist/browser/segment-structure-assert.js.map +0 -1
- package/dist/browser/server-action-bridge.d.ts +0 -26
- package/dist/browser/server-action-bridge.d.ts.map +0 -1
- package/dist/browser/server-action-bridge.js +0 -668
- package/dist/browser/server-action-bridge.js.map +0 -1
- package/dist/browser/shallow.d.ts +0 -12
- package/dist/browser/shallow.d.ts.map +0 -1
- package/dist/browser/shallow.js +0 -34
- package/dist/browser/shallow.js.map +0 -1
- package/dist/browser/types.d.ts +0 -369
- package/dist/browser/types.d.ts.map +0 -1
- package/dist/browser/types.js +0 -2
- package/dist/browser/types.js.map +0 -1
- package/dist/build/__tests__/generate-cli.test.d.ts +0 -2
- package/dist/build/__tests__/generate-cli.test.d.ts.map +0 -1
- package/dist/build/__tests__/generate-cli.test.js +0 -237
- package/dist/build/__tests__/generate-cli.test.js.map +0 -1
- package/dist/build/__tests__/generate-manifest.test.d.ts +0 -2
- package/dist/build/__tests__/generate-manifest.test.d.ts.map +0 -1
- package/dist/build/__tests__/generate-manifest.test.js +0 -119
- package/dist/build/__tests__/generate-manifest.test.js.map +0 -1
- package/dist/build/__tests__/generate-route-types.test.d.ts +0 -2
- package/dist/build/__tests__/generate-route-types.test.d.ts.map +0 -1
- package/dist/build/__tests__/generate-route-types.test.js +0 -620
- package/dist/build/__tests__/generate-route-types.test.js.map +0 -1
- package/dist/build/__tests__/per-router-manifest.test.d.ts +0 -2
- package/dist/build/__tests__/per-router-manifest.test.d.ts.map +0 -1
- package/dist/build/__tests__/per-router-manifest.test.js +0 -308
- package/dist/build/__tests__/per-router-manifest.test.js.map +0 -1
- package/dist/build/generate-manifest.d.ts +0 -81
- package/dist/build/generate-manifest.d.ts.map +0 -1
- package/dist/build/generate-manifest.js +0 -276
- package/dist/build/generate-manifest.js.map +0 -1
- package/dist/build/generate-route-types.d.ts +0 -115
- package/dist/build/generate-route-types.d.ts.map +0 -1
- package/dist/build/generate-route-types.js +0 -740
- package/dist/build/generate-route-types.js.map +0 -1
- package/dist/build/index.d.ts +0 -21
- package/dist/build/index.d.ts.map +0 -1
- package/dist/build/index.js +0 -21
- package/dist/build/index.js.map +0 -1
- package/dist/build/route-trie.d.ts +0 -71
- package/dist/build/route-trie.d.ts.map +0 -1
- package/dist/build/route-trie.js +0 -175
- package/dist/build/route-trie.js.map +0 -1
- package/dist/cache/__tests__/cache-scope.test.d.ts +0 -2
- package/dist/cache/__tests__/cache-scope.test.d.ts.map +0 -1
- package/dist/cache/__tests__/cache-scope.test.js +0 -208
- package/dist/cache/__tests__/cache-scope.test.js.map +0 -1
- package/dist/cache/__tests__/document-cache.test.d.ts +0 -2
- package/dist/cache/__tests__/document-cache.test.d.ts.map +0 -1
- package/dist/cache/__tests__/document-cache.test.js +0 -345
- package/dist/cache/__tests__/document-cache.test.js.map +0 -1
- package/dist/cache/__tests__/memory-segment-store.test.d.ts +0 -2
- package/dist/cache/__tests__/memory-segment-store.test.d.ts.map +0 -1
- package/dist/cache/__tests__/memory-segment-store.test.js +0 -425
- package/dist/cache/__tests__/memory-segment-store.test.js.map +0 -1
- package/dist/cache/__tests__/memory-store.test.d.ts +0 -2
- package/dist/cache/__tests__/memory-store.test.d.ts.map +0 -1
- package/dist/cache/__tests__/memory-store.test.js +0 -367
- package/dist/cache/__tests__/memory-store.test.js.map +0 -1
- package/dist/cache/cache-scope.d.ts +0 -102
- package/dist/cache/cache-scope.d.ts.map +0 -1
- package/dist/cache/cache-scope.js +0 -440
- package/dist/cache/cache-scope.js.map +0 -1
- package/dist/cache/cf/__tests__/cf-cache-store.test.d.ts +0 -2
- package/dist/cache/cf/__tests__/cf-cache-store.test.d.ts.map +0 -1
- package/dist/cache/cf/__tests__/cf-cache-store.test.js +0 -330
- package/dist/cache/cf/__tests__/cf-cache-store.test.js.map +0 -1
- package/dist/cache/cf/cf-cache-store.d.ts +0 -165
- package/dist/cache/cf/cf-cache-store.d.ts.map +0 -1
- package/dist/cache/cf/cf-cache-store.js +0 -242
- package/dist/cache/cf/cf-cache-store.js.map +0 -1
- package/dist/cache/cf/index.d.ts +0 -14
- package/dist/cache/cf/index.d.ts.map +0 -1
- package/dist/cache/cf/index.js +0 -17
- package/dist/cache/cf/index.js.map +0 -1
- package/dist/cache/document-cache.d.ts +0 -64
- package/dist/cache/document-cache.d.ts.map +0 -1
- package/dist/cache/document-cache.js +0 -228
- package/dist/cache/document-cache.js.map +0 -1
- package/dist/cache/index.d.ts +0 -19
- package/dist/cache/index.d.ts.map +0 -1
- package/dist/cache/index.js +0 -21
- package/dist/cache/index.js.map +0 -1
- package/dist/cache/memory-segment-store.d.ts +0 -110
- package/dist/cache/memory-segment-store.d.ts.map +0 -1
- package/dist/cache/memory-segment-store.js +0 -117
- package/dist/cache/memory-segment-store.js.map +0 -1
- package/dist/cache/memory-store.d.ts +0 -41
- package/dist/cache/memory-store.d.ts.map +0 -1
- package/dist/cache/memory-store.js +0 -191
- package/dist/cache/memory-store.js.map +0 -1
- package/dist/cache/types.d.ts +0 -317
- package/dist/cache/types.d.ts.map +0 -1
- package/dist/cache/types.js +0 -12
- package/dist/cache/types.js.map +0 -1
- package/dist/client.d.ts +0 -248
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -367
- package/dist/client.js.map +0 -1
- package/dist/client.rsc.d.ts +0 -26
- package/dist/client.rsc.d.ts.map +0 -1
- package/dist/client.rsc.js +0 -46
- package/dist/client.rsc.js.map +0 -1
- package/dist/component-utils.d.ts +0 -36
- package/dist/component-utils.d.ts.map +0 -1
- package/dist/component-utils.js +0 -61
- package/dist/component-utils.js.map +0 -1
- package/dist/components/DefaultDocument.d.ts +0 -13
- package/dist/components/DefaultDocument.d.ts.map +0 -1
- package/dist/components/DefaultDocument.js +0 -15
- package/dist/components/DefaultDocument.js.map +0 -1
- package/dist/debug.d.ts +0 -58
- package/dist/debug.d.ts.map +0 -1
- package/dist/debug.js +0 -157
- package/dist/debug.js.map +0 -1
- package/dist/default-error-boundary.d.ts +0 -11
- package/dist/default-error-boundary.d.ts.map +0 -1
- package/dist/default-error-boundary.js +0 -45
- package/dist/default-error-boundary.js.map +0 -1
- package/dist/deps/browser.d.ts +0 -2
- package/dist/deps/browser.d.ts.map +0 -1
- package/dist/deps/browser.js +0 -3
- package/dist/deps/browser.js.map +0 -1
- package/dist/deps/html-stream-client.d.ts +0 -2
- package/dist/deps/html-stream-client.d.ts.map +0 -1
- package/dist/deps/html-stream-client.js +0 -3
- package/dist/deps/html-stream-client.js.map +0 -1
- package/dist/deps/html-stream-server.d.ts +0 -2
- package/dist/deps/html-stream-server.d.ts.map +0 -1
- package/dist/deps/html-stream-server.js +0 -3
- package/dist/deps/html-stream-server.js.map +0 -1
- package/dist/deps/rsc.d.ts +0 -2
- package/dist/deps/rsc.d.ts.map +0 -1
- package/dist/deps/rsc.js +0 -4
- package/dist/deps/rsc.js.map +0 -1
- package/dist/deps/ssr.d.ts +0 -2
- package/dist/deps/ssr.d.ts.map +0 -1
- package/dist/deps/ssr.js +0 -3
- package/dist/deps/ssr.js.map +0 -1
- package/dist/errors.d.ts +0 -174
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -241
- package/dist/errors.js.map +0 -1
- package/dist/handle.d.ts +0 -78
- package/dist/handle.d.ts.map +0 -1
- package/dist/handle.js +0 -82
- package/dist/handle.js.map +0 -1
- package/dist/handles/MetaTags.d.ts +0 -14
- package/dist/handles/MetaTags.d.ts.map +0 -1
- package/dist/handles/MetaTags.js +0 -136
- package/dist/handles/MetaTags.js.map +0 -1
- package/dist/handles/index.d.ts +0 -6
- package/dist/handles/index.d.ts.map +0 -1
- package/dist/handles/index.js +0 -6
- package/dist/handles/index.js.map +0 -1
- package/dist/handles/meta.d.ts +0 -39
- package/dist/handles/meta.d.ts.map +0 -1
- package/dist/handles/meta.js +0 -202
- package/dist/handles/meta.js.map +0 -1
- package/dist/host/__tests__/errors.test.d.ts +0 -2
- package/dist/host/__tests__/errors.test.d.ts.map +0 -1
- package/dist/host/__tests__/errors.test.js +0 -76
- package/dist/host/__tests__/errors.test.js.map +0 -1
- package/dist/host/__tests__/pattern-comprehensive.test.d.ts +0 -2
- package/dist/host/__tests__/pattern-comprehensive.test.d.ts.map +0 -1
- package/dist/host/__tests__/pattern-comprehensive.test.js +0 -732
- package/dist/host/__tests__/pattern-comprehensive.test.js.map +0 -1
- package/dist/host/__tests__/pattern-matcher.test.d.ts +0 -2
- package/dist/host/__tests__/pattern-matcher.test.d.ts.map +0 -1
- package/dist/host/__tests__/pattern-matcher.test.js +0 -251
- package/dist/host/__tests__/pattern-matcher.test.js.map +0 -1
- package/dist/host/__tests__/router.test.d.ts +0 -2
- package/dist/host/__tests__/router.test.d.ts.map +0 -1
- package/dist/host/__tests__/router.test.js +0 -241
- package/dist/host/__tests__/router.test.js.map +0 -1
- package/dist/host/__tests__/testing.test.d.ts +0 -2
- package/dist/host/__tests__/testing.test.d.ts.map +0 -1
- package/dist/host/__tests__/testing.test.js +0 -64
- package/dist/host/__tests__/testing.test.js.map +0 -1
- package/dist/host/__tests__/utils.test.d.ts +0 -2
- package/dist/host/__tests__/utils.test.d.ts.map +0 -1
- package/dist/host/__tests__/utils.test.js +0 -29
- package/dist/host/__tests__/utils.test.js.map +0 -1
- package/dist/host/cookie-handler.d.ts +0 -34
- package/dist/host/cookie-handler.d.ts.map +0 -1
- package/dist/host/cookie-handler.js +0 -124
- package/dist/host/cookie-handler.js.map +0 -1
- package/dist/host/errors.d.ts +0 -56
- package/dist/host/errors.d.ts.map +0 -1
- package/dist/host/errors.js +0 -79
- package/dist/host/errors.js.map +0 -1
- package/dist/host/index.d.ts +0 -29
- package/dist/host/index.d.ts.map +0 -1
- package/dist/host/index.js +0 -32
- package/dist/host/index.js.map +0 -1
- package/dist/host/pattern-matcher.d.ts +0 -36
- package/dist/host/pattern-matcher.d.ts.map +0 -1
- package/dist/host/pattern-matcher.js +0 -172
- package/dist/host/pattern-matcher.js.map +0 -1
- package/dist/host/router.d.ts +0 -26
- package/dist/host/router.d.ts.map +0 -1
- package/dist/host/router.js +0 -218
- package/dist/host/router.js.map +0 -1
- package/dist/host/testing.d.ts +0 -36
- package/dist/host/testing.d.ts.map +0 -1
- package/dist/host/testing.js +0 -55
- package/dist/host/testing.js.map +0 -1
- package/dist/host/types.d.ts +0 -115
- package/dist/host/types.d.ts.map +0 -1
- package/dist/host/types.js +0 -7
- package/dist/host/types.js.map +0 -1
- package/dist/host/utils.d.ts +0 -21
- package/dist/host/utils.d.ts.map +0 -1
- package/dist/host/utils.js +0 -23
- package/dist/host/utils.js.map +0 -1
- package/dist/href-client.d.ts +0 -131
- package/dist/href-client.d.ts.map +0 -1
- package/dist/href-client.js +0 -64
- package/dist/href-client.js.map +0 -1
- package/dist/href-context.d.ts +0 -29
- package/dist/href-context.d.ts.map +0 -1
- package/dist/href-context.js +0 -21
- package/dist/href-context.js.map +0 -1
- package/dist/index.d.ts +0 -73
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -91
- package/dist/index.js.map +0 -1
- package/dist/index.rsc.d.ts +0 -32
- package/dist/index.rsc.d.ts.map +0 -1
- package/dist/index.rsc.js +0 -40
- package/dist/index.rsc.js.map +0 -1
- package/dist/internal-debug.d.ts +0 -2
- package/dist/internal-debug.d.ts.map +0 -1
- package/dist/internal-debug.js +0 -5
- package/dist/internal-debug.js.map +0 -1
- package/dist/loader.d.ts +0 -14
- package/dist/loader.d.ts.map +0 -1
- package/dist/loader.js +0 -20
- package/dist/loader.js.map +0 -1
- package/dist/loader.rsc.d.ts +0 -19
- package/dist/loader.rsc.d.ts.map +0 -1
- package/dist/loader.rsc.js +0 -99
- package/dist/loader.rsc.js.map +0 -1
- package/dist/network-error-thrower.d.ts +0 -17
- package/dist/network-error-thrower.d.ts.map +0 -1
- package/dist/network-error-thrower.js +0 -14
- package/dist/network-error-thrower.js.map +0 -1
- package/dist/outlet-context.d.ts +0 -13
- package/dist/outlet-context.d.ts.map +0 -1
- package/dist/outlet-context.js +0 -3
- package/dist/outlet-context.js.map +0 -1
- package/dist/prerender/__tests__/param-hash.test.d.ts +0 -2
- package/dist/prerender/__tests__/param-hash.test.d.ts.map +0 -1
- package/dist/prerender/__tests__/param-hash.test.js +0 -148
- package/dist/prerender/__tests__/param-hash.test.js.map +0 -1
- package/dist/prerender/param-hash.d.ts +0 -16
- package/dist/prerender/param-hash.d.ts.map +0 -1
- package/dist/prerender/param-hash.js +0 -36
- package/dist/prerender/param-hash.js.map +0 -1
- package/dist/prerender/store.d.ts +0 -38
- package/dist/prerender/store.d.ts.map +0 -1
- package/dist/prerender/store.js +0 -61
- package/dist/prerender/store.js.map +0 -1
- package/dist/prerender.d.ts +0 -66
- package/dist/prerender.d.ts.map +0 -1
- package/dist/prerender.js +0 -57
- package/dist/prerender.js.map +0 -1
- package/dist/reverse.d.ts +0 -196
- package/dist/reverse.d.ts.map +0 -1
- package/dist/reverse.js +0 -78
- package/dist/reverse.js.map +0 -1
- package/dist/root-error-boundary.d.ts +0 -33
- package/dist/root-error-boundary.d.ts.map +0 -1
- package/dist/root-error-boundary.js +0 -165
- package/dist/root-error-boundary.js.map +0 -1
- package/dist/route-content-wrapper.d.ts +0 -46
- package/dist/route-content-wrapper.d.ts.map +0 -1
- package/dist/route-content-wrapper.js +0 -77
- package/dist/route-content-wrapper.js.map +0 -1
- package/dist/route-definition.d.ts +0 -421
- package/dist/route-definition.d.ts.map +0 -1
- package/dist/route-definition.js +0 -868
- package/dist/route-definition.js.map +0 -1
- package/dist/route-map-builder.d.ts +0 -155
- package/dist/route-map-builder.d.ts.map +0 -1
- package/dist/route-map-builder.js +0 -237
- package/dist/route-map-builder.js.map +0 -1
- package/dist/route-types.d.ts +0 -165
- package/dist/route-types.d.ts.map +0 -1
- package/dist/route-types.js +0 -7
- package/dist/route-types.js.map +0 -1
- package/dist/router/__tests__/handler-context.test.d.ts +0 -2
- package/dist/router/__tests__/handler-context.test.d.ts.map +0 -1
- package/dist/router/__tests__/handler-context.test.js +0 -65
- package/dist/router/__tests__/handler-context.test.js.map +0 -1
- package/dist/router/__tests__/loader-cycle-detection.test.d.ts +0 -2
- package/dist/router/__tests__/loader-cycle-detection.test.d.ts.map +0 -1
- package/dist/router/__tests__/loader-cycle-detection.test.js +0 -221
- package/dist/router/__tests__/loader-cycle-detection.test.js.map +0 -1
- package/dist/router/__tests__/match-context.test.d.ts +0 -2
- package/dist/router/__tests__/match-context.test.d.ts.map +0 -1
- package/dist/router/__tests__/match-context.test.js +0 -92
- package/dist/router/__tests__/match-context.test.js.map +0 -1
- package/dist/router/__tests__/match-pipelines.test.d.ts +0 -2
- package/dist/router/__tests__/match-pipelines.test.d.ts.map +0 -1
- package/dist/router/__tests__/match-pipelines.test.js +0 -417
- package/dist/router/__tests__/match-pipelines.test.js.map +0 -1
- package/dist/router/__tests__/match-result.test.d.ts +0 -2
- package/dist/router/__tests__/match-result.test.d.ts.map +0 -1
- package/dist/router/__tests__/match-result.test.js +0 -457
- package/dist/router/__tests__/match-result.test.js.map +0 -1
- package/dist/router/__tests__/on-error.test.d.ts +0 -2
- package/dist/router/__tests__/on-error.test.d.ts.map +0 -1
- package/dist/router/__tests__/on-error.test.js +0 -678
- package/dist/router/__tests__/on-error.test.js.map +0 -1
- package/dist/router/__tests__/pattern-matching.test.d.ts +0 -2
- package/dist/router/__tests__/pattern-matching.test.d.ts.map +0 -1
- package/dist/router/__tests__/pattern-matching.test.js +0 -629
- package/dist/router/__tests__/pattern-matching.test.js.map +0 -1
- package/dist/router/__tests__/segment-resolution-parallel-loading.test.d.ts +0 -2
- package/dist/router/__tests__/segment-resolution-parallel-loading.test.d.ts.map +0 -1
- package/dist/router/__tests__/segment-resolution-parallel-loading.test.js +0 -155
- package/dist/router/__tests__/segment-resolution-parallel-loading.test.js.map +0 -1
- package/dist/router/error-handling.d.ts +0 -77
- package/dist/router/error-handling.d.ts.map +0 -1
- package/dist/router/error-handling.js +0 -202
- package/dist/router/error-handling.js.map +0 -1
- package/dist/router/handler-context.d.ts +0 -20
- package/dist/router/handler-context.d.ts.map +0 -1
- package/dist/router/handler-context.js +0 -198
- package/dist/router/handler-context.js.map +0 -1
- package/dist/router/intercept-resolution.d.ts +0 -66
- package/dist/router/intercept-resolution.d.ts.map +0 -1
- package/dist/router/intercept-resolution.js +0 -246
- package/dist/router/intercept-resolution.js.map +0 -1
- package/dist/router/loader-resolution.d.ts +0 -64
- package/dist/router/loader-resolution.d.ts.map +0 -1
- package/dist/router/loader-resolution.js +0 -284
- package/dist/router/loader-resolution.js.map +0 -1
- package/dist/router/logging.d.ts +0 -15
- package/dist/router/logging.d.ts.map +0 -1
- package/dist/router/logging.js +0 -99
- package/dist/router/logging.js.map +0 -1
- package/dist/router/manifest.d.ts +0 -22
- package/dist/router/manifest.d.ts.map +0 -1
- package/dist/router/manifest.js +0 -181
- package/dist/router/manifest.js.map +0 -1
- package/dist/router/match-api.d.ts +0 -35
- package/dist/router/match-api.d.ts.map +0 -1
- package/dist/router/match-api.js +0 -406
- package/dist/router/match-api.js.map +0 -1
- package/dist/router/match-context.d.ts +0 -206
- package/dist/router/match-context.d.ts.map +0 -1
- package/dist/router/match-context.js +0 -17
- package/dist/router/match-context.js.map +0 -1
- package/dist/router/match-middleware/background-revalidation.d.ts +0 -127
- package/dist/router/match-middleware/background-revalidation.d.ts.map +0 -1
- package/dist/router/match-middleware/background-revalidation.js +0 -75
- package/dist/router/match-middleware/background-revalidation.js.map +0 -1
- package/dist/router/match-middleware/cache-lookup.d.ts +0 -112
- package/dist/router/match-middleware/cache-lookup.d.ts.map +0 -1
- package/dist/router/match-middleware/cache-lookup.js +0 -257
- package/dist/router/match-middleware/cache-lookup.js.map +0 -1
- package/dist/router/match-middleware/cache-store.d.ts +0 -113
- package/dist/router/match-middleware/cache-store.d.ts.map +0 -1
- package/dist/router/match-middleware/cache-store.js +0 -108
- package/dist/router/match-middleware/cache-store.js.map +0 -1
- package/dist/router/match-middleware/index.d.ts +0 -81
- package/dist/router/match-middleware/index.d.ts.map +0 -1
- package/dist/router/match-middleware/index.js +0 -80
- package/dist/router/match-middleware/index.js.map +0 -1
- package/dist/router/match-middleware/intercept-resolution.d.ts +0 -117
- package/dist/router/match-middleware/intercept-resolution.d.ts.map +0 -1
- package/dist/router/match-middleware/intercept-resolution.js +0 -134
- package/dist/router/match-middleware/intercept-resolution.js.map +0 -1
- package/dist/router/match-middleware/segment-resolution.d.ts +0 -99
- package/dist/router/match-middleware/segment-resolution.d.ts.map +0 -1
- package/dist/router/match-middleware/segment-resolution.js +0 -53
- package/dist/router/match-middleware/segment-resolution.js.map +0 -1
- package/dist/router/match-pipelines.d.ts +0 -147
- package/dist/router/match-pipelines.d.ts.map +0 -1
- package/dist/router/match-pipelines.js +0 -82
- package/dist/router/match-pipelines.js.map +0 -1
- package/dist/router/match-result.d.ts +0 -126
- package/dist/router/match-result.d.ts.map +0 -1
- package/dist/router/match-result.js +0 -93
- package/dist/router/match-result.js.map +0 -1
- package/dist/router/metrics.d.ts +0 -20
- package/dist/router/metrics.d.ts.map +0 -1
- package/dist/router/metrics.js +0 -47
- package/dist/router/metrics.js.map +0 -1
- package/dist/router/middleware.d.ts +0 -249
- package/dist/router/middleware.d.ts.map +0 -1
- package/dist/router/middleware.js +0 -434
- package/dist/router/middleware.js.map +0 -1
- package/dist/router/middleware.test.d.ts +0 -2
- package/dist/router/middleware.test.d.ts.map +0 -1
- package/dist/router/middleware.test.js +0 -816
- package/dist/router/middleware.test.js.map +0 -1
- package/dist/router/pattern-matching.d.ts +0 -149
- package/dist/router/pattern-matching.d.ts.map +0 -1
- package/dist/router/pattern-matching.js +0 -349
- package/dist/router/pattern-matching.js.map +0 -1
- package/dist/router/revalidation.d.ts +0 -44
- package/dist/router/revalidation.d.ts.map +0 -1
- package/dist/router/revalidation.js +0 -147
- package/dist/router/revalidation.js.map +0 -1
- package/dist/router/router-context.d.ts +0 -135
- package/dist/router/router-context.d.ts.map +0 -1
- package/dist/router/router-context.js +0 -36
- package/dist/router/router-context.js.map +0 -1
- package/dist/router/segment-resolution.d.ts +0 -127
- package/dist/router/segment-resolution.d.ts.map +0 -1
- package/dist/router/segment-resolution.js +0 -919
- package/dist/router/segment-resolution.js.map +0 -1
- package/dist/router/trie-matching.d.ts +0 -40
- package/dist/router/trie-matching.d.ts.map +0 -1
- package/dist/router/trie-matching.js +0 -127
- package/dist/router/trie-matching.js.map +0 -1
- package/dist/router/types.d.ts +0 -136
- package/dist/router/types.d.ts.map +0 -1
- package/dist/router/types.js +0 -7
- package/dist/router/types.js.map +0 -1
- package/dist/router.d.ts +0 -753
- package/dist/router.d.ts.map +0 -1
- package/dist/router.gen.d.ts +0 -6
- package/dist/router.gen.d.ts.map +0 -1
- package/dist/router.gen.js +0 -6
- package/dist/router.gen.js.map +0 -1
- package/dist/router.js +0 -1304
- package/dist/router.js.map +0 -1
- package/dist/rsc/__tests__/helpers.test.d.ts +0 -2
- package/dist/rsc/__tests__/helpers.test.d.ts.map +0 -1
- package/dist/rsc/__tests__/helpers.test.js +0 -140
- package/dist/rsc/__tests__/helpers.test.js.map +0 -1
- package/dist/rsc/handler.d.ts +0 -45
- package/dist/rsc/handler.d.ts.map +0 -1
- package/dist/rsc/handler.js +0 -1172
- package/dist/rsc/handler.js.map +0 -1
- package/dist/rsc/helpers.d.ts +0 -16
- package/dist/rsc/helpers.d.ts.map +0 -1
- package/dist/rsc/helpers.js +0 -55
- package/dist/rsc/helpers.js.map +0 -1
- package/dist/rsc/index.d.ts +0 -22
- package/dist/rsc/index.d.ts.map +0 -1
- package/dist/rsc/index.js +0 -23
- package/dist/rsc/index.js.map +0 -1
- package/dist/rsc/nonce.d.ts +0 -9
- package/dist/rsc/nonce.d.ts.map +0 -1
- package/dist/rsc/nonce.js +0 -18
- package/dist/rsc/nonce.js.map +0 -1
- package/dist/rsc/types.d.ts +0 -206
- package/dist/rsc/types.d.ts.map +0 -1
- package/dist/rsc/types.js +0 -8
- package/dist/rsc/types.js.map +0 -1
- package/dist/search-params.d.ts +0 -103
- package/dist/search-params.d.ts.map +0 -1
- package/dist/search-params.js +0 -74
- package/dist/search-params.js.map +0 -1
- package/dist/segment-system.d.ts +0 -75
- package/dist/segment-system.d.ts.map +0 -1
- package/dist/segment-system.js +0 -336
- package/dist/segment-system.js.map +0 -1
- package/dist/server/context.d.ts +0 -245
- package/dist/server/context.d.ts.map +0 -1
- package/dist/server/context.js +0 -197
- package/dist/server/context.js.map +0 -1
- package/dist/server/fetchable-loader-store.d.ts +0 -18
- package/dist/server/fetchable-loader-store.d.ts.map +0 -1
- package/dist/server/fetchable-loader-store.js +0 -18
- package/dist/server/fetchable-loader-store.js.map +0 -1
- package/dist/server/handle-store.d.ts +0 -85
- package/dist/server/handle-store.d.ts.map +0 -1
- package/dist/server/handle-store.js +0 -142
- package/dist/server/handle-store.js.map +0 -1
- package/dist/server/loader-registry.d.ts +0 -55
- package/dist/server/loader-registry.d.ts.map +0 -1
- package/dist/server/loader-registry.js +0 -132
- package/dist/server/loader-registry.js.map +0 -1
- package/dist/server/request-context.d.ts +0 -226
- package/dist/server/request-context.d.ts.map +0 -1
- package/dist/server/request-context.js +0 -290
- package/dist/server/request-context.js.map +0 -1
- package/dist/server/root-layout.d.ts +0 -4
- package/dist/server/root-layout.d.ts.map +0 -1
- package/dist/server/root-layout.js +0 -5
- package/dist/server/root-layout.js.map +0 -1
- package/dist/server.d.ts +0 -15
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -20
- package/dist/server.js.map +0 -1
- package/dist/ssr/__tests__/ssr-handler.test.d.ts +0 -2
- package/dist/ssr/__tests__/ssr-handler.test.d.ts.map +0 -1
- package/dist/ssr/__tests__/ssr-handler.test.js +0 -132
- package/dist/ssr/__tests__/ssr-handler.test.js.map +0 -1
- package/dist/ssr/index.d.ts +0 -98
- package/dist/ssr/index.d.ts.map +0 -1
- package/dist/ssr/index.js +0 -158
- package/dist/ssr/index.js.map +0 -1
- package/dist/static-handler.d.ts +0 -50
- package/dist/static-handler.d.ts.map +0 -1
- package/dist/static-handler.gen.d.ts +0 -5
- package/dist/static-handler.gen.d.ts.map +0 -1
- package/dist/static-handler.gen.js +0 -5
- package/dist/static-handler.gen.js.map +0 -1
- package/dist/static-handler.js +0 -29
- package/dist/static-handler.js.map +0 -1
- package/dist/testing/vitest.js +0 -82
- package/dist/theme/ThemeProvider.d.ts +0 -20
- package/dist/theme/ThemeProvider.d.ts.map +0 -1
- package/dist/theme/ThemeProvider.js +0 -240
- package/dist/theme/ThemeProvider.js.map +0 -1
- package/dist/theme/ThemeScript.d.ts +0 -48
- package/dist/theme/ThemeScript.d.ts.map +0 -1
- package/dist/theme/ThemeScript.js +0 -13
- package/dist/theme/ThemeScript.js.map +0 -1
- package/dist/theme/__tests__/theme.test.d.ts +0 -2
- package/dist/theme/__tests__/theme.test.d.ts.map +0 -1
- package/dist/theme/__tests__/theme.test.js +0 -103
- package/dist/theme/__tests__/theme.test.js.map +0 -1
- package/dist/theme/constants.d.ts +0 -29
- package/dist/theme/constants.d.ts.map +0 -1
- package/dist/theme/constants.js +0 -48
- package/dist/theme/constants.js.map +0 -1
- package/dist/theme/index.d.ts +0 -31
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/index.js +0 -36
- package/dist/theme/index.js.map +0 -1
- package/dist/theme/theme-context.d.ts +0 -40
- package/dist/theme/theme-context.d.ts.map +0 -1
- package/dist/theme/theme-context.js +0 -60
- package/dist/theme/theme-context.js.map +0 -1
- package/dist/theme/theme-script.d.ts +0 -27
- package/dist/theme/theme-script.d.ts.map +0 -1
- package/dist/theme/theme-script.js +0 -147
- package/dist/theme/theme-script.js.map +0 -1
- package/dist/theme/types.d.ts +0 -163
- package/dist/theme/types.d.ts.map +0 -1
- package/dist/theme/types.js +0 -11
- package/dist/theme/types.js.map +0 -1
- package/dist/theme/use-theme.d.ts +0 -12
- package/dist/theme/use-theme.d.ts.map +0 -1
- package/dist/theme/use-theme.js +0 -40
- package/dist/theme/use-theme.js.map +0 -1
- package/dist/types.d.ts +0 -1479
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -10
- package/dist/types.js.map +0 -1
- package/dist/urls.d.ts +0 -441
- package/dist/urls.d.ts.map +0 -1
- package/dist/urls.gen.d.ts +0 -8
- package/dist/urls.gen.d.ts.map +0 -1
- package/dist/urls.gen.js +0 -8
- package/dist/urls.gen.js.map +0 -1
- package/dist/urls.js +0 -443
- package/dist/urls.js.map +0 -1
- package/dist/use-loader.d.ts +0 -127
- package/dist/use-loader.d.ts.map +0 -1
- package/dist/use-loader.js +0 -237
- package/dist/use-loader.js.map +0 -1
- package/dist/vite/__tests__/ast-handler-extract.test.d.ts +0 -2
- package/dist/vite/__tests__/ast-handler-extract.test.d.ts.map +0 -1
- package/dist/vite/__tests__/ast-handler-extract.test.js +0 -294
- package/dist/vite/__tests__/ast-handler-extract.test.js.map +0 -1
- package/dist/vite/__tests__/expose-id-utils.test.d.ts +0 -2
- package/dist/vite/__tests__/expose-id-utils.test.d.ts.map +0 -1
- package/dist/vite/__tests__/expose-id-utils.test.js +0 -224
- package/dist/vite/__tests__/expose-id-utils.test.js.map +0 -1
- package/dist/vite/__tests__/expose-internal-ids.test.d.ts +0 -2
- package/dist/vite/__tests__/expose-internal-ids.test.d.ts.map +0 -1
- package/dist/vite/__tests__/expose-internal-ids.test.js +0 -647
- package/dist/vite/__tests__/expose-internal-ids.test.js.map +0 -1
- package/dist/vite/__tests__/expose-router-id.test.d.ts +0 -2
- package/dist/vite/__tests__/expose-router-id.test.d.ts.map +0 -1
- package/dist/vite/__tests__/expose-router-id.test.js +0 -39
- package/dist/vite/__tests__/expose-router-id.test.js.map +0 -1
- package/dist/vite/ast-handler-extract.d.ts +0 -49
- package/dist/vite/ast-handler-extract.d.ts.map +0 -1
- package/dist/vite/ast-handler-extract.js +0 -249
- package/dist/vite/ast-handler-extract.js.map +0 -1
- package/dist/vite/expose-action-id.d.ts +0 -19
- package/dist/vite/expose-action-id.d.ts.map +0 -1
- package/dist/vite/expose-action-id.js +0 -250
- package/dist/vite/expose-action-id.js.map +0 -1
- package/dist/vite/expose-id-utils.d.ts +0 -69
- package/dist/vite/expose-id-utils.d.ts.map +0 -1
- package/dist/vite/expose-id-utils.js +0 -289
- package/dist/vite/expose-id-utils.js.map +0 -1
- package/dist/vite/expose-internal-ids.d.ts +0 -22
- package/dist/vite/expose-internal-ids.d.ts.map +0 -1
- package/dist/vite/expose-internal-ids.js +0 -886
- package/dist/vite/expose-internal-ids.js.map +0 -1
- package/dist/vite/index.d.ts +0 -149
- package/dist/vite/index.d.ts.map +0 -1
- package/dist/vite/index.js.bak +0 -5448
- package/dist/vite/index.js.map +0 -1
- package/dist/vite/index.named-routes.gen.ts +0 -103
- package/dist/vite/package-resolution.d.ts +0 -43
- package/dist/vite/package-resolution.d.ts.map +0 -1
- package/dist/vite/package-resolution.js +0 -112
- package/dist/vite/package-resolution.js.map +0 -1
- package/dist/vite/virtual-entries.d.ts +0 -25
- package/dist/vite/virtual-entries.d.ts.map +0 -1
- package/dist/vite/virtual-entries.js +0 -110
- package/dist/vite/virtual-entries.js.map +0 -1
|
@@ -45,6 +45,8 @@ import {
|
|
|
45
45
|
resolveSwrWindow,
|
|
46
46
|
DEFAULT_FUNCTION_TTL,
|
|
47
47
|
} from "../cache-policy.js";
|
|
48
|
+
import { reportCacheError, reportingAsync } from "../cache-error.js";
|
|
49
|
+
import type { CacheErrorCategory } from "../cache-error.js";
|
|
48
50
|
|
|
49
51
|
// ============================================================================
|
|
50
52
|
// Constants
|
|
@@ -56,6 +58,66 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
|
|
|
56
58
|
/** Header storing cache status: HIT | REVALIDATING */
|
|
57
59
|
export const CACHE_STATUS_HEADER = "x-edge-cache-status";
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Header storing this entry's cache tags as a JSON array. JSON-encoded (not the
|
|
63
|
+
* comma-delimited CF `Cache-Tag` format) so tags containing commas round-trip
|
|
64
|
+
* safely; the read paths parse this to run the tag-invalidation check.
|
|
65
|
+
*/
|
|
66
|
+
export const CACHE_TAGS_HEADER = "x-edge-cache-tags";
|
|
67
|
+
|
|
68
|
+
/** Header storing the ms-epoch timestamp when this entry's tags were attached. */
|
|
69
|
+
export const CACHE_TAGGED_AT_HEADER = "x-edge-cache-tagged-at";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* KV key prefix for tag-invalidation markers. A marker stores the ms-epoch
|
|
73
|
+
* timestamp of the most recent invalidation of a tag; reads treat any entry
|
|
74
|
+
* whose taggedAt is older than its tags' latest marker as invalidated. Markers
|
|
75
|
+
* live in the SAME KV namespace as the cached entries - there is no separate
|
|
76
|
+
* tag-invalidation store.
|
|
77
|
+
*/
|
|
78
|
+
export const TAG_MARKER_PREFIX = "__tag__/";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
|
|
82
|
+
* markers (enabled by tagCacheTtl). Distinct from data keys (doc:/fn:/segment)
|
|
83
|
+
* and from the KV marker prefix so the two never collide.
|
|
84
|
+
*/
|
|
85
|
+
const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sentinel body for an L1-cached marker meaning "this tag has no invalidation
|
|
89
|
+
* marker." Distinct from any real ms-epoch timestamp (always a large positive
|
|
90
|
+
* integer). A Cache API miss (match() === undefined) always means "re-read KV",
|
|
91
|
+
* never "no marker" - absence is only ever represented by this cached sentinel.
|
|
92
|
+
*/
|
|
93
|
+
const TAG_MARKER_ABSENT = "none";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Header storing the epoch-ms timestamp when an entry was marked REVALIDATING.
|
|
97
|
+
* The SWR thundering-herd guard reads this to decide whether the in-flight
|
|
98
|
+
* revalidation is still recent. It replaces a prior reliance on the HTTP `Age`
|
|
99
|
+
* header: CF's Cache API does not populate `Age` reliably per-colo (and our own
|
|
100
|
+
* unit MockCache never set it), so an absent `Age` defaulted to 0 and made every
|
|
101
|
+
* REVALIDATING entry look "just revalidated" forever -- a dropped/never-finished
|
|
102
|
+
* background revalidation could then pin an entry stale until hard expiry. An
|
|
103
|
+
* explicit timestamp we write ourselves (same pattern as CACHE_STALE_AT_HEADER)
|
|
104
|
+
* is reliable and lets the MAX_REVALIDATION_INTERVAL re-arm actually fire.
|
|
105
|
+
*/
|
|
106
|
+
export const CACHE_REVALIDATING_AT_HEADER = "x-edge-cache-revalidating-at";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Header storing the absolute epoch-ms hard-expiry deadline (staleAt +
|
|
110
|
+
* swrWindow*1000) of an L1 entry. The stale-path REVALIDATING re-put reads this
|
|
111
|
+
* to recompute a SHRINKING Cache-Control max-age instead of copying set()'s
|
|
112
|
+
* original full-window max-age. Without it, every MAX_REVALIDATION_INTERVAL
|
|
113
|
+
* re-arm re-puts the full window and restarts CF's retention clock, pinning a
|
|
114
|
+
* perpetually-stale entry (one whose background revalidation keeps failing) past
|
|
115
|
+
* its intended hard-expiry indefinitely. Mirrors the KVSegmentEnvelope `e`
|
|
116
|
+
* field and the remaining-ttl math in promoteSegmentToL1/promoteItemToL1.
|
|
117
|
+
* @internal
|
|
118
|
+
*/
|
|
119
|
+
const CACHE_EXPIRES_AT_HEADER = "x-edge-cache-expires-at";
|
|
120
|
+
|
|
59
121
|
/**
|
|
60
122
|
* Header stashing the route author's original Cache-Control on L1 document
|
|
61
123
|
* entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
|
|
@@ -72,6 +134,118 @@ const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
|
|
|
72
134
|
*/
|
|
73
135
|
export const MAX_REVALIDATION_INTERVAL = 30;
|
|
74
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
|
|
139
|
+
* null when no marker exists). Keyed first by the request context object (so it
|
|
140
|
+
* is naturally request-scoped and garbage-collected with the request) and then
|
|
141
|
+
* by the store INSTANCE.
|
|
142
|
+
*
|
|
143
|
+
* The per-store nesting matters because a single request can run more than one
|
|
144
|
+
* CFCacheStore - the app-level store plus a route's `cache({ store })` override,
|
|
145
|
+
* which may point at a DIFFERENT KV binding or version. A module-level map keyed
|
|
146
|
+
* by request alone (the inner map keyed by the raw tag name) would let store B's
|
|
147
|
+
* memoized marker for a tag mask store A's own KV marker, so A could serve an
|
|
148
|
+
* entry A's own KV says is invalidated. Keying by the instance isolates them;
|
|
149
|
+
* two reads through the SAME store still share the memo. A read through one
|
|
150
|
+
* store never populates another's memo, so each store always consults its own KV
|
|
151
|
+
* binding. Markers are read only through isGloballyInvalidated(), which already
|
|
152
|
+
* short-circuits when a store has no KV, so a store without KV never allocates.
|
|
153
|
+
*
|
|
154
|
+
* Without the memo, isGloballyInvalidated() issues a KV read per tag on every
|
|
155
|
+
* tagged cache read, so a page composed of many segments/items sharing a tag
|
|
156
|
+
* pays that cost N times. The memo collapses it to one KV read per distinct tag
|
|
157
|
+
* per (request, store). invalidateTags() writes through so a same-request
|
|
158
|
+
* updateTag() stays read-your-own-writes consistent (the action's own re-render
|
|
159
|
+
* sees its own invalidation from the memo, without a re-read).
|
|
160
|
+
*
|
|
161
|
+
* It does NOT span requests, so a hot single-entry route still pays one KV read
|
|
162
|
+
* per request; that read hits Cloudflare KV's own edge read cache for hot keys.
|
|
163
|
+
*/
|
|
164
|
+
const tagMarkerMemo = new WeakMap<
|
|
165
|
+
object,
|
|
166
|
+
WeakMap<object, Map<string, number | null>>
|
|
167
|
+
>();
|
|
168
|
+
|
|
169
|
+
function getTagMarkerMemo(
|
|
170
|
+
ctx: object,
|
|
171
|
+
store: object,
|
|
172
|
+
): Map<string, number | null> {
|
|
173
|
+
let byStore = tagMarkerMemo.get(ctx);
|
|
174
|
+
if (!byStore) {
|
|
175
|
+
byStore = new WeakMap();
|
|
176
|
+
tagMarkerMemo.set(ctx, byStore);
|
|
177
|
+
}
|
|
178
|
+
let memo = byStore.get(store);
|
|
179
|
+
if (!memo) {
|
|
180
|
+
memo = new Map();
|
|
181
|
+
byStore.set(store, memo);
|
|
182
|
+
}
|
|
183
|
+
return memo;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Per-request map of IN-FLIGHT marker reads (tag -> the pending read promise).
|
|
188
|
+
* The resolved-value memo above only collapses SEQUENTIAL reads of a tag; the
|
|
189
|
+
* router resolves sibling segments in PARALLEL, so without this several
|
|
190
|
+
* concurrently-resolving segments sharing a tag would each issue their own KV
|
|
191
|
+
* read before any of them populates the memo. Sharing the in-flight promise
|
|
192
|
+
* collapses those to a single KV read. Entries are dropped once resolved (the
|
|
193
|
+
* value is then in the memo), so this only spans the concurrent read window.
|
|
194
|
+
*/
|
|
195
|
+
const tagMarkerInflight = new WeakMap<
|
|
196
|
+
object,
|
|
197
|
+
WeakMap<object, Map<string, Promise<number | null>>>
|
|
198
|
+
>();
|
|
199
|
+
|
|
200
|
+
function getTagMarkerInflight(
|
|
201
|
+
ctx: object,
|
|
202
|
+
store: object,
|
|
203
|
+
): Map<string, Promise<number | null>> {
|
|
204
|
+
let byStore = tagMarkerInflight.get(ctx);
|
|
205
|
+
if (!byStore) {
|
|
206
|
+
byStore = new WeakMap();
|
|
207
|
+
tagMarkerInflight.set(ctx, byStore);
|
|
208
|
+
}
|
|
209
|
+
let inflight = byStore.get(store);
|
|
210
|
+
if (!inflight) {
|
|
211
|
+
inflight = new Map();
|
|
212
|
+
byStore.set(store, inflight);
|
|
213
|
+
}
|
|
214
|
+
return inflight;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** KV key byte-length ceiling. Cloudflare KV rejects keys larger than this. */
|
|
218
|
+
const KV_MAX_KEY_BYTES = 512;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Cloudflare KV's minimum `expirationTtl` (seconds). A `put` with a smaller
|
|
222
|
+
* expirationTtl is rejected outright. Tag-invalidation markers (the only writes
|
|
223
|
+
* that take a consumer-supplied TTL via tagInvalidationTtl) are floored to this
|
|
224
|
+
* so a too-small value cannot make EVERY updateTag/revalidateTag throw.
|
|
225
|
+
*/
|
|
226
|
+
const KV_MIN_EXPIRATION_TTL = 60;
|
|
227
|
+
|
|
228
|
+
const kvKeyEncoder = new TextEncoder();
|
|
229
|
+
|
|
230
|
+
/** UTF-8 byte length of a KV key (multibyte tags can exceed the char count). */
|
|
231
|
+
function kvKeyByteLength(key: string): number {
|
|
232
|
+
return kvKeyEncoder.encode(key).length;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Stores (by namespace) already warned about tag machinery configured without a
|
|
237
|
+
* KV namespace, so the warning fires once per process rather than per request
|
|
238
|
+
* (CFCacheStore is constructed per request).
|
|
239
|
+
*/
|
|
240
|
+
const warnedNoKvReadInvalidation = new Set<string>();
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Stores (by namespace) already warned about a tagInvalidationTtl below KV's
|
|
244
|
+
* expirationTtl floor, so the floor warning fires once per process rather than
|
|
245
|
+
* once per request (CFCacheStore is constructed per request).
|
|
246
|
+
*/
|
|
247
|
+
const warnedTagInvalidationTtlFloor = new Set<string>();
|
|
248
|
+
|
|
75
249
|
/**
|
|
76
250
|
* Maximum time (ms) to wait for an L1 edge cache (CF Cache API) read before
|
|
77
251
|
* giving up and treating it as a miss. The Cache API is normally sub-millisecond
|
|
@@ -84,6 +258,63 @@ export const MAX_REVALIDATION_INTERVAL = 30;
|
|
|
84
258
|
*/
|
|
85
259
|
export const EDGE_LOOKUP_TIMEOUT_MS = 10;
|
|
86
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Maximum time (ms) to wait for the BODY of a matched L1 entry to be read
|
|
263
|
+
* (response.json()) before treating the read as a miss.
|
|
264
|
+
*
|
|
265
|
+
* This is separate from {@link EDGE_LOOKUP_TIMEOUT_MS} on purpose. CF's Cache
|
|
266
|
+
* API resolves `match()` with a lazily-streamed body, so a fast `match` can be
|
|
267
|
+
* followed by a multi-second stall while the body bytes are fetched -- the
|
|
268
|
+
* latency tail lives here, after the match budget has already passed. The
|
|
269
|
+
* default bounds that tail aggressively: a healthy per-colo body read (fetch +
|
|
270
|
+
* JSON parse) settles in low single-digit milliseconds, so 20ms clears a
|
|
271
|
+
* healthy read while still failing fast to L2/KV (or render) on a degraded colo
|
|
272
|
+
* instead of pinning the request behind a seconds-long read. Raise it per store
|
|
273
|
+
* if large Flight payloads legitimately need longer.
|
|
274
|
+
*
|
|
275
|
+
* Override per store via `CFCacheStoreOptions.edgeReadTimeoutMs` (<= 0 disables).
|
|
276
|
+
*/
|
|
277
|
+
export const EDGE_READ_TIMEOUT_MS = 20;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Maximum time (ms) to wait for an L2 (KV) read (`kv.get(key, {type:"json"})`)
|
|
281
|
+
* before treating it as a miss. Unlike the L1 budgets, KV is a GLOBAL store: the
|
|
282
|
+
* file header documents ~50ms healthy reads, and a degraded namespace can tail
|
|
283
|
+
* to seconds. KV is the LAST cache tier before a full render, so an unbounded
|
|
284
|
+
* read here pins the whole request behind a degraded global lookup.
|
|
285
|
+
*
|
|
286
|
+
* The default (170ms) sits a few multiples above the documented ~50ms healthy
|
|
287
|
+
* read, leaving headroom for legitimate latency tails (larger payloads,
|
|
288
|
+
* far-from-colo regions) so a healthy-but-slow read does not false-miss into a
|
|
289
|
+
* render, while still abandoning a genuinely degraded namespace well before its
|
|
290
|
+
* multi-second tail can pin the request. A deployment with a tighter SLA can
|
|
291
|
+
* lower it, and one whose healthy p99 runs higher should raise it: measure the
|
|
292
|
+
* KV read p99 (Workers Analytics) and add margin. It is a degradation
|
|
293
|
+
* guard-rail, not a tuning lever for "slow KV is normal here".
|
|
294
|
+
*
|
|
295
|
+
* Override per store via `CFCacheStoreOptions.kvReadTimeoutMs` (<= 0 disables).
|
|
296
|
+
*/
|
|
297
|
+
export const KV_READ_TIMEOUT_MS = 170;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Compute the Cache-Control directive for a stale-path REVALIDATING re-put from
|
|
301
|
+
* the entry's stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER). Returns the
|
|
302
|
+
* REMAINING ttl so the re-put preserves the original retention deadline instead
|
|
303
|
+
* of restarting it -- copying set()'s original full-window max-age would reset
|
|
304
|
+
* CF's retention clock on every re-arm and pin a perpetually-stale entry forever.
|
|
305
|
+
* An entry lacking a valid deadline (legacy/tampered) floors to max-age=1, so it
|
|
306
|
+
* hard-expires in ~1s and self-heals via KV. Mirrors promoteSegmentToL1's math.
|
|
307
|
+
* @internal
|
|
308
|
+
*/
|
|
309
|
+
function remainingCacheControl(headers: Headers, now: number): string {
|
|
310
|
+
const expiresAt = Number(headers.get(CACHE_EXPIRES_AT_HEADER));
|
|
311
|
+
const remainingTtl =
|
|
312
|
+
Number.isFinite(expiresAt) && expiresAt > 0
|
|
313
|
+
? Math.max(1, Math.floor((expiresAt - now) / 1000))
|
|
314
|
+
: 1;
|
|
315
|
+
return `public, max-age=${remainingTtl}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
87
318
|
// ============================================================================
|
|
88
319
|
// Types
|
|
89
320
|
// ============================================================================
|
|
@@ -134,6 +365,10 @@ interface KVItemEnvelope {
|
|
|
134
365
|
s: number;
|
|
135
366
|
/** When entry hard-expires (ms epoch) */
|
|
136
367
|
e: number;
|
|
368
|
+
/** Cache tags (for distributed tag invalidation) */
|
|
369
|
+
t?: string[];
|
|
370
|
+
/** Timestamp when tags were attached (ms epoch) */
|
|
371
|
+
ta?: number;
|
|
137
372
|
}
|
|
138
373
|
|
|
139
374
|
/**
|
|
@@ -147,14 +382,112 @@ interface KVResponseEnvelope {
|
|
|
147
382
|
st: number;
|
|
148
383
|
/** HTTP status text */
|
|
149
384
|
stx: string;
|
|
150
|
-
/** Serialized headers as key-value pairs */
|
|
385
|
+
/** Serialized headers as key-value pairs (client-facing; no internal headers) */
|
|
151
386
|
hd: [string, string][];
|
|
152
387
|
/** When entry becomes stale (ms epoch) */
|
|
153
388
|
s: number;
|
|
154
389
|
/** When entry hard-expires (ms epoch) */
|
|
155
390
|
e: number;
|
|
391
|
+
/** Cache tags (for distributed tag invalidation) */
|
|
392
|
+
t?: string[];
|
|
393
|
+
/** Timestamp when tags were attached (ms epoch) */
|
|
394
|
+
ta?: number;
|
|
156
395
|
}
|
|
157
396
|
|
|
397
|
+
/**
|
|
398
|
+
* One L1 read decision, surfaced when `debug` is enabled. Lets an operator
|
|
399
|
+
* confirm on a real deployment (e.g. via `wrangler tail`) that the store's
|
|
400
|
+
* observed inputs match its decision: which tier answered, the entry's status,
|
|
401
|
+
* the stale/revalidating timestamps, the raw CF `Age` header (so its
|
|
402
|
+
* unreliability can be seen next to the explicit revalidating-at stamp), and
|
|
403
|
+
* the measured match/body-read durations (where the latency tail shows up).
|
|
404
|
+
*/
|
|
405
|
+
export interface CFCacheReadDebugEvent {
|
|
406
|
+
/**
|
|
407
|
+
* Which read method produced this event. Only the JSON read paths (segment
|
|
408
|
+
* `get` and function `getItem`) participate in debug; the document
|
|
409
|
+
* `getResponse` path streams its body and is intentionally out of scope.
|
|
410
|
+
*/
|
|
411
|
+
op: "get" | "getItem";
|
|
412
|
+
/** Cache key (without the internal fn:/doc: prefix or version path). */
|
|
413
|
+
key: string;
|
|
414
|
+
/**
|
|
415
|
+
* What the read resolved to:
|
|
416
|
+
* - l1-fresh / l1-stale-revalidate / l1-revalidating-guarded: L1 hit outcomes
|
|
417
|
+
* - match-timeout / body-timeout: the L1 latency budgets fired
|
|
418
|
+
* - match-error: the L1 match() itself rejected (a transient Cache API infra
|
|
419
|
+
* error) -- a miss that falls through to L2/KV and is reported cache-read,
|
|
420
|
+
* distinct from a genuine l1-miss (absence) so the two are separable
|
|
421
|
+
* - body-error: the L1 body read failed fast (corrupt/non-JSON body) -- a miss
|
|
422
|
+
* that falls through to L2/KV, distinct from a body-timeout
|
|
423
|
+
* - non-200: L1 returned a non-200 (treated as a miss)
|
|
424
|
+
* - l1-miss: no L1 entry
|
|
425
|
+
* - kv-fresh / kv-stale / kv-miss: L2 fallback outcomes
|
|
426
|
+
* - kv-stale-suppressed: a stale L2 hit served WITHOUT revalidation because
|
|
427
|
+
* the L1 fall-through was degraded (body-timeout / non-200) -- the herd
|
|
428
|
+
* mitigation, distinct from kv-stale so the suppression is visible
|
|
429
|
+
* - kv-timeout: the L2/KV read budget fired (read abandoned, NOT a genuine
|
|
430
|
+
* absence -- distinct from kv-miss so a degradation signal is separable)
|
|
431
|
+
* - tag-invalidated: a live L1/KV entry whose cache tags were invalidated
|
|
432
|
+
* after it was written -- treated as a miss so the next render re-populates
|
|
433
|
+
* it (the tag-invalidation read path, distinct from a plain miss)
|
|
434
|
+
* - error: the read threw
|
|
435
|
+
*/
|
|
436
|
+
outcome:
|
|
437
|
+
| "l1-fresh"
|
|
438
|
+
| "l1-stale-revalidate"
|
|
439
|
+
| "l1-revalidating-guarded"
|
|
440
|
+
| "match-timeout"
|
|
441
|
+
| "match-error"
|
|
442
|
+
| "body-timeout"
|
|
443
|
+
| "body-error"
|
|
444
|
+
| "non-200"
|
|
445
|
+
| "tag-invalidated"
|
|
446
|
+
| "l1-miss"
|
|
447
|
+
| "kv-fresh"
|
|
448
|
+
| "kv-stale"
|
|
449
|
+
| "kv-stale-suppressed"
|
|
450
|
+
| "kv-miss"
|
|
451
|
+
| "kv-timeout"
|
|
452
|
+
| "error";
|
|
453
|
+
/** HTTP status of the matched L1 response, when one was returned. */
|
|
454
|
+
status?: number;
|
|
455
|
+
/**
|
|
456
|
+
* Stored cache status header (CACHE_STATUS_HEADER): "HIT" or "REVALIDATING".
|
|
457
|
+
* Distinct from `isRevalidating`, which also factors in stamp recency -- this
|
|
458
|
+
* is the raw stored value, so a REVALIDATING entry whose stamp aged out (so
|
|
459
|
+
* `isRevalidating` is false) is still distinguishable from a plain HIT.
|
|
460
|
+
*/
|
|
461
|
+
cacheStatus?: string | null;
|
|
462
|
+
/** Epoch-ms when the entry goes stale (from CACHE_STALE_AT_HEADER). */
|
|
463
|
+
staleAt?: number;
|
|
464
|
+
/** Epoch-ms the entry was marked REVALIDATING (from the explicit stamp). */
|
|
465
|
+
revalidatingAt?: number;
|
|
466
|
+
/** Raw CF `Age` header, for comparison against revalidatingAt (may be null). */
|
|
467
|
+
ageHeader?: string | null;
|
|
468
|
+
isStale?: boolean;
|
|
469
|
+
isRevalidating?: boolean;
|
|
470
|
+
shouldRevalidate?: boolean;
|
|
471
|
+
/** Wall-clock ms spent in cache.match (bounded by edgeLookupTimeoutMs). */
|
|
472
|
+
matchMs?: number;
|
|
473
|
+
/**
|
|
474
|
+
* Wall-clock ms spent resolving the entry's tag-invalidation markers (the
|
|
475
|
+
* per-request memo -> optional per-colo L1 marker cache -> KV cascade), for a
|
|
476
|
+
* tagged entry. 0/absent for an untagged entry or a memo hit; a non-trivial
|
|
477
|
+
* value is the serial marker-read tail that sits between matchMs and
|
|
478
|
+
* bodyReadMs. Only measured when debug is enabled.
|
|
479
|
+
*/
|
|
480
|
+
markerMs?: number;
|
|
481
|
+
/** Wall-clock ms spent reading the body (bounded by edgeReadTimeoutMs). */
|
|
482
|
+
bodyReadMs?: number;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Debug sink. `true` logs each {@link CFCacheReadDebugEvent} to console; a
|
|
487
|
+
* function receives the events for programmatic capture.
|
|
488
|
+
*/
|
|
489
|
+
export type CFCacheDebug = boolean | ((event: CFCacheReadDebugEvent) => void);
|
|
490
|
+
|
|
158
491
|
export interface CFCacheStoreOptions<TEnv = unknown> {
|
|
159
492
|
/**
|
|
160
493
|
* Cache namespace. If not provided, uses caches.default (recommended).
|
|
@@ -196,9 +529,92 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
|
|
|
196
529
|
* ```typescript
|
|
197
530
|
* new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
|
|
198
531
|
* ```
|
|
532
|
+
*
|
|
533
|
+
* Tag-based invalidation (updateTag/revalidateTag) requires KV: the
|
|
534
|
+
* tag-invalidation markers are stored in this same namespace. There is no
|
|
535
|
+
* separate tag-invalidation store to configure.
|
|
199
536
|
*/
|
|
200
537
|
kv?: KVNamespace;
|
|
201
538
|
|
|
539
|
+
/**
|
|
540
|
+
* Optional eager-purge hook, called ONCE per updateTag()/revalidateTag() with
|
|
541
|
+
* the namespaced Cloudflare Cache-Tags to purge (one batched call for the
|
|
542
|
+
* whole invalidation, not one per tag). These exactly match the `Cache-Tag`
|
|
543
|
+
* header this store writes on its tag-lookup marker entries
|
|
544
|
+
* (`rg:{namespace}:lk:{encodedTag}`), so forwarding them to Cloudflare's
|
|
545
|
+
* purge-by-tag API evicts the cached lookups in every colo - making
|
|
546
|
+
* cross-colo invalidation prompt instead of waiting out `tagCacheTtl`.
|
|
547
|
+
*
|
|
548
|
+
* Only meaningful with `tagCacheTtl > 0` (otherwise there are no cached
|
|
549
|
+
* lookups to purge). The values are pre-encoded, so commas in tag names are
|
|
550
|
+
* safe to pass straight to the purge API.
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* onRevalidateTag: async (cacheTags) => {
|
|
555
|
+
* await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache`, {
|
|
556
|
+
* method: "POST",
|
|
557
|
+
* headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
|
|
558
|
+
* body: JSON.stringify({ tags: cacheTags }),
|
|
559
|
+
* });
|
|
560
|
+
* }
|
|
561
|
+
* ```
|
|
562
|
+
*/
|
|
563
|
+
onRevalidateTag?: (cacheTags: string[]) => Promise<void>;
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Optional expiration (seconds) for tag-invalidation markers in KV. A marker
|
|
567
|
+
* must outlive every entry tagged before the invalidation, so this MUST
|
|
568
|
+
* exceed your largest entry TTL+SWR. Defaults to no expiration (markers
|
|
569
|
+
* persist; they are tiny - one timestamp per distinct invalidated tag).
|
|
570
|
+
*
|
|
571
|
+
* Note the opposite sizing from `tagCacheTtl` below: `tagInvalidationTtl` must
|
|
572
|
+
* be LARGE (outlive data); `tagCacheTtl` should be SMALL (a staleness ceiling).
|
|
573
|
+
*
|
|
574
|
+
* Cardinality matters: each DISTINCT invalidated tag writes one permanent KV
|
|
575
|
+
* marker (with the no-expiry default). Keep tags LOW-cardinality and never
|
|
576
|
+
* derive an invalidation tag from untrusted input (e.g.
|
|
577
|
+
* `revalidateTag(req.query.tag)`) - an attacker could otherwise grow your KV
|
|
578
|
+
* namespace without bound. Set a `tagInvalidationTtl` only if your tags are
|
|
579
|
+
* unavoidably high-cardinality AND it can still safely exceed your max entry
|
|
580
|
+
* TTL+SWR.
|
|
581
|
+
*/
|
|
582
|
+
tagInvalidationTtl?: number;
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Optional TTL (seconds) for caching tag-invalidation markers in the per-colo
|
|
586
|
+
* Cache API (L1), to avoid a KV marker read on every tagged cache read.
|
|
587
|
+
*
|
|
588
|
+
* Default `0` = disabled: the marker is read from KV on every tagged read
|
|
589
|
+
* (today's behavior), giving the strongest cross-colo invalidation latency
|
|
590
|
+
* (~KV consistency). A positive value caches each marker (including the
|
|
591
|
+
* "no marker yet" state) in L1 for that many seconds, so within the window a
|
|
592
|
+
* colo answers from L1 with no KV read.
|
|
593
|
+
*
|
|
594
|
+
* The colo that runs `updateTag`/`revalidateTag` writes the fresh marker
|
|
595
|
+
* straight into its own L1 (write-through), so the invalidating request and
|
|
596
|
+
* later reads in that colo observe the invalidation immediately. One caveat: a
|
|
597
|
+
* read already in flight when the invalidation lands (one that began its KV
|
|
598
|
+
* marker fetch first) can re-cache the PRIOR marker into L1 after the
|
|
599
|
+
* write-through, so a racing concurrent reader in the same colo may miss the
|
|
600
|
+
* invalidation for up to `tagCacheTtl` -- the Cache API exposes no
|
|
601
|
+
* compare-and-set to close this fully. `tagCacheTtl` is therefore a staleness
|
|
602
|
+
* CEILING, not a promise of zero same-colo latency; keep it small (or wire
|
|
603
|
+
* `onRevalidateTag`) when that matters. By default OTHER colos only converge
|
|
604
|
+
* when their cached marker expires, so `tagCacheTtl` is the MAXIMUM extra
|
|
605
|
+
* cross-colo invalidation latency for them. Recommended 30-60 for high-read,
|
|
606
|
+
* low-mutation tags; leave at 0 when prompt global invalidation matters and
|
|
607
|
+
* you cannot wire a purge.
|
|
608
|
+
*
|
|
609
|
+
* To make other colos prompt WITHOUT a short TTL, wire `onRevalidateTag` to a
|
|
610
|
+
* Cloudflare purge-by-tag call: each marker entry carries a namespaced
|
|
611
|
+
* `Cache-Tag`, and `onRevalidateTag` is handed exactly those tags to purge, so
|
|
612
|
+
* the cached lookups are evicted everywhere on invalidation. With a purge
|
|
613
|
+
* wired, `tagCacheTtl` becomes purely a read-cost reducer + fallback window
|
|
614
|
+
* (safe to set large) rather than the invalidation-latency ceiling.
|
|
615
|
+
*/
|
|
616
|
+
tagCacheTtl?: number;
|
|
617
|
+
|
|
202
618
|
/**
|
|
203
619
|
* Cache version string override. When this changes, all cached entries are
|
|
204
620
|
* effectively invalidated (new keys won't match old entries).
|
|
@@ -219,11 +635,54 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
|
|
|
219
635
|
*/
|
|
220
636
|
edgeLookupTimeoutMs?: number;
|
|
221
637
|
|
|
638
|
+
/**
|
|
639
|
+
* Latency budget (ms) for reading the BODY of a matched L1 entry
|
|
640
|
+
* (response.json()). CF streams the cache body lazily, so the multi-second
|
|
641
|
+
* tail can appear after `match` already resolved; this bounds it. On timeout
|
|
642
|
+
* the read is treated as a miss and falls through to L2/KV or render.
|
|
643
|
+
*
|
|
644
|
+
* Separate from {@link edgeLookupTimeoutMs} because a healthy body read
|
|
645
|
+
* (fetch + JSON parse of a potentially large Flight payload) takes a little
|
|
646
|
+
* longer than a `match`. Defaults to {@link EDGE_READ_TIMEOUT_MS} (20), which
|
|
647
|
+
* clears a healthy per-colo read yet fails fast on a degraded one. Set to 0
|
|
648
|
+
* (or any value <= 0) to disable and always await the body.
|
|
649
|
+
*/
|
|
650
|
+
edgeReadTimeoutMs?: number;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Latency budget (ms) for an L2 (KV) read. KV is the last cache tier before a
|
|
654
|
+
* full render and is a global store (~50ms healthy, seconds when degraded);
|
|
655
|
+
* this bounds it so a slow namespace cannot pin the request. On timeout the
|
|
656
|
+
* read is treated as a miss (no L1 promote) and falls through to render.
|
|
657
|
+
*
|
|
658
|
+
* Defaults to {@link KV_READ_TIMEOUT_MS} (170) -- a few multiples above the
|
|
659
|
+
* ~50ms healthy read, with headroom for legitimate tails (large payloads / far
|
|
660
|
+
* regions) yet still well under a degraded namespace's multi-second tail.
|
|
661
|
+
* Lower it for a tighter SLA, raise it if your healthy KV p99 runs higher; it
|
|
662
|
+
* is a degradation guard-rail, not a tuning lever. Set to 0 (or any value
|
|
663
|
+
* <= 0) to disable and always await KV.
|
|
664
|
+
*/
|
|
665
|
+
kvReadTimeoutMs?: number;
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
|
|
669
|
+
* (visible via `wrangler tail`); pass a function to capture events directly.
|
|
670
|
+
* Off by default. Intended for validating cache behavior on a real
|
|
671
|
+
* deployment before relying on it; not for steady-state production.
|
|
672
|
+
*/
|
|
673
|
+
debug?: CFCacheDebug;
|
|
674
|
+
|
|
222
675
|
/**
|
|
223
676
|
* Custom key generator applied to all cache operations.
|
|
224
677
|
* Receives the full RequestContext (including env) and the default-generated key.
|
|
225
678
|
* Return value becomes the final cache key (unless route overrides with `key` option).
|
|
226
679
|
*
|
|
680
|
+
* Reserved prefixes: tag-invalidation markers live in the SAME KV namespace as
|
|
681
|
+
* data, keyed `__tag__/<tag>` (and `__tagmarker__/<tag>` for the L1 cache). A
|
|
682
|
+
* returned key must NOT begin with `__tag__/` or `__tagmarker__/`, or it can
|
|
683
|
+
* collide with a tag marker and corrupt invalidation. The documented
|
|
684
|
+
* prepend-style generators below are safe.
|
|
685
|
+
*
|
|
227
686
|
* @example Using headers for user segmentation
|
|
228
687
|
* ```typescript
|
|
229
688
|
* keyGenerator: (ctx, defaultKey) => {
|
|
@@ -276,7 +735,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
276
735
|
private readonly waitUntil?: (fn: () => Promise<void>) => void;
|
|
277
736
|
private readonly version?: string;
|
|
278
737
|
private readonly edgeLookupTimeoutMs: number;
|
|
738
|
+
private readonly edgeReadTimeoutMs: number;
|
|
739
|
+
private readonly kvReadTimeoutMs: number;
|
|
740
|
+
private readonly debug?: (event: CFCacheReadDebugEvent) => void;
|
|
279
741
|
private readonly kv?: KVNamespace;
|
|
742
|
+
private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
|
|
743
|
+
private readonly tagInvalidationTtl?: number;
|
|
744
|
+
private readonly tagCacheTtl: number;
|
|
280
745
|
|
|
281
746
|
constructor(options: CFCacheStoreOptions<TEnv>) {
|
|
282
747
|
if (!options.ctx) {
|
|
@@ -296,11 +761,122 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
296
761
|
this.explicitBaseUrl = options.baseUrl;
|
|
297
762
|
this.defaults = options.defaults;
|
|
298
763
|
this.version = options.version ?? VERSION;
|
|
299
|
-
|
|
300
|
-
|
|
764
|
+
// Coalesce only finite numbers to the override; a non-finite value (NaN from
|
|
765
|
+
// `Number(env.UNSET)`, or Infinity) would otherwise sail past `?? DEFAULT`
|
|
766
|
+
// (which only replaces null/undefined) into setTimeout, where NaN/Infinity
|
|
767
|
+
// are spec-coerced to ~1ms and silently turn the budget into a near-100%
|
|
768
|
+
// false-miss on that tier. A genuine finite 0 or negative still passes
|
|
769
|
+
// through and disables the budget per the documented `<= 0` contract.
|
|
770
|
+
const finiteBudget = (
|
|
771
|
+
value: number | undefined,
|
|
772
|
+
fallback: number,
|
|
773
|
+
): number =>
|
|
774
|
+
typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
775
|
+
this.edgeLookupTimeoutMs = finiteBudget(
|
|
776
|
+
options.edgeLookupTimeoutMs,
|
|
777
|
+
EDGE_LOOKUP_TIMEOUT_MS,
|
|
778
|
+
);
|
|
779
|
+
this.edgeReadTimeoutMs = finiteBudget(
|
|
780
|
+
options.edgeReadTimeoutMs,
|
|
781
|
+
EDGE_READ_TIMEOUT_MS,
|
|
782
|
+
);
|
|
783
|
+
this.kvReadTimeoutMs = finiteBudget(
|
|
784
|
+
options.kvReadTimeoutMs,
|
|
785
|
+
KV_READ_TIMEOUT_MS,
|
|
786
|
+
);
|
|
787
|
+
this.debug =
|
|
788
|
+
options.debug === true
|
|
789
|
+
? (event) =>
|
|
790
|
+
console.log(`[CFCacheStore:debug] ${JSON.stringify(event)}`)
|
|
791
|
+
: typeof options.debug === "function"
|
|
792
|
+
? options.debug
|
|
793
|
+
: undefined;
|
|
301
794
|
this.keyGenerator = options.keyGenerator;
|
|
302
795
|
this.waitUntil = (fn) => options.ctx.waitUntil(fn());
|
|
303
796
|
this.kv = options.kv;
|
|
797
|
+
this.onRevalidateTag = options.onRevalidateTag;
|
|
798
|
+
// tagInvalidationTtl feeds KV's expirationTtl, which CF rejects below
|
|
799
|
+
// KV_MIN_EXPIRATION_TTL (60s) -- a too-small finite value would make EVERY
|
|
800
|
+
// marker write throw and break ALL invalidation. Floor it (and warn once);
|
|
801
|
+
// a non-finite/non-positive value falls back to the no-expiry default
|
|
802
|
+
// (markers persist) rather than silently sailing a NaN into expirationTtl.
|
|
803
|
+
this.tagInvalidationTtl = this.sanitizeTagInvalidationTtl(
|
|
804
|
+
options.tagInvalidationTtl,
|
|
805
|
+
);
|
|
806
|
+
// tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
|
|
807
|
+
// from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
|
|
808
|
+
// through and silently disable the cache while reading as "configured".
|
|
809
|
+
// Coerce any non-finite/non-positive value to the documented 0 = disabled.
|
|
810
|
+
this.tagCacheTtl =
|
|
811
|
+
typeof options.tagCacheTtl === "number" &&
|
|
812
|
+
Number.isFinite(options.tagCacheTtl) &&
|
|
813
|
+
options.tagCacheTtl > 0
|
|
814
|
+
? options.tagCacheTtl
|
|
815
|
+
: 0;
|
|
816
|
+
|
|
817
|
+
// Read-side tag invalidation requires KV: isGloballyInvalidated() compares an
|
|
818
|
+
// entry's taggedAt against the per-tag KV marker and short-circuits to "not
|
|
819
|
+
// invalidated" when no KV namespace is configured. A consumer who wires the
|
|
820
|
+
// tag machinery (tagCacheTtl for L1 markers, or onRevalidateTag for CDN purge)
|
|
821
|
+
// but omits kv gets only the purge fired - marker writes are skipped without
|
|
822
|
+
// kv - yet every tagged read still serves stale data with no other signal.
|
|
823
|
+
// Surface that misconfiguration.
|
|
824
|
+
if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
|
|
825
|
+
const id = this.namespace ?? "default";
|
|
826
|
+
if (!warnedNoKvReadInvalidation.has(id)) {
|
|
827
|
+
warnedNoKvReadInvalidation.add(id);
|
|
828
|
+
console.warn(
|
|
829
|
+
`[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
|
|
830
|
+
`namespace, so tag invalidation has NO read-side effect: tagged reads ` +
|
|
831
|
+
`are never treated as invalidated and serve stale data. Configure ` +
|
|
832
|
+
`{ kv } for distributed tag invalidation.`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
|
|
840
|
+
* floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
|
|
841
|
+
* one-time warning) so invalidation keeps working instead of every marker
|
|
842
|
+
* write throwing; a non-finite or non-positive value returns undefined (the
|
|
843
|
+
* no-expiry default). The warning still notes the sizing rule: the TTL must
|
|
844
|
+
* exceed the largest entry TTL+SWR or invalidated entries can resurrect.
|
|
845
|
+
* @internal
|
|
846
|
+
*/
|
|
847
|
+
private sanitizeTagInvalidationTtl(
|
|
848
|
+
value: number | undefined,
|
|
849
|
+
): number | undefined {
|
|
850
|
+
if (value == null) return undefined;
|
|
851
|
+
if (!Number.isFinite(value) || value <= 0) return undefined;
|
|
852
|
+
if (value < KV_MIN_EXPIRATION_TTL) {
|
|
853
|
+
const id = this.namespace ?? "default";
|
|
854
|
+
if (!warnedTagInvalidationTtlFloor.has(id)) {
|
|
855
|
+
warnedTagInvalidationTtlFloor.add(id);
|
|
856
|
+
console.warn(
|
|
857
|
+
`[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
|
|
858
|
+
`${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
|
|
859
|
+
`${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
|
|
860
|
+
`TTL+SWR or invalidated entries can resurrect when the marker expires.`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return KV_MIN_EXPIRATION_TTL;
|
|
864
|
+
}
|
|
865
|
+
return value;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Emit a debug event if `debug` is enabled. Swallows sink errors so a faulty
|
|
870
|
+
* debug callback can never break a cache read.
|
|
871
|
+
* @internal
|
|
872
|
+
*/
|
|
873
|
+
private emitDebug(event: CFCacheReadDebugEvent): void {
|
|
874
|
+
if (!this.debug) return;
|
|
875
|
+
try {
|
|
876
|
+
this.debug(event);
|
|
877
|
+
} catch {
|
|
878
|
+
// A broken debug sink must not affect the request.
|
|
879
|
+
}
|
|
304
880
|
}
|
|
305
881
|
|
|
306
882
|
/**
|
|
@@ -368,56 +944,257 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
368
944
|
}
|
|
369
945
|
|
|
370
946
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
947
|
+
* Race an async cache read against a latency budget. Shared by all three read
|
|
948
|
+
* tiers (L1 match, L1 body, L2/KV) so the timeout policy lives in one place:
|
|
949
|
+
* on timeout it returns `{ value: undefined, timedOut: true }` and logs
|
|
950
|
+
* `${label} exceeded ${budgetMs}ms; treating as miss`; the abandoned read is
|
|
951
|
+
* left to settle in the background (late rejection swallowed) rather than
|
|
952
|
+
* aborted, since the underlying CF primitives expose no cancellation. A budget
|
|
953
|
+
* <= 0 disables the bound and awaits the read directly. `read` is a thunk so
|
|
954
|
+
* the disabled path and the raced path start the read identically.
|
|
379
955
|
* @internal
|
|
380
956
|
*/
|
|
381
|
-
private async
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
}
|
|
957
|
+
private async readWithTimeout<T>(
|
|
958
|
+
read: () => Promise<T>,
|
|
959
|
+
budgetMs: number,
|
|
960
|
+
label: string,
|
|
961
|
+
): Promise<{ value: T | undefined; timedOut: boolean }> {
|
|
962
|
+
if (budgetMs <= 0) return { value: await read(), timedOut: false };
|
|
963
|
+
|
|
389
964
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
390
965
|
const timeout = new Promise<{ timedOut: true }>((resolve) => {
|
|
391
|
-
timer = setTimeout(() => resolve({ timedOut: true }),
|
|
966
|
+
timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
|
|
392
967
|
});
|
|
393
968
|
try {
|
|
394
|
-
const
|
|
969
|
+
const readPromise = read();
|
|
395
970
|
// The losing branch keeps running; ensure a late rejection can't surface
|
|
396
971
|
// as an unhandled rejection once we've stopped awaiting it.
|
|
397
|
-
|
|
972
|
+
readPromise.catch(() => {});
|
|
398
973
|
const result = await Promise.race([
|
|
399
|
-
|
|
400
|
-
timedOut: false as const,
|
|
401
|
-
response,
|
|
402
|
-
})),
|
|
974
|
+
readPromise.then((value) => ({ timedOut: false as const, value })),
|
|
403
975
|
timeout,
|
|
404
976
|
]);
|
|
405
977
|
if (result.timedOut) {
|
|
406
978
|
console.warn(
|
|
407
|
-
`[CFCacheStore]
|
|
979
|
+
`[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
|
|
408
980
|
);
|
|
409
|
-
return undefined;
|
|
981
|
+
return { value: undefined, timedOut: true };
|
|
410
982
|
}
|
|
411
|
-
return result.
|
|
983
|
+
return { value: result.value, timedOut: false };
|
|
412
984
|
} finally {
|
|
413
985
|
if (timer) clearTimeout(timer);
|
|
414
986
|
}
|
|
415
987
|
}
|
|
416
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Read from the L1 edge cache under the edgeLookupTimeoutMs budget. A `match`
|
|
991
|
+
* slower than the budget is abandoned and reported as a miss
|
|
992
|
+
* (`{ response: undefined, timedOut: true }`) so a degraded colo cannot stall
|
|
993
|
+
* the request; callers fall through to their normal miss path (L2/KV or
|
|
994
|
+
* render). The `timedOut` flag lets callers distinguish an abandoned slow
|
|
995
|
+
* match from a genuine miss for debug reporting; `error` is set when the
|
|
996
|
+
* `match` itself rejected (a transient L1 infra error) so the caller can
|
|
997
|
+
* report it as cache-read while still degrading to L2/KV -- distinct from a
|
|
998
|
+
* genuine miss (no entry), which sets neither flag.
|
|
999
|
+
* @internal
|
|
1000
|
+
*/
|
|
1001
|
+
private async matchWithTimeout(
|
|
1002
|
+
cache: Cache,
|
|
1003
|
+
request: Request,
|
|
1004
|
+
): Promise<{
|
|
1005
|
+
response: Response | undefined;
|
|
1006
|
+
timedOut: boolean;
|
|
1007
|
+
error?: unknown;
|
|
1008
|
+
}> {
|
|
1009
|
+
let matchError: unknown;
|
|
1010
|
+
const { value, timedOut } = await this.readWithTimeout(
|
|
1011
|
+
// A fast match rejection is caught at the thunk and reported as a miss
|
|
1012
|
+
// (response undefined), so the caller falls through to L2/KV rather than
|
|
1013
|
+
// escaping to the outer catch -- symmetric with the body-read thunk. The
|
|
1014
|
+
// error is captured (not swallowed) so the caller can surface it via
|
|
1015
|
+
// onError as a cache-read degradation.
|
|
1016
|
+
() =>
|
|
1017
|
+
cache.match(request).catch((e) => {
|
|
1018
|
+
matchError = e;
|
|
1019
|
+
return undefined;
|
|
1020
|
+
}),
|
|
1021
|
+
this.edgeLookupTimeoutMs,
|
|
1022
|
+
"edge cache lookup",
|
|
1023
|
+
);
|
|
1024
|
+
return { response: value, timedOut, error: matchError };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Read and JSON-parse a matched L1 Response's body under the edgeReadTimeoutMs
|
|
1029
|
+
* budget. CF resolves `match()` with a lazily-streamed body, so the latency
|
|
1030
|
+
* tail surfaces here -- after matchWithTimeout has already passed -- not in the
|
|
1031
|
+
* match itself. On timeout `undefined` is returned so the caller falls through
|
|
1032
|
+
* to L2/KV or render.
|
|
1033
|
+
* @internal
|
|
1034
|
+
*/
|
|
1035
|
+
private async readJsonWithTimeout<T>(
|
|
1036
|
+
response: Response,
|
|
1037
|
+
): Promise<{ value: T | undefined; errored: boolean; error?: unknown }> {
|
|
1038
|
+
// A FAST json() rejection (a corrupt body, or a foreign 200 non-JSON
|
|
1039
|
+
// response that collided on this key) is caught at the thunk and turned into
|
|
1040
|
+
// a miss, so the caller falls through to L2/KV exactly like a body-timeout
|
|
1041
|
+
// -- instead of escaping to get()/getItem()'s outer catch, which returns
|
|
1042
|
+
// null WITHOUT ever consulting KV. The catch lives here, not in
|
|
1043
|
+
// readWithTimeout, so the L2/KV tier keeps propagating a genuine kv.get
|
|
1044
|
+
// rejection to its own error sink. The `errored` flag lets the caller emit a
|
|
1045
|
+
// distinct "body-error" debug outcome rather than masquerading as a timeout.
|
|
1046
|
+
// On a TIMEOUT the json() promise is still pending, so the catch has not
|
|
1047
|
+
// fired: errored stays false and the outcome is correctly a body-timeout. A
|
|
1048
|
+
// late rejection after the timeout only mutates the closure flag, which the
|
|
1049
|
+
// already-returned object no longer reads.
|
|
1050
|
+
let errored = false;
|
|
1051
|
+
let error: unknown;
|
|
1052
|
+
const { value } = await this.readWithTimeout<T | undefined>(
|
|
1053
|
+
() =>
|
|
1054
|
+
(response.json() as Promise<T>).catch((e) => {
|
|
1055
|
+
errored = true;
|
|
1056
|
+
error = e;
|
|
1057
|
+
return undefined;
|
|
1058
|
+
}),
|
|
1059
|
+
this.edgeReadTimeoutMs,
|
|
1060
|
+
"edge cache body read",
|
|
1061
|
+
);
|
|
1062
|
+
return { value, errored, error };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Self-heal a corrupt L1 entry, then return the fall-through result. Reports
|
|
1067
|
+
* the corruption as cache-corrupt (so an onError consumer sees it distinctly
|
|
1068
|
+
* from a transient outage), runs the caller's L2/KV fall-through, and evicts
|
|
1069
|
+
* the faulty per-colo entry ONLY when that fall-through found no good copy.
|
|
1070
|
+
*
|
|
1071
|
+
* The conditional evict is the load-bearing detail: when KV DOES serve a copy,
|
|
1072
|
+
* kvGet* has already scheduled a same-key promote (`cache.put`); an eager
|
|
1073
|
+
* `cache.delete` here would race that put with no CF Cache API ordering
|
|
1074
|
+
* guarantee and could clobber the freshly-restored entry. So in that case we
|
|
1075
|
+
* lean on #558's heal-by-overwrite (the non-suppressed fall-through promotes /
|
|
1076
|
+
* a fresh render re-`set`s over the bad entry) and skip the delete. Only when
|
|
1077
|
+
* this request's fall-through found no copy (=== null) is the eager evict
|
|
1078
|
+
* scheduled -- useful then, since nothing else will overwrite the poison entry.
|
|
1079
|
+
* A null fall-through can also be a KV-read TIMEOUT rather than a genuine miss:
|
|
1080
|
+
* a concurrent request that read KV successfully may be promoting the same key,
|
|
1081
|
+
* and this evict could race it. That is benign -- the worst case is one wasted
|
|
1082
|
+
* colo-local promote, never a wrong served value, and the next read self-heals
|
|
1083
|
+
* -- so we accept it rather than suppressing the evict on a timeout (which
|
|
1084
|
+
* would strand the poison entry when KV really is empty). The evict is
|
|
1085
|
+
* non-blocking (waitUntil) so it never adds latency to the degraded read.
|
|
1086
|
+
* @internal
|
|
1087
|
+
*/
|
|
1088
|
+
private async healCorruptL1<T>(
|
|
1089
|
+
cache: Cache,
|
|
1090
|
+
request: Request,
|
|
1091
|
+
error: unknown,
|
|
1092
|
+
label: string,
|
|
1093
|
+
fallThrough: () => Promise<T | null>,
|
|
1094
|
+
): Promise<T | null> {
|
|
1095
|
+
reportCacheError(
|
|
1096
|
+
error ?? new Error("corrupt/partial L1 body"),
|
|
1097
|
+
"cache-corrupt",
|
|
1098
|
+
`[CFCacheStore] ${label}: corrupt L1 body`,
|
|
1099
|
+
);
|
|
1100
|
+
const result = await fallThrough();
|
|
1101
|
+
if (result === null) {
|
|
1102
|
+
const evict = (): Promise<void> =>
|
|
1103
|
+
reportingAsync(
|
|
1104
|
+
() => cache.delete(request),
|
|
1105
|
+
"cache-delete",
|
|
1106
|
+
`[CFCacheStore] ${label}: evict corrupt L1`,
|
|
1107
|
+
);
|
|
1108
|
+
if (this.waitUntil) this.waitUntil(evict);
|
|
1109
|
+
else void evict();
|
|
1110
|
+
}
|
|
1111
|
+
return result;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Re-put a stale L1 entry marked REVALIDATING, so concurrent requests serve it
|
|
1116
|
+
* without each triggering a revalidation. Shared by get()/getItem().
|
|
1117
|
+
*
|
|
1118
|
+
* The write is NON-BLOCKING (waitUntil) and best-effort by design:
|
|
1119
|
+
* - It runs in waitUntil, so it never adds the put latency to the served stale
|
|
1120
|
+
* read and a put failure can never turn that good read into a miss. The put
|
|
1121
|
+
* is still initiated synchronously (this.waitUntil invokes its callback
|
|
1122
|
+
* immediately), so concurrent readers see the marker land at the same time an
|
|
1123
|
+
* awaited write would -- awaiting only blocks the current request.
|
|
1124
|
+
* - The background revalidation's fresh set() is gated behind a full re-render,
|
|
1125
|
+
* so it lands well after this put; a stale-clobbers-fresh race would require
|
|
1126
|
+
* this single put to be slower than that entire render+set, and self-heals
|
|
1127
|
+
* within MAX_REVALIDATION_INTERVAL.
|
|
1128
|
+
*
|
|
1129
|
+
* Cache-Control is recomputed to the REMAINING ttl from the stored hard-expiry
|
|
1130
|
+
* deadline (see remainingCacheControl), not copied from the original
|
|
1131
|
+
* full-window header -- copying it would restart CF retention on every re-arm
|
|
1132
|
+
* and pin a perpetually-failing entry past hard-expiry. A legacy/tampered entry
|
|
1133
|
+
* without a valid deadline floors to max-age=1 and self-heals via KV.
|
|
1134
|
+
* @internal
|
|
1135
|
+
*/
|
|
1136
|
+
private markRevalidating(
|
|
1137
|
+
cache: Cache,
|
|
1138
|
+
request: Request,
|
|
1139
|
+
sourceHeaders: Headers,
|
|
1140
|
+
status: number,
|
|
1141
|
+
body: string,
|
|
1142
|
+
): void {
|
|
1143
|
+
const reputNow = Date.now();
|
|
1144
|
+
const headers = new Headers(sourceHeaders);
|
|
1145
|
+
headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
|
|
1146
|
+
headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
|
|
1147
|
+
headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
|
|
1148
|
+
const markerResponse = new Response(body, { status, headers });
|
|
1149
|
+
const write = async (): Promise<void> => {
|
|
1150
|
+
try {
|
|
1151
|
+
await cache.put(request, markerResponse);
|
|
1152
|
+
} catch {
|
|
1153
|
+
// Best-effort: a failed marker write must not affect the served read;
|
|
1154
|
+
// the entry simply re-arms on the next stale read.
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
if (this.waitUntil) this.waitUntil(write);
|
|
1158
|
+
else void write();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
417
1161
|
// ============================================================================
|
|
418
1162
|
// Segment Cache Methods
|
|
419
1163
|
// ============================================================================
|
|
420
1164
|
|
|
1165
|
+
/**
|
|
1166
|
+
* Guard the segment tier against a `keyGenerator` that returns a key colliding
|
|
1167
|
+
* with a reserved tag-marker namespace: `__tag__/` (the KV marker key) or
|
|
1168
|
+
* `__tagmarker__/` (the L1 Cache API marker request). The item/doc tiers are
|
|
1169
|
+
* internally prefixed (`fn:`/`doc:`) so only the bare segment key can collide;
|
|
1170
|
+
* a collision would let a segment write clobber - or a segment read/delete
|
|
1171
|
+
* evict - a live tag marker, silently breaking invalidation. Report loudly
|
|
1172
|
+
* (so a misconfigured keyGenerator surfaces immediately) and treat the segment
|
|
1173
|
+
* operation as a miss/no-op rather than corrupting the marker namespace.
|
|
1174
|
+
* @internal
|
|
1175
|
+
*/
|
|
1176
|
+
private isReservedSegmentKey(
|
|
1177
|
+
key: string,
|
|
1178
|
+
category: CacheErrorCategory,
|
|
1179
|
+
): boolean {
|
|
1180
|
+
const reserved = key.startsWith(TAG_MARKER_PREFIX)
|
|
1181
|
+
? TAG_MARKER_PREFIX
|
|
1182
|
+
: key.startsWith(TAG_MARKER_CACHE_PREFIX)
|
|
1183
|
+
? TAG_MARKER_CACHE_PREFIX
|
|
1184
|
+
: null;
|
|
1185
|
+
if (!reserved) return false;
|
|
1186
|
+
reportCacheError(
|
|
1187
|
+
new Error(
|
|
1188
|
+
`segment key "${key}" collides with the reserved "${reserved}" ` +
|
|
1189
|
+
`tag-marker namespace; the operation is ignored. Fix the store ` +
|
|
1190
|
+
`keyGenerator so it does not produce keys with this prefix.`,
|
|
1191
|
+
),
|
|
1192
|
+
category,
|
|
1193
|
+
"[CFCacheStore] reserved key",
|
|
1194
|
+
);
|
|
1195
|
+
return true;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
421
1198
|
/**
|
|
422
1199
|
* Get cached entry data by key.
|
|
423
1200
|
*
|
|
@@ -430,48 +1207,219 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
430
1207
|
* KV hits are promoted to L1 in the background.
|
|
431
1208
|
*/
|
|
432
1209
|
async get(key: string): Promise<CacheGetResult | null> {
|
|
1210
|
+
if (this.isReservedSegmentKey(key, "cache-read")) return null;
|
|
433
1211
|
try {
|
|
434
1212
|
const cache = await this.getCache();
|
|
435
1213
|
const request = this.keyToRequest(key);
|
|
436
|
-
const
|
|
1214
|
+
const matchStart = Date.now();
|
|
1215
|
+
const {
|
|
1216
|
+
response,
|
|
1217
|
+
timedOut,
|
|
1218
|
+
error: matchError,
|
|
1219
|
+
} = await this.matchWithTimeout(cache, request);
|
|
1220
|
+
const matchMs = Date.now() - matchStart;
|
|
437
1221
|
|
|
438
1222
|
if (!response) {
|
|
1223
|
+
// A transient L1 match error (matchError set) is reported as cache-read
|
|
1224
|
+
// but, like a genuine miss or an abandoned slow match (timedOut), still
|
|
1225
|
+
// degrades to L2/KV rather than failing the read.
|
|
1226
|
+
if (matchError)
|
|
1227
|
+
reportCacheError(
|
|
1228
|
+
matchError,
|
|
1229
|
+
"cache-read",
|
|
1230
|
+
"[CFCacheStore] get L1 match",
|
|
1231
|
+
);
|
|
1232
|
+
if (this.debug)
|
|
1233
|
+
this.emitDebug({
|
|
1234
|
+
op: "get",
|
|
1235
|
+
key,
|
|
1236
|
+
// A match REJECTION (matchError) is distinct from a genuine absence:
|
|
1237
|
+
// surface it as match-error so debug agrees with the cache-read
|
|
1238
|
+
// already routed to onError, instead of masquerading as l1-miss.
|
|
1239
|
+
outcome: matchError
|
|
1240
|
+
? "match-error"
|
|
1241
|
+
: timedOut
|
|
1242
|
+
? "match-timeout"
|
|
1243
|
+
: "l1-miss",
|
|
1244
|
+
matchMs,
|
|
1245
|
+
});
|
|
439
1246
|
return this.kvGetSegment(key);
|
|
440
1247
|
}
|
|
441
1248
|
|
|
1249
|
+
// A non-200 entry (a cached error response, or a foreign response that
|
|
1250
|
+
// landed on this key) is not valid segment data; treat it as a miss
|
|
1251
|
+
// rather than JSON-parsing garbage and serving it as a hit.
|
|
1252
|
+
if (response.status !== 200) {
|
|
1253
|
+
if (this.debug)
|
|
1254
|
+
this.emitDebug({
|
|
1255
|
+
op: "get",
|
|
1256
|
+
key,
|
|
1257
|
+
outcome: "non-200",
|
|
1258
|
+
status: response.status,
|
|
1259
|
+
matchMs,
|
|
1260
|
+
});
|
|
1261
|
+
// Degraded fall-through: suppress revalidation so a broken L1 entry hit
|
|
1262
|
+
// concurrently serves KV-stale, not a herd. See kvGetSegment.
|
|
1263
|
+
return this.kvGetSegment(key, { suppressRevalidate: true });
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Tag invalidation: an entry whose tags were invalidated after it was
|
|
1267
|
+
// cached is treated as a miss, so the next render re-populates it. We
|
|
1268
|
+
// return null (re-render locally) rather than falling through to KV. In
|
|
1269
|
+
// the common case the L1 entry and its KV twin were written together with
|
|
1270
|
+
// the same taggedAt, so kvGetSegment's own tag check would miss too and a
|
|
1271
|
+
// fall-through is pure cost. The tiers CAN diverge -- another colo may have
|
|
1272
|
+
// already re-rendered and written a fresher KV envelope -- in which case a
|
|
1273
|
+
// fall-through could serve that copy instead of re-rendering here.
|
|
1274
|
+
// Capturing that cross-colo optimization is a deferred follow-up, not a
|
|
1275
|
+
// correctness gap: this colo's next read after its own re-render self-heals.
|
|
1276
|
+
const tagInfo = this.readTagInfo(response.headers);
|
|
1277
|
+
// Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
|
|
1278
|
+
// when debug is on, so the hot path pays nothing. It is the serial read
|
|
1279
|
+
// that sits between matchMs and bodyReadMs for a tagged entry.
|
|
1280
|
+
const markerStart = this.debug ? Date.now() : 0;
|
|
1281
|
+
const invalidated = await this.isGloballyInvalidated(
|
|
1282
|
+
tagInfo.tags,
|
|
1283
|
+
tagInfo.taggedAt,
|
|
1284
|
+
);
|
|
1285
|
+
const markerMs = this.debug ? Date.now() - markerStart : undefined;
|
|
1286
|
+
if (invalidated) {
|
|
1287
|
+
if (this.debug)
|
|
1288
|
+
this.emitDebug({
|
|
1289
|
+
op: "get",
|
|
1290
|
+
key,
|
|
1291
|
+
outcome: "tag-invalidated",
|
|
1292
|
+
status: response.status,
|
|
1293
|
+
matchMs,
|
|
1294
|
+
markerMs,
|
|
1295
|
+
});
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
442
1299
|
// Read status headers
|
|
443
1300
|
const status = response.headers.get(CACHE_STATUS_HEADER);
|
|
444
|
-
const age = Number(response.headers.get("age") ?? "0");
|
|
445
1301
|
const staleAt = Number(
|
|
446
1302
|
response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
|
|
447
1303
|
);
|
|
1304
|
+
const revalidatingAt = Number(
|
|
1305
|
+
response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
|
|
1306
|
+
);
|
|
448
1307
|
|
|
449
|
-
const
|
|
1308
|
+
const now = Date.now();
|
|
1309
|
+
const isStale = staleAt > 0 && now > staleAt;
|
|
1310
|
+
// Recency comes from our explicit revalidating-at stamp, not CF's `Age`
|
|
1311
|
+
// header (see CACHE_REVALIDATING_AT_HEADER). An absent/zero stamp counts
|
|
1312
|
+
// as "not recent" so a dropped revalidation re-arms instead of pinning.
|
|
450
1313
|
const isRevalidating =
|
|
451
|
-
status === "REVALIDATING" &&
|
|
1314
|
+
status === "REVALIDATING" &&
|
|
1315
|
+
revalidatingAt > 0 &&
|
|
1316
|
+
now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
|
|
1317
|
+
|
|
1318
|
+
// Single emitter for the post-header L1 outcomes. Undefined (so the event
|
|
1319
|
+
// object is never allocated) when debug is off; the informational-only
|
|
1320
|
+
// `age` header is read lazily inside for the same reason.
|
|
1321
|
+
const debugRead = this.debug
|
|
1322
|
+
? (
|
|
1323
|
+
outcome: CFCacheReadDebugEvent["outcome"],
|
|
1324
|
+
bodyReadMs: number,
|
|
1325
|
+
shouldRevalidate?: boolean,
|
|
1326
|
+
) =>
|
|
1327
|
+
this.emitDebug({
|
|
1328
|
+
op: "get",
|
|
1329
|
+
key,
|
|
1330
|
+
outcome,
|
|
1331
|
+
status: response.status,
|
|
1332
|
+
cacheStatus: status,
|
|
1333
|
+
staleAt,
|
|
1334
|
+
revalidatingAt,
|
|
1335
|
+
ageHeader: response.headers.get("age"),
|
|
1336
|
+
isStale,
|
|
1337
|
+
isRevalidating,
|
|
1338
|
+
shouldRevalidate,
|
|
1339
|
+
matchMs,
|
|
1340
|
+
markerMs,
|
|
1341
|
+
bodyReadMs,
|
|
1342
|
+
})
|
|
1343
|
+
: undefined;
|
|
452
1344
|
|
|
453
1345
|
// Case 1: Fresh or already being revalidated - just return data
|
|
454
1346
|
if (!isStale || isRevalidating) {
|
|
455
|
-
const
|
|
1347
|
+
const bodyStart = Date.now();
|
|
1348
|
+
const {
|
|
1349
|
+
value: data,
|
|
1350
|
+
errored,
|
|
1351
|
+
error,
|
|
1352
|
+
} = await this.readJsonWithTimeout<CachedEntryData>(response);
|
|
1353
|
+
const bodyReadMs = Date.now() - bodyStart;
|
|
1354
|
+
if (data === undefined) {
|
|
1355
|
+
debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
|
|
1356
|
+
// A body-ERROR (corrupt/foreign body) self-heals via healCorruptL1:
|
|
1357
|
+
// report cache-corrupt, fall through to L2/KV (which overwrites the
|
|
1358
|
+
// bad entry), and evict only if KV had no good copy to promote. A
|
|
1359
|
+
// body-TIMEOUT is a degraded read of a likely-valid entry: leave it
|
|
1360
|
+
// intact and suppress revalidation so a stalling colo cannot herd.
|
|
1361
|
+
if (errored)
|
|
1362
|
+
return this.healCorruptL1(cache, request, error, "get", () =>
|
|
1363
|
+
this.kvGetSegment(key, { suppressRevalidate: false }),
|
|
1364
|
+
);
|
|
1365
|
+
return this.kvGetSegment(key, { suppressRevalidate: true });
|
|
1366
|
+
}
|
|
1367
|
+
debugRead?.(
|
|
1368
|
+
isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
|
|
1369
|
+
bodyReadMs,
|
|
1370
|
+
false,
|
|
1371
|
+
);
|
|
456
1372
|
return { data, shouldRevalidate: false };
|
|
457
1373
|
}
|
|
458
1374
|
|
|
459
|
-
// Case 2: Stale and needs revalidation
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
1375
|
+
// Case 2: Stale and needs revalidation.
|
|
1376
|
+
// Read the body under the edge-read budget BEFORE writing the REVALIDATING
|
|
1377
|
+
// marker. CF can resolve match() fast but stall the body stream; the prior
|
|
1378
|
+
// approach teed the stream and awaited cache.put(b1) first, which blocked
|
|
1379
|
+
// on that same stalled stream so the read budget could never fire on a
|
|
1380
|
+
// stale hit. Reading first bounds the stall and lets us skip marking an
|
|
1381
|
+
// entry we could not even read.
|
|
1382
|
+
const bodyStart = Date.now();
|
|
1383
|
+
const {
|
|
1384
|
+
value: data,
|
|
1385
|
+
errored,
|
|
1386
|
+
error,
|
|
1387
|
+
} = await this.readJsonWithTimeout<CachedEntryData>(response);
|
|
1388
|
+
const bodyReadMs = Date.now() - bodyStart;
|
|
1389
|
+
if (data === undefined) {
|
|
1390
|
+
debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
|
|
1391
|
+
// Heal + conditionally evict a body-error, suppress a body-timeout; see
|
|
1392
|
+
// Case 1.
|
|
1393
|
+
if (errored)
|
|
1394
|
+
return this.healCorruptL1(
|
|
1395
|
+
cache,
|
|
1396
|
+
request,
|
|
1397
|
+
error,
|
|
1398
|
+
"get(revalidating)",
|
|
1399
|
+
() => this.kvGetSegment(key, { suppressRevalidate: false }),
|
|
1400
|
+
);
|
|
1401
|
+
return this.kvGetSegment(key, { suppressRevalidate: true });
|
|
1402
|
+
}
|
|
464
1403
|
|
|
465
|
-
//
|
|
466
|
-
|
|
1404
|
+
// Mark REVALIDATING so concurrent requests don't all revalidate, then
|
|
1405
|
+
// return the stale data. The marker write is non-blocking and best-effort
|
|
1406
|
+
// (see markRevalidating) -- it must not add latency to, or fail, the served
|
|
1407
|
+
// stale read.
|
|
1408
|
+
this.markRevalidating(
|
|
1409
|
+
cache,
|
|
467
1410
|
request,
|
|
468
|
-
|
|
1411
|
+
response.headers,
|
|
1412
|
+
response.status,
|
|
1413
|
+
JSON.stringify(data),
|
|
469
1414
|
);
|
|
470
1415
|
|
|
471
|
-
|
|
1416
|
+
debugRead?.("l1-stale-revalidate", bodyReadMs, true);
|
|
472
1417
|
return { data, shouldRevalidate: true };
|
|
473
1418
|
} catch (error) {
|
|
474
|
-
|
|
1419
|
+
// reportCacheError logs and routes to onError (cache-read); the debug
|
|
1420
|
+
// emit is the separate wrangler-tail signal. Keep both observability paths.
|
|
1421
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] get");
|
|
1422
|
+
if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
|
|
475
1423
|
return null;
|
|
476
1424
|
}
|
|
477
1425
|
}
|
|
@@ -487,6 +1435,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
487
1435
|
ttl: number,
|
|
488
1436
|
swr?: number,
|
|
489
1437
|
): Promise<void> {
|
|
1438
|
+
if (this.isReservedSegmentKey(key, "cache-write")) return;
|
|
490
1439
|
try {
|
|
491
1440
|
const cache = await this.getCache();
|
|
492
1441
|
const request = this.keyToRequest(key);
|
|
@@ -496,32 +1445,57 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
496
1445
|
const totalTtl = ttl + swrWindow;
|
|
497
1446
|
const staleAt = Date.now() + ttl * 1000;
|
|
498
1447
|
|
|
499
|
-
|
|
1448
|
+
// Stamp the tag timestamp at write time and carry it (with the tags)
|
|
1449
|
+
// into both the L1 body and the KV envelope so reads can run the
|
|
1450
|
+
// invalidation check.
|
|
1451
|
+
const taggedAt =
|
|
1452
|
+
Array.isArray(data.tags) && data.tags.length > 0
|
|
1453
|
+
? Date.now()
|
|
1454
|
+
: undefined;
|
|
1455
|
+
const dataToStore: CachedEntryData = taggedAt
|
|
1456
|
+
? { ...data, taggedAt }
|
|
1457
|
+
: data;
|
|
1458
|
+
|
|
1459
|
+
const body = JSON.stringify(dataToStore);
|
|
500
1460
|
const response = new Response(body, {
|
|
501
1461
|
headers: {
|
|
502
1462
|
"Content-Type": "application/json",
|
|
503
1463
|
"Cache-Control": `public, max-age=${totalTtl}`,
|
|
504
1464
|
[CACHE_STALE_AT_HEADER]: String(staleAt),
|
|
1465
|
+
// Absolute hard-expiry deadline so a stale-path re-put can recompute a
|
|
1466
|
+
// shrinking max-age instead of restarting retention (see
|
|
1467
|
+
// remainingCacheControl / CACHE_EXPIRES_AT_HEADER).
|
|
1468
|
+
[CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
|
|
505
1469
|
[CACHE_STATUS_HEADER]: "HIT",
|
|
1470
|
+
...this.tagHeaderEntries(dataToStore.tags, taggedAt),
|
|
506
1471
|
},
|
|
507
1472
|
});
|
|
508
1473
|
|
|
509
1474
|
const putPromise = cache.put(request, response);
|
|
510
1475
|
|
|
511
1476
|
if (this.waitUntil) {
|
|
512
|
-
// Non-blocking write
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
1477
|
+
// Non-blocking write. These store-level background tasks intentionally
|
|
1478
|
+
// omit the reportingAsync ctx argument: the store is a request-agnostic
|
|
1479
|
+
// singleton and this.waitUntil is the execution context's, not a single
|
|
1480
|
+
// request's, so a failure is reported console-loud only (it cannot be
|
|
1481
|
+
// attributed to one request's onError). The request-scoped tag verbs
|
|
1482
|
+
// (revalidateTag / stale-revalidation) DO thread their captured ctx.
|
|
1483
|
+
this.waitUntil(() =>
|
|
1484
|
+
reportingAsync(
|
|
1485
|
+
() => putPromise,
|
|
1486
|
+
"cache-write",
|
|
1487
|
+
"[CFCacheStore] L1 write",
|
|
1488
|
+
),
|
|
1489
|
+
);
|
|
516
1490
|
} else {
|
|
517
1491
|
// Blocking fallback
|
|
518
1492
|
await putPromise;
|
|
519
1493
|
}
|
|
520
1494
|
|
|
521
1495
|
// L2: persist to KV
|
|
522
|
-
this.kvSetSegment(key,
|
|
1496
|
+
this.kvSetSegment(key, dataToStore, staleAt, totalTtl, swrWindow);
|
|
523
1497
|
} catch (error) {
|
|
524
|
-
|
|
1498
|
+
reportCacheError(error, "cache-write", "[CFCacheStore] set");
|
|
525
1499
|
}
|
|
526
1500
|
}
|
|
527
1501
|
|
|
@@ -529,6 +1503,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
529
1503
|
* Delete a cached entry from L1 and L2.
|
|
530
1504
|
*/
|
|
531
1505
|
async delete(key: string): Promise<boolean> {
|
|
1506
|
+
if (this.isReservedSegmentKey(key, "cache-delete")) return false;
|
|
532
1507
|
try {
|
|
533
1508
|
const cache = await this.getCache();
|
|
534
1509
|
const result = await cache.delete(this.keyToRequest(key));
|
|
@@ -536,18 +1511,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
536
1511
|
// L2: delete from KV
|
|
537
1512
|
if (this.kv && this.waitUntil) {
|
|
538
1513
|
const kvKey = this.toKVKey(key);
|
|
539
|
-
this.waitUntil(
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
1514
|
+
this.waitUntil(() =>
|
|
1515
|
+
reportingAsync(
|
|
1516
|
+
() => this.kv!.delete(kvKey),
|
|
1517
|
+
"cache-delete",
|
|
1518
|
+
"[CFCacheStore] delete L2",
|
|
1519
|
+
),
|
|
1520
|
+
);
|
|
546
1521
|
}
|
|
547
1522
|
|
|
548
1523
|
return result;
|
|
549
1524
|
} catch (error) {
|
|
550
|
-
|
|
1525
|
+
reportCacheError(error, "cache-delete", "[CFCacheStore] delete");
|
|
551
1526
|
return false;
|
|
552
1527
|
}
|
|
553
1528
|
}
|
|
@@ -567,22 +1542,52 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
567
1542
|
try {
|
|
568
1543
|
const cache = await this.getCache();
|
|
569
1544
|
const request = this.keyToRequest(`doc:${key}`);
|
|
570
|
-
|
|
1545
|
+
// The document path is outside the debug surface (op is only get/getItem),
|
|
1546
|
+
// so the match-timeout flag is not surfaced as an event here -- though
|
|
1547
|
+
// matchWithTimeout still warns on a slow match. A miss or timeout falls
|
|
1548
|
+
// through to the KV document path and then render.
|
|
1549
|
+
const { response, error: matchError } = await this.matchWithTimeout(
|
|
1550
|
+
cache,
|
|
1551
|
+
request,
|
|
1552
|
+
);
|
|
571
1553
|
|
|
572
1554
|
if (!response || response.status !== 200) {
|
|
1555
|
+
// A transient L1 match rejection (matchError set; only ever set when
|
|
1556
|
+
// response is undefined) is surfaced as cache-read before degrading to
|
|
1557
|
+
// L2/KV -- matching get()/getItem(). A genuine miss or a non-200 hit
|
|
1558
|
+
// carries no matchError and reports nothing.
|
|
1559
|
+
if (matchError)
|
|
1560
|
+
reportCacheError(
|
|
1561
|
+
matchError,
|
|
1562
|
+
"cache-read",
|
|
1563
|
+
"[CFCacheStore] getResponse L1 match",
|
|
1564
|
+
);
|
|
573
1565
|
return this.kvGetResponse(key);
|
|
574
1566
|
}
|
|
575
1567
|
|
|
1568
|
+
// Tag invalidation check (treat invalidated entry as a miss).
|
|
1569
|
+
const tagInfo = this.readTagInfo(response.headers);
|
|
1570
|
+
if (await this.isGloballyInvalidated(tagInfo.tags, tagInfo.taggedAt)) {
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
576
1574
|
// Check staleness
|
|
577
1575
|
const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
|
|
578
1576
|
const isStale = staleAt > 0 && Date.now() > staleAt;
|
|
579
1577
|
|
|
1578
|
+
// L1 document bodies are streamed through verbatim - unlike the segment/
|
|
1579
|
+
// item tiers (which JSON-parse and so structurally detect corruption) and
|
|
1580
|
+
// the KV doc tier (validated in kvGetResponse, KV being the real partial-
|
|
1581
|
+
// read vector). Integrity here relies on the Cache API: cache.put stores a
|
|
1582
|
+
// response atomically or fails, so a truncated body is not served back. We
|
|
1583
|
+
// deliberately do NOT buffer+hash the body to re-verify it: that would
|
|
1584
|
+
// defeat streaming the document and add a full read to every cache hit.
|
|
580
1585
|
return {
|
|
581
1586
|
response: this.toClientResponse(response),
|
|
582
1587
|
shouldRevalidate: isStale,
|
|
583
1588
|
};
|
|
584
1589
|
} catch (error) {
|
|
585
|
-
|
|
1590
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
|
|
586
1591
|
return null;
|
|
587
1592
|
}
|
|
588
1593
|
}
|
|
@@ -604,6 +1609,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
604
1609
|
headers.delete(CACHE_ORIG_CC_HEADER);
|
|
605
1610
|
headers.delete(CACHE_STALE_AT_HEADER);
|
|
606
1611
|
headers.delete(CACHE_STATUS_HEADER);
|
|
1612
|
+
headers.delete(CACHE_TAGS_HEADER);
|
|
1613
|
+
headers.delete(CACHE_TAGGED_AT_HEADER);
|
|
607
1614
|
return new Response(response.body, {
|
|
608
1615
|
status: response.status,
|
|
609
1616
|
statusText: response.statusText,
|
|
@@ -620,6 +1627,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
620
1627
|
response: Response,
|
|
621
1628
|
ttl: number,
|
|
622
1629
|
swr?: number,
|
|
1630
|
+
tags?: string[],
|
|
623
1631
|
): Promise<void> {
|
|
624
1632
|
try {
|
|
625
1633
|
const cache = await this.getCache();
|
|
@@ -629,6 +1637,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
629
1637
|
const swrWindow = resolveSwrWindow(swr, this.defaults);
|
|
630
1638
|
const totalTtl = ttl + swrWindow;
|
|
631
1639
|
const staleAt = Date.now() + ttl * 1000;
|
|
1640
|
+
const taggedAt =
|
|
1641
|
+
Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
|
|
632
1642
|
|
|
633
1643
|
// Clone body for potential KV write before consuming it for L1
|
|
634
1644
|
const [l1Body, kvBody] = this.kv
|
|
@@ -647,6 +1657,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
647
1657
|
}
|
|
648
1658
|
headers.set("Cache-Control", `public, max-age=${totalTtl}`);
|
|
649
1659
|
headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
|
|
1660
|
+
// Internal tag headers (stripped by toClientResponse before serving).
|
|
1661
|
+
const tagHeaders = this.tagHeaderEntries(tags, taggedAt);
|
|
1662
|
+
for (const [name, value] of Object.entries(tagHeaders)) {
|
|
1663
|
+
headers.set(name, value);
|
|
1664
|
+
}
|
|
650
1665
|
|
|
651
1666
|
const toCache = new Response(l1Body, {
|
|
652
1667
|
status: response.status,
|
|
@@ -658,9 +1673,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
658
1673
|
|
|
659
1674
|
if (this.waitUntil) {
|
|
660
1675
|
// Non-blocking write
|
|
661
|
-
this.waitUntil(
|
|
662
|
-
|
|
663
|
-
|
|
1676
|
+
this.waitUntil(() =>
|
|
1677
|
+
reportingAsync(
|
|
1678
|
+
() => putPromise,
|
|
1679
|
+
"cache-write",
|
|
1680
|
+
"[CFCacheStore] L1 write",
|
|
1681
|
+
),
|
|
1682
|
+
);
|
|
664
1683
|
} else {
|
|
665
1684
|
// Blocking fallback
|
|
666
1685
|
await putPromise;
|
|
@@ -677,26 +1696,30 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
677
1696
|
: new ArrayBuffer(0);
|
|
678
1697
|
const bodyBase64 = bufferToBase64(bodyBuf);
|
|
679
1698
|
|
|
680
|
-
this.waitUntil(
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1699
|
+
this.waitUntil(() =>
|
|
1700
|
+
reportingAsync(
|
|
1701
|
+
() => {
|
|
1702
|
+
const envelope: KVResponseEnvelope = {
|
|
1703
|
+
b: bodyBase64,
|
|
1704
|
+
st: response.status,
|
|
1705
|
+
stx: response.statusText,
|
|
1706
|
+
hd: headersArray,
|
|
1707
|
+
s: staleAt,
|
|
1708
|
+
e: staleAt + swrWindow * 1000,
|
|
1709
|
+
t: tags,
|
|
1710
|
+
ta: taggedAt,
|
|
1711
|
+
};
|
|
1712
|
+
return this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1713
|
+
expirationTtl: totalTtl,
|
|
1714
|
+
});
|
|
1715
|
+
},
|
|
1716
|
+
"cache-write",
|
|
1717
|
+
"[CFCacheStore] kvPutResponse",
|
|
1718
|
+
),
|
|
1719
|
+
);
|
|
697
1720
|
}
|
|
698
1721
|
} catch (error) {
|
|
699
|
-
|
|
1722
|
+
reportCacheError(error, "cache-write", "[CFCacheStore] putResponse");
|
|
700
1723
|
}
|
|
701
1724
|
}
|
|
702
1725
|
|
|
@@ -713,48 +1736,173 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
713
1736
|
try {
|
|
714
1737
|
const cache = await this.getCache();
|
|
715
1738
|
const request = this.keyToRequest(`fn:${key}`);
|
|
716
|
-
const
|
|
1739
|
+
const matchStart = Date.now();
|
|
1740
|
+
const {
|
|
1741
|
+
response,
|
|
1742
|
+
timedOut,
|
|
1743
|
+
error: matchError,
|
|
1744
|
+
} = await this.matchWithTimeout(cache, request);
|
|
1745
|
+
const matchMs = Date.now() - matchStart;
|
|
1746
|
+
|
|
1747
|
+
if (!response) {
|
|
1748
|
+
// Transient match error reported cache-read; still degrades to L2/KV.
|
|
1749
|
+
if (matchError)
|
|
1750
|
+
reportCacheError(
|
|
1751
|
+
matchError,
|
|
1752
|
+
"cache-read",
|
|
1753
|
+
"[CFCacheStore] getItem L1 match",
|
|
1754
|
+
);
|
|
1755
|
+
if (this.debug)
|
|
1756
|
+
this.emitDebug({
|
|
1757
|
+
op: "getItem",
|
|
1758
|
+
key,
|
|
1759
|
+
// match-error (rejection) vs l1-miss (absence); see get().
|
|
1760
|
+
outcome: matchError
|
|
1761
|
+
? "match-error"
|
|
1762
|
+
: timedOut
|
|
1763
|
+
? "match-timeout"
|
|
1764
|
+
: "l1-miss",
|
|
1765
|
+
matchMs,
|
|
1766
|
+
});
|
|
1767
|
+
return this.kvGetItem(key);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Non-200 entry is not a valid cached function result; treat as a miss.
|
|
1771
|
+
if (response.status !== 200) {
|
|
1772
|
+
if (this.debug)
|
|
1773
|
+
this.emitDebug({
|
|
1774
|
+
op: "getItem",
|
|
1775
|
+
key,
|
|
1776
|
+
outcome: "non-200",
|
|
1777
|
+
status: response.status,
|
|
1778
|
+
matchMs,
|
|
1779
|
+
});
|
|
1780
|
+
// Degraded fall-through: suppress revalidation so a broken L1 entry hit
|
|
1781
|
+
// concurrently serves KV-stale instead of spawning a herd (see get()).
|
|
1782
|
+
return this.kvGetItem(key, { suppressRevalidate: true });
|
|
1783
|
+
}
|
|
717
1784
|
|
|
718
|
-
|
|
1785
|
+
// Tag invalidation check (treat invalidated entry as a miss). Measure the
|
|
1786
|
+
// marker-resolution tail only under debug (see get()).
|
|
1787
|
+
const tagInfo = this.readTagInfo(response.headers);
|
|
1788
|
+
const markerStart = this.debug ? Date.now() : 0;
|
|
1789
|
+
const invalidated = await this.isGloballyInvalidated(
|
|
1790
|
+
tagInfo.tags,
|
|
1791
|
+
tagInfo.taggedAt,
|
|
1792
|
+
);
|
|
1793
|
+
const markerMs = this.debug ? Date.now() - markerStart : undefined;
|
|
1794
|
+
if (invalidated) {
|
|
1795
|
+
if (this.debug)
|
|
1796
|
+
this.emitDebug({
|
|
1797
|
+
op: "getItem",
|
|
1798
|
+
key,
|
|
1799
|
+
outcome: "tag-invalidated",
|
|
1800
|
+
status: response.status,
|
|
1801
|
+
matchMs,
|
|
1802
|
+
markerMs,
|
|
1803
|
+
});
|
|
1804
|
+
return null;
|
|
1805
|
+
}
|
|
719
1806
|
|
|
720
1807
|
const staleAt = Number(
|
|
721
1808
|
response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
|
|
722
1809
|
);
|
|
723
1810
|
const status = response.headers.get(CACHE_STATUS_HEADER);
|
|
724
|
-
const
|
|
1811
|
+
const revalidatingAt = Number(
|
|
1812
|
+
response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
|
|
1813
|
+
);
|
|
725
1814
|
|
|
726
|
-
const
|
|
1815
|
+
const now = Date.now();
|
|
1816
|
+
const isStale = staleAt > 0 && now > staleAt;
|
|
1817
|
+
// Recency from our explicit stamp, not CF's `Age` header (see get()).
|
|
727
1818
|
const isRevalidating =
|
|
728
|
-
status === "REVALIDATING" &&
|
|
729
|
-
|
|
730
|
-
|
|
1819
|
+
status === "REVALIDATING" &&
|
|
1820
|
+
revalidatingAt > 0 &&
|
|
1821
|
+
now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
|
|
1822
|
+
|
|
1823
|
+
// Single emitter for the post-header L1 outcomes (see get()). Undefined
|
|
1824
|
+
// when debug is off, so the event object is never allocated on the hot
|
|
1825
|
+
// path; the informational-only `age` header is read lazily inside.
|
|
1826
|
+
const debugRead = this.debug
|
|
1827
|
+
? (
|
|
1828
|
+
outcome: CFCacheReadDebugEvent["outcome"],
|
|
1829
|
+
bodyReadMs: number,
|
|
1830
|
+
shouldRevalidate?: boolean,
|
|
1831
|
+
) =>
|
|
1832
|
+
this.emitDebug({
|
|
1833
|
+
op: "getItem",
|
|
1834
|
+
key,
|
|
1835
|
+
outcome,
|
|
1836
|
+
status: response.status,
|
|
1837
|
+
cacheStatus: status,
|
|
1838
|
+
staleAt,
|
|
1839
|
+
revalidatingAt,
|
|
1840
|
+
ageHeader: response.headers.get("age"),
|
|
1841
|
+
isStale,
|
|
1842
|
+
isRevalidating,
|
|
1843
|
+
shouldRevalidate,
|
|
1844
|
+
matchMs,
|
|
1845
|
+
markerMs,
|
|
1846
|
+
bodyReadMs,
|
|
1847
|
+
})
|
|
1848
|
+
: undefined;
|
|
1849
|
+
|
|
1850
|
+
const bodyStart = Date.now();
|
|
1851
|
+
const {
|
|
1852
|
+
value: data,
|
|
1853
|
+
errored,
|
|
1854
|
+
error,
|
|
1855
|
+
} = await this.readJsonWithTimeout<{
|
|
731
1856
|
value: string;
|
|
732
1857
|
handles?: string;
|
|
733
|
-
};
|
|
1858
|
+
}>(response);
|
|
1859
|
+
const bodyReadMs = Date.now() - bodyStart;
|
|
1860
|
+
if (data === undefined) {
|
|
1861
|
+
debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
|
|
1862
|
+
// Heal + conditionally evict a body-error, suppress a body-timeout; see
|
|
1863
|
+
// get().
|
|
1864
|
+
if (errored)
|
|
1865
|
+
return this.healCorruptL1(cache, request, error, "getItem", () =>
|
|
1866
|
+
this.kvGetItem(key, { suppressRevalidate: false }),
|
|
1867
|
+
);
|
|
1868
|
+
return this.kvGetItem(key, { suppressRevalidate: true });
|
|
1869
|
+
}
|
|
734
1870
|
|
|
735
1871
|
if (!isStale || isRevalidating) {
|
|
1872
|
+
debugRead?.(
|
|
1873
|
+
isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
|
|
1874
|
+
bodyReadMs,
|
|
1875
|
+
false,
|
|
1876
|
+
);
|
|
736
1877
|
return {
|
|
737
1878
|
value: data.value,
|
|
738
1879
|
handles: data.handles,
|
|
739
1880
|
shouldRevalidate: false,
|
|
1881
|
+
tags: tagInfo.tags,
|
|
740
1882
|
};
|
|
741
1883
|
}
|
|
742
1884
|
|
|
743
|
-
// Stale and needs revalidation
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1885
|
+
// Stale and needs revalidation -- mark REVALIDATING (non-blocking,
|
|
1886
|
+
// best-effort, remaining-ttl) and return the stale value. See get() /
|
|
1887
|
+
// markRevalidating for the full rationale.
|
|
1888
|
+
this.markRevalidating(
|
|
1889
|
+
cache,
|
|
747
1890
|
request,
|
|
748
|
-
|
|
1891
|
+
response.headers,
|
|
1892
|
+
200,
|
|
1893
|
+
JSON.stringify(data),
|
|
749
1894
|
);
|
|
750
1895
|
|
|
1896
|
+
debugRead?.("l1-stale-revalidate", bodyReadMs, true);
|
|
751
1897
|
return {
|
|
752
1898
|
value: data.value,
|
|
753
1899
|
handles: data.handles,
|
|
754
1900
|
shouldRevalidate: true,
|
|
1901
|
+
tags: tagInfo.tags,
|
|
755
1902
|
};
|
|
756
1903
|
} catch (error) {
|
|
757
|
-
|
|
1904
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] getItem");
|
|
1905
|
+
if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
|
|
758
1906
|
return null;
|
|
759
1907
|
}
|
|
760
1908
|
}
|
|
@@ -777,22 +1925,33 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
777
1925
|
const totalTtl = ttl + swrWindow;
|
|
778
1926
|
const staleAt = Date.now() + ttl * 1000;
|
|
779
1927
|
|
|
1928
|
+
const tags = options?.tags;
|
|
1929
|
+
const taggedAt =
|
|
1930
|
+
Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
|
|
1931
|
+
|
|
780
1932
|
const body = JSON.stringify({ value, handles: options?.handles });
|
|
781
1933
|
const response = new Response(body, {
|
|
782
1934
|
headers: {
|
|
783
1935
|
"Content-Type": "application/json",
|
|
784
1936
|
"Cache-Control": `public, max-age=${totalTtl}`,
|
|
785
1937
|
[CACHE_STALE_AT_HEADER]: String(staleAt),
|
|
1938
|
+
// Absolute hard-expiry deadline; see set() / remainingCacheControl.
|
|
1939
|
+
[CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
|
|
786
1940
|
[CACHE_STATUS_HEADER]: "HIT",
|
|
1941
|
+
...this.tagHeaderEntries(tags, taggedAt),
|
|
787
1942
|
},
|
|
788
1943
|
});
|
|
789
1944
|
|
|
790
1945
|
const putPromise = cache.put(request, response);
|
|
791
1946
|
|
|
792
1947
|
if (this.waitUntil) {
|
|
793
|
-
this.waitUntil(
|
|
794
|
-
|
|
795
|
-
|
|
1948
|
+
this.waitUntil(() =>
|
|
1949
|
+
reportingAsync(
|
|
1950
|
+
() => putPromise,
|
|
1951
|
+
"cache-write",
|
|
1952
|
+
"[CFCacheStore] L1 write",
|
|
1953
|
+
),
|
|
1954
|
+
);
|
|
796
1955
|
} else {
|
|
797
1956
|
await putPromise;
|
|
798
1957
|
}
|
|
@@ -800,24 +1959,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
800
1959
|
// L2: persist to KV (KV requires expirationTtl >= 60s)
|
|
801
1960
|
if (this.kv && this.waitUntil && totalTtl >= 60) {
|
|
802
1961
|
const kvKey = this.toKVKey(`fn:${key}`);
|
|
803
|
-
this.waitUntil(
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1962
|
+
this.waitUntil(() =>
|
|
1963
|
+
reportingAsync(
|
|
1964
|
+
() => {
|
|
1965
|
+
const envelope: KVItemEnvelope = {
|
|
1966
|
+
v: value,
|
|
1967
|
+
h: options?.handles,
|
|
1968
|
+
s: staleAt,
|
|
1969
|
+
e: staleAt + swrWindow * 1000,
|
|
1970
|
+
t: tags,
|
|
1971
|
+
ta: taggedAt,
|
|
1972
|
+
};
|
|
1973
|
+
return this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
1974
|
+
expirationTtl: totalTtl,
|
|
1975
|
+
});
|
|
1976
|
+
},
|
|
1977
|
+
"cache-write",
|
|
1978
|
+
"[CFCacheStore] kvSetItem",
|
|
1979
|
+
),
|
|
1980
|
+
);
|
|
818
1981
|
}
|
|
819
1982
|
} catch (error) {
|
|
820
|
-
|
|
1983
|
+
reportCacheError(error, "cache-write", "[CFCacheStore] setItem");
|
|
821
1984
|
}
|
|
822
1985
|
}
|
|
823
1986
|
|
|
@@ -849,6 +2012,512 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
849
2012
|
return `${versionPath}${key}`;
|
|
850
2013
|
}
|
|
851
2014
|
|
|
2015
|
+
/**
|
|
2016
|
+
* Best-effort delete of a single KV key, reporting (not swallowing) a delete
|
|
2017
|
+
* failure as cache-delete. Used by the corrupt-entry self-heal paths.
|
|
2018
|
+
* @internal
|
|
2019
|
+
*/
|
|
2020
|
+
private async evictKvKey(kvKey: string, label: string): Promise<void> {
|
|
2021
|
+
try {
|
|
2022
|
+
await this.kv!.delete(kvKey);
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
reportCacheError(
|
|
2025
|
+
error,
|
|
2026
|
+
"cache-delete",
|
|
2027
|
+
`[CFCacheStore] ${label}: evict failed`,
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Schedule a corrupt-entry KV eviction as a NON-BLOCKING background task
|
|
2034
|
+
* (waitUntil) instead of awaiting it on the request path. The corrupt read has
|
|
2035
|
+
* already resolved to a miss; awaiting an unbounded kv.delete here would re-add
|
|
2036
|
+
* exactly the multi-second stall the read budgets exist to prevent when the KV
|
|
2037
|
+
* namespace is degraded. evictKvKey never rejects (it reports its own failure),
|
|
2038
|
+
* so the fire-and-forget fallback is safe when no waitUntil is available.
|
|
2039
|
+
* @internal
|
|
2040
|
+
*/
|
|
2041
|
+
private scheduleKvEvict(kvKey: string, label: string): void {
|
|
2042
|
+
const evict = (): Promise<void> => this.evictKvKey(kvKey, label);
|
|
2043
|
+
if (this.waitUntil) this.waitUntil(evict);
|
|
2044
|
+
else void evict();
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/**
|
|
2048
|
+
* KV-get a JSON envelope, EVICTING the key only when it is genuinely corrupt.
|
|
2049
|
+
*
|
|
2050
|
+
* Reads as { type: "text" }, NOT { type: "json" }, on purpose: the "json" form
|
|
2051
|
+
* fuses the network read and the JSON parse, so a transient KV outage (5xx/429/
|
|
2052
|
+
* network blip) is indistinguishable from a malformed body and would delete a
|
|
2053
|
+
* still-good cross-colo entry - a self-inflicted miss storm. Reading text lets a
|
|
2054
|
+
* transient read error propagate to the caller's outer catch (reported
|
|
2055
|
+
* cache-read, the entry left intact); only a JSON.parse failure on a body that
|
|
2056
|
+
* WAS successfully read - or an envelope that parses but fails `validate`
|
|
2057
|
+
* (fields missing from a truncated write) - is true corruption that evicts +
|
|
2058
|
+
* reports cache-corrupt. A MISSING key (kv.get -> null) is a normal miss.
|
|
2059
|
+
* @internal
|
|
2060
|
+
*/
|
|
2061
|
+
private async kvGetOrEvict<T>(
|
|
2062
|
+
kvKey: string,
|
|
2063
|
+
validate: (envelope: T) => boolean,
|
|
2064
|
+
label: string,
|
|
2065
|
+
): Promise<{ value: T | null; timedOut: boolean }> {
|
|
2066
|
+
// Bound the read with the KV latency budget (inherited from #558) so a
|
|
2067
|
+
// degraded namespace cannot pin the request. readWithTimeout reports
|
|
2068
|
+
// timedOut on budget expiry; a transient read REJECTION (5xx/429/network)
|
|
2069
|
+
// instead propagates out to the caller's outer catch (reported cache-read,
|
|
2070
|
+
// the entry left intact) -- deliberately NOT caught as corruption.
|
|
2071
|
+
const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
|
|
2072
|
+
() => this.kv!.get(kvKey, { type: "text" }),
|
|
2073
|
+
this.kvReadTimeoutMs,
|
|
2074
|
+
"KV read",
|
|
2075
|
+
);
|
|
2076
|
+
if (timedOut) return { value: null, timedOut: true };
|
|
2077
|
+
if (raw == null) return { value: null, timedOut: false }; // missing = miss
|
|
2078
|
+
|
|
2079
|
+
// Real CF KV with { type: "text" } returns a string: parse + structurally
|
|
2080
|
+
// validate it; a parse/validate failure on a successfully-read body is the
|
|
2081
|
+
// only true corruption (evict + cache-corrupt). A KV binding that already
|
|
2082
|
+
// returns a parsed object (some shims/tests) is used as-is.
|
|
2083
|
+
let envelope: T;
|
|
2084
|
+
if (typeof raw === "string") {
|
|
2085
|
+
try {
|
|
2086
|
+
envelope = JSON.parse(raw) as T;
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
reportCacheError(
|
|
2089
|
+
error,
|
|
2090
|
+
"cache-corrupt",
|
|
2091
|
+
`[CFCacheStore] ${label}: corrupt JSON in KV, evicting`,
|
|
2092
|
+
);
|
|
2093
|
+
this.scheduleKvEvict(kvKey, label);
|
|
2094
|
+
return { value: null, timedOut: false };
|
|
2095
|
+
}
|
|
2096
|
+
} else {
|
|
2097
|
+
envelope = raw as T;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// A body that parses to null or a primitive ('null', '42', 'true', '"x"')
|
|
2101
|
+
// is not a valid envelope. Guard it BEFORE validate(): the property-reading
|
|
2102
|
+
// validators throw on a null/primitive rather than returning false, which
|
|
2103
|
+
// would escape to the caller's outer catch as a transient cache-read and
|
|
2104
|
+
// leave the bad key un-evicted (re-failing every read until its KV TTL). The
|
|
2105
|
+
// typeof check short-circuits validate() so it only ever runs on an object.
|
|
2106
|
+
if (
|
|
2107
|
+
envelope == null ||
|
|
2108
|
+
typeof envelope !== "object" ||
|
|
2109
|
+
!validate(envelope)
|
|
2110
|
+
) {
|
|
2111
|
+
reportCacheError(
|
|
2112
|
+
new Error("malformed/partial KV envelope"),
|
|
2113
|
+
"cache-corrupt",
|
|
2114
|
+
`[CFCacheStore] ${label}: malformed envelope, evicting`,
|
|
2115
|
+
);
|
|
2116
|
+
this.scheduleKvEvict(kvKey, label);
|
|
2117
|
+
return { value: null, timedOut: false };
|
|
2118
|
+
}
|
|
2119
|
+
return { value: envelope, timedOut: false };
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// ============================================================================
|
|
2123
|
+
// Tag Invalidation (single-store: markers live in this.kv)
|
|
2124
|
+
// ============================================================================
|
|
2125
|
+
|
|
2126
|
+
/** KV key for a tag's invalidation marker. */
|
|
2127
|
+
private tagMarkerKey(tag: string): string {
|
|
2128
|
+
return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Header entries carrying an entry's tags (JSON-encoded, comma-safe) and the
|
|
2133
|
+
* timestamp they were attached. Returns an empty object when there are no
|
|
2134
|
+
* tags so untagged entries stay header-free and skip the invalidation check.
|
|
2135
|
+
*/
|
|
2136
|
+
private tagHeaderEntries(
|
|
2137
|
+
tags: string[] | undefined,
|
|
2138
|
+
taggedAt: number | undefined,
|
|
2139
|
+
): Record<string, string> {
|
|
2140
|
+
if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return {};
|
|
2141
|
+
return {
|
|
2142
|
+
// encodeURIComponent so the value is pure ASCII: HTTP header values are
|
|
2143
|
+
// ByteStrings, but JSON.stringify leaves codepoints > U+00FF (emoji/CJK)
|
|
2144
|
+
// verbatim, which makes new Response({ headers }) throw and the outer
|
|
2145
|
+
// try/catch silently drop the whole entry from cache. Decoded in
|
|
2146
|
+
// readTagInfo. The L1 marker Cache-Tag path encodes for the same reason.
|
|
2147
|
+
[CACHE_TAGS_HEADER]: encodeURIComponent(JSON.stringify(tags)),
|
|
2148
|
+
[CACHE_TAGGED_AT_HEADER]: String(taggedAt),
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
/** Read an entry's tags/taggedAt back from its headers. */
|
|
2153
|
+
private readTagInfo(headers: Headers): {
|
|
2154
|
+
tags?: string[];
|
|
2155
|
+
taggedAt?: number;
|
|
2156
|
+
} {
|
|
2157
|
+
const rawTags = headers.get(CACHE_TAGS_HEADER);
|
|
2158
|
+
const rawTaggedAt = headers.get(CACHE_TAGGED_AT_HEADER);
|
|
2159
|
+
if (!rawTags || !rawTaggedAt) return {};
|
|
2160
|
+
try {
|
|
2161
|
+
return {
|
|
2162
|
+
tags: JSON.parse(decodeURIComponent(rawTags)) as string[],
|
|
2163
|
+
taggedAt: Number(rawTaggedAt),
|
|
2164
|
+
};
|
|
2165
|
+
} catch {
|
|
2166
|
+
return {};
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Whether an entry tagged at `taggedAt` with `tags` has been invalidated since.
|
|
2172
|
+
* Reads the per-tag invalidation markers from KV and returns true if any tag's
|
|
2173
|
+
* latest invalidation is at or after taggedAt (>= so a same-millisecond
|
|
2174
|
+
* invalidate wins, favouring freshness over staleness). Fails open: KV errors
|
|
2175
|
+
* never turn a hit into a wrongful miss-storm beyond this single read.
|
|
2176
|
+
*/
|
|
2177
|
+
private async isGloballyInvalidated(
|
|
2178
|
+
tags: string[] | undefined,
|
|
2179
|
+
taggedAt: number | undefined,
|
|
2180
|
+
): Promise<boolean> {
|
|
2181
|
+
// Array.isArray (not just truthiness): a non-array tags value - direct store
|
|
2182
|
+
// misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
|
|
2183
|
+
// must fail safe to "not invalidated" rather than throwing `.map` on every
|
|
2184
|
+
// read (which the outer catch would mis-report as a transient cache-read).
|
|
2185
|
+
if (!this.kv || !Array.isArray(tags) || tags.length === 0 || !taggedAt)
|
|
2186
|
+
return false;
|
|
2187
|
+
const ctx = _getRequestContext();
|
|
2188
|
+
const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
|
|
2189
|
+
const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
|
|
2190
|
+
try {
|
|
2191
|
+
const markers = await Promise.all(
|
|
2192
|
+
tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
|
|
2193
|
+
);
|
|
2194
|
+
for (const marker of markers) {
|
|
2195
|
+
if (marker != null && marker >= taggedAt) return true;
|
|
2196
|
+
}
|
|
2197
|
+
return false;
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
reportCacheError(
|
|
2200
|
+
error,
|
|
2201
|
+
"cache-read",
|
|
2202
|
+
"[CFCacheStore] tag invalidation check",
|
|
2203
|
+
);
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/** Synthetic Cache API request for a tag's L1-cached invalidation marker. */
|
|
2209
|
+
private tagMarkerRequest(tag: string): Request {
|
|
2210
|
+
return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* Read a tag's latest invalidation timestamp (or null if never invalidated)
|
|
2215
|
+
* through the cascade: per-request memo -> per-colo L1 cache (only when
|
|
2216
|
+
* tagCacheTtl > 0) -> KV (the global truth). The memo is always consulted
|
|
2217
|
+
* first so it stays authoritative within a request (read-your-own-writes),
|
|
2218
|
+
* and every KV/L1 result is written back into the memo. A Cache API miss
|
|
2219
|
+
* always falls through to KV; absence is represented by a cached sentinel,
|
|
2220
|
+
* never by a miss.
|
|
2221
|
+
*
|
|
2222
|
+
* Concurrent reads of the same tag within a request share one in-flight read
|
|
2223
|
+
* (the resolved-value memo only collapses sequential reads; parallel segment
|
|
2224
|
+
* loading would otherwise issue one KV read per concurrent reader).
|
|
2225
|
+
* @internal
|
|
2226
|
+
*/
|
|
2227
|
+
private async readTagMarker(
|
|
2228
|
+
tag: string,
|
|
2229
|
+
memo: Map<string, number | null> | undefined,
|
|
2230
|
+
inflight: Map<string, Promise<number | null>> | undefined,
|
|
2231
|
+
): Promise<number | null> {
|
|
2232
|
+
if (memo && memo.has(tag)) return memo.get(tag) ?? null;
|
|
2233
|
+
|
|
2234
|
+
// Collapse concurrent (not-yet-resolved) reads of this tag onto one promise.
|
|
2235
|
+
if (inflight) {
|
|
2236
|
+
const pending = inflight.get(tag);
|
|
2237
|
+
if (pending) return pending;
|
|
2238
|
+
const read = this.fetchTagMarker(tag, memo);
|
|
2239
|
+
inflight.set(tag, read);
|
|
2240
|
+
try {
|
|
2241
|
+
return await read;
|
|
2242
|
+
} finally {
|
|
2243
|
+
// Resolved values now live in the memo; drop the in-flight entry.
|
|
2244
|
+
inflight.delete(tag);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
return this.fetchTagMarker(tag, memo);
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/**
|
|
2252
|
+
* Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
|
|
2253
|
+
* tagCacheTtl) -> KV. Writes the resolved value back into the memo.
|
|
2254
|
+
* @internal
|
|
2255
|
+
*/
|
|
2256
|
+
private async fetchTagMarker(
|
|
2257
|
+
tag: string,
|
|
2258
|
+
memo: Map<string, number | null> | undefined,
|
|
2259
|
+
): Promise<number | null> {
|
|
2260
|
+
// Write the resolved marker into the memo WITHOUT clobbering a value a
|
|
2261
|
+
// concurrent invalidateTags() wrote during our await. The router resolves
|
|
2262
|
+
// sibling slots in parallel, so a slot's updateTag() can land the
|
|
2263
|
+
// authoritative invalidatedAt into the memo while this read is still in
|
|
2264
|
+
// flight; overwriting it with our (pre-invalidation) read result would break
|
|
2265
|
+
// read-your-own-writes for the rest of the request. If the tag was memoized
|
|
2266
|
+
// mid-read, that value wins and is returned. Without a memo, the read result
|
|
2267
|
+
// stands as-is.
|
|
2268
|
+
const memoize = (read: number | null): number | null => {
|
|
2269
|
+
if (memo && memo.has(tag)) return memo.get(tag) ?? null;
|
|
2270
|
+
memo?.set(tag, read);
|
|
2271
|
+
return read;
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
// L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
|
|
2275
|
+
// edge budgets as data reads (inherited from #558) so a degraded colo cannot
|
|
2276
|
+
// stall a tagged read; a miss, timeout, or error all fall through to KV.
|
|
2277
|
+
if (this.tagCacheTtl > 0) {
|
|
2278
|
+
try {
|
|
2279
|
+
const cache = await this.getCache();
|
|
2280
|
+
const { response: hit, error: matchError } =
|
|
2281
|
+
await this.matchWithTimeout(cache, this.tagMarkerRequest(tag));
|
|
2282
|
+
// A transient match REJECTION is captured (not thrown) by
|
|
2283
|
+
// matchWithTimeout; surface it as cache-read like the data read paths
|
|
2284
|
+
// before falling through to KV, rather than silently dropping it.
|
|
2285
|
+
if (matchError)
|
|
2286
|
+
reportCacheError(
|
|
2287
|
+
matchError,
|
|
2288
|
+
"cache-read",
|
|
2289
|
+
"[CFCacheStore] tag marker L1 match",
|
|
2290
|
+
);
|
|
2291
|
+
if (hit) {
|
|
2292
|
+
const { value: body } = await this.readWithTimeout(
|
|
2293
|
+
() => hit.text(),
|
|
2294
|
+
this.edgeReadTimeoutMs,
|
|
2295
|
+
"tag marker L1 body read",
|
|
2296
|
+
);
|
|
2297
|
+
if (body !== undefined) {
|
|
2298
|
+
const value = body === TAG_MARKER_ABSENT ? null : Number(body);
|
|
2299
|
+
return memoize(value);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
} catch {
|
|
2303
|
+
// Fall through to KV on any L1 read error.
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// KV (global truth), bounded by the KV budget. On TIMEOUT fail OPEN: treat
|
|
2308
|
+
// the marker as absent (-> entry not invalidated -> served) so a degraded
|
|
2309
|
+
// namespace cannot pin every tagged read behind a slow global lookup. A
|
|
2310
|
+
// transient REJECTION instead propagates to isGloballyInvalidated's catch
|
|
2311
|
+
// (reported cache-read), which also fails open. Either way one slow tag
|
|
2312
|
+
// never amplifies into a per-segment stall.
|
|
2313
|
+
const { value: raw, timedOut } = await this.readWithTimeout<string | null>(
|
|
2314
|
+
() => this.kv!.get(this.tagMarkerKey(tag), { type: "text" }),
|
|
2315
|
+
this.kvReadTimeoutMs,
|
|
2316
|
+
"tag marker KV read",
|
|
2317
|
+
);
|
|
2318
|
+
if (timedOut) {
|
|
2319
|
+
// Memoize the fail-open result so the rest of this request is consistent
|
|
2320
|
+
// (and does not re-pay the timeout per segment sharing the tag).
|
|
2321
|
+
return memoize(null);
|
|
2322
|
+
}
|
|
2323
|
+
const value = raw != null ? Number(raw) : null;
|
|
2324
|
+
const resolved = memoize(value);
|
|
2325
|
+
|
|
2326
|
+
// Populate L1 for subsequent reads in this colo (non-blocking). Use the
|
|
2327
|
+
// resolved (memo-aware) value so a marker invalidated mid-read is not
|
|
2328
|
+
// re-cached stale into this colo's L1.
|
|
2329
|
+
if (this.tagCacheTtl > 0) {
|
|
2330
|
+
const put = () => this.putTagMarkerL1(tag, resolved);
|
|
2331
|
+
if (this.waitUntil) this.waitUntil(put);
|
|
2332
|
+
else void put();
|
|
2333
|
+
}
|
|
2334
|
+
return resolved;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* Cloudflare Cache-Tags written on a tag's L1 marker entry, namespaced per
|
|
2339
|
+
* store so purges never collide with other Cache-Tags in the zone. Three
|
|
2340
|
+
* tiers, broad to specific:
|
|
2341
|
+
* rg:{ns} - everything this store cached (deploy/nuclear reset)
|
|
2342
|
+
* rg:{ns}:lk - all tag-lookup markers
|
|
2343
|
+
* rg:{ns}:lk:{tag} - this tag's lookup (the normal updateTag purge target)
|
|
2344
|
+
* The tag value is encodeURIComponent'd so commas/spaces can't corrupt the
|
|
2345
|
+
* comma-delimited Cache-Tag header.
|
|
2346
|
+
* @internal
|
|
2347
|
+
*/
|
|
2348
|
+
private lookupCacheTags(tag: string): string[] {
|
|
2349
|
+
const ns = this.namespace ?? "default";
|
|
2350
|
+
return [`rg:${ns}`, `rg:${ns}:lk`, this.lookupPurgeTag(tag)];
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/** The specific Cache-Tag a consumer purges to evict tag `tag`'s lookup. */
|
|
2354
|
+
private lookupPurgeTag(tag: string): string {
|
|
2355
|
+
const ns = this.namespace ?? "default";
|
|
2356
|
+
return `rg:${ns}:lk:${encodeURIComponent(tag)}`;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* Write a tag marker value into the per-colo L1 Cache API with tagCacheTtl.
|
|
2361
|
+
* `null` is stored as the TAG_MARKER_ABSENT sentinel so "no marker yet" is
|
|
2362
|
+
* cacheable (most tags are never invalidated - that is where the read savings
|
|
2363
|
+
* come from). The entry also carries a namespaced Cache-Tag so an external
|
|
2364
|
+
* purge-by-tag (via onRevalidateTag) can evict it across colos promptly,
|
|
2365
|
+
* rather than waiting out tagCacheTtl. Best-effort.
|
|
2366
|
+
* @internal
|
|
2367
|
+
*/
|
|
2368
|
+
private async putTagMarkerL1(
|
|
2369
|
+
tag: string,
|
|
2370
|
+
value: number | null,
|
|
2371
|
+
opts?: { critical?: boolean },
|
|
2372
|
+
): Promise<void> {
|
|
2373
|
+
if (this.tagCacheTtl <= 0) return;
|
|
2374
|
+
try {
|
|
2375
|
+
const cache = await this.getCache();
|
|
2376
|
+
const body = value != null ? String(value) : TAG_MARKER_ABSENT;
|
|
2377
|
+
await cache.put(
|
|
2378
|
+
this.tagMarkerRequest(tag),
|
|
2379
|
+
new Response(body, {
|
|
2380
|
+
headers: {
|
|
2381
|
+
"Cache-Control": `public, max-age=${this.tagCacheTtl}`,
|
|
2382
|
+
"Cache-Tag": this.lookupCacheTags(tag).join(","),
|
|
2383
|
+
},
|
|
2384
|
+
}),
|
|
2385
|
+
);
|
|
2386
|
+
} catch (error) {
|
|
2387
|
+
// The read-path populate is best-effort: a failed populate just means the
|
|
2388
|
+
// next read consults KV. The invalidation WRITE-THROUGH (critical) is not
|
|
2389
|
+
// - silently swallowing it would leave this colo's stale marker (often the
|
|
2390
|
+
// ABSENT sentinel) authoritative for tagCacheTtl while updateTag reports
|
|
2391
|
+
// success. Surface it, and best-effort delete the L1 marker so the next
|
|
2392
|
+
// read re-reads KV, which already holds the fresh marker (written before
|
|
2393
|
+
// this write-through in invalidateTags).
|
|
2394
|
+
if (opts?.critical) {
|
|
2395
|
+
reportCacheError(
|
|
2396
|
+
error,
|
|
2397
|
+
"cache-invalidate",
|
|
2398
|
+
"[CFCacheStore] tag marker L1 write-through",
|
|
2399
|
+
);
|
|
2400
|
+
await reportingAsync(
|
|
2401
|
+
async () => {
|
|
2402
|
+
const cache = await this.getCache();
|
|
2403
|
+
await cache.delete(this.tagMarkerRequest(tag));
|
|
2404
|
+
},
|
|
2405
|
+
"cache-delete",
|
|
2406
|
+
"[CFCacheStore] tag marker L1 evict after failed write-through",
|
|
2407
|
+
);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Invalidate every entry tagged with any of `tags`. Receives the whole batch
|
|
2414
|
+
* from one updateTag()/revalidateTag() call so the eager-purge hook fires
|
|
2415
|
+
* ONCE (one CDN purge request, not one per tag). For each tag: records the KV
|
|
2416
|
+
* marker (the durable cross-colo truth that reads compare taggedAt against),
|
|
2417
|
+
* writes the fresh marker straight into this colo's L1 (write-through, NOT
|
|
2418
|
+
* delete - a delete would let the next read re-read a not-yet-converged KV
|
|
2419
|
+
* value and re-arm the stale window), and memoizes it for same-request
|
|
2420
|
+
* read-your-own-writes. Finally fires onRevalidateTag with the namespaced
|
|
2421
|
+
* lookup Cache-Tags so a consumer purge evicts the cached lookups in other
|
|
2422
|
+
* colos promptly (otherwise they converge within tagCacheTtl).
|
|
2423
|
+
*
|
|
2424
|
+
* Durable-write integrity: the in-memory write-through (memo + L1) for a tag
|
|
2425
|
+
* runs ONLY after that tag's KV marker write is confirmed. If any KV write
|
|
2426
|
+
* fails (transient error, or an over-512-byte key), this rejects with the
|
|
2427
|
+
* failed tags so an awaiting updateTag() surfaces the failure instead of
|
|
2428
|
+
* silently reporting success while other requests/colos serve stale data. The
|
|
2429
|
+
* eager purge still fires for the whole batch first (it is additive).
|
|
2430
|
+
*/
|
|
2431
|
+
async invalidateTags(tags: string[]): Promise<void> {
|
|
2432
|
+
if (tags.length === 0) return;
|
|
2433
|
+
const invalidatedAt = Date.now();
|
|
2434
|
+
const ctx = _getRequestContext();
|
|
2435
|
+
const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
|
|
2436
|
+
|
|
2437
|
+
if (!this.kv && !this.onRevalidateTag) {
|
|
2438
|
+
console.warn(
|
|
2439
|
+
`[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
|
|
2440
|
+
`for distributed invalidation, or an onRevalidateTag hook.`,
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const failedTags = new Set<string>();
|
|
2445
|
+
const errors: unknown[] = [];
|
|
2446
|
+
if (this.kv) {
|
|
2447
|
+
await Promise.all(
|
|
2448
|
+
tags.map(async (tag) => {
|
|
2449
|
+
const markerKey = this.tagMarkerKey(tag);
|
|
2450
|
+
if (kvKeyByteLength(markerKey) > KV_MAX_KEY_BYTES) {
|
|
2451
|
+
failedTags.add(tag);
|
|
2452
|
+
errors.push(
|
|
2453
|
+
new Error(
|
|
2454
|
+
`tag "${tag}" produces a ${kvKeyByteLength(markerKey)}-byte KV ` +
|
|
2455
|
+
`marker key, over the ${KV_MAX_KEY_BYTES}-byte limit`,
|
|
2456
|
+
),
|
|
2457
|
+
);
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
try {
|
|
2461
|
+
await this.kv!.put(markerKey, String(invalidatedAt), {
|
|
2462
|
+
...(this.tagInvalidationTtl
|
|
2463
|
+
? { expirationTtl: this.tagInvalidationTtl }
|
|
2464
|
+
: {}),
|
|
2465
|
+
});
|
|
2466
|
+
} catch (error) {
|
|
2467
|
+
failedTags.add(tag);
|
|
2468
|
+
errors.push(error);
|
|
2469
|
+
}
|
|
2470
|
+
}),
|
|
2471
|
+
);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// Write-through memo + L1 only for tags with a confirmed durable marker, and
|
|
2475
|
+
// only when KV is configured. Markers are read exclusively through
|
|
2476
|
+
// isGloballyInvalidated(), which short-circuits to "not invalidated" when
|
|
2477
|
+
// !this.kv; writing memo/L1 markers without KV would be dead state no read
|
|
2478
|
+
// path ever consults. The onRevalidateTag purge below still fires regardless
|
|
2479
|
+
// (it is additive and external to the marker cascade). The memo write is
|
|
2480
|
+
// synchronous (read-your-own-writes); the L1 Cache API writes are
|
|
2481
|
+
// independent, so fan them out in parallel rather than awaiting each.
|
|
2482
|
+
if (this.kv) {
|
|
2483
|
+
const l1Writes: Promise<void>[] = [];
|
|
2484
|
+
for (const tag of tags) {
|
|
2485
|
+
if (failedTags.has(tag)) continue;
|
|
2486
|
+
memo?.set(tag, invalidatedAt);
|
|
2487
|
+
if (this.tagCacheTtl > 0) {
|
|
2488
|
+
l1Writes.push(
|
|
2489
|
+
this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
if (l1Writes.length > 0) await Promise.all(l1Writes);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// One batched eager purge of the lookup markers for the whole call. Fired
|
|
2497
|
+
// regardless of KV write outcome (it is additive and uses pure string ops).
|
|
2498
|
+
if (this.onRevalidateTag) {
|
|
2499
|
+
try {
|
|
2500
|
+
await this.onRevalidateTag(tags.map((tag) => this.lookupPurgeTag(tag)));
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
reportCacheError(
|
|
2503
|
+
error,
|
|
2504
|
+
"cache-invalidate",
|
|
2505
|
+
"[CFCacheStore] onRevalidateTag hook",
|
|
2506
|
+
);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
if (failedTags.size > 0) {
|
|
2511
|
+
const err = new Error(
|
|
2512
|
+
`[CFCacheStore] ${failedTags.size}/${tags.length} tag marker write(s) ` +
|
|
2513
|
+
`failed: ${[...failedTags].join(", ")}. Those tags may still serve ` +
|
|
2514
|
+
`stale data across requests/colos; retry the invalidation.`,
|
|
2515
|
+
);
|
|
2516
|
+
(err as Error & { cause?: unknown }).cause = errors[0];
|
|
2517
|
+
throw err;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
|
|
852
2521
|
// ============================================================================
|
|
853
2522
|
// KV L2 Helpers
|
|
854
2523
|
// ============================================================================
|
|
@@ -859,28 +2528,78 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
859
2528
|
* Promotes hits to L1 via waitUntil.
|
|
860
2529
|
* @internal
|
|
861
2530
|
*/
|
|
862
|
-
private async kvGetSegment(
|
|
2531
|
+
private async kvGetSegment(
|
|
2532
|
+
key: string,
|
|
2533
|
+
opts?: { suppressRevalidate?: boolean },
|
|
2534
|
+
): Promise<CacheGetResult | null> {
|
|
863
2535
|
if (!this.kv) return null;
|
|
864
2536
|
|
|
865
2537
|
try {
|
|
866
2538
|
const kvKey = this.toKVKey(key);
|
|
867
|
-
const
|
|
868
|
-
|
|
2539
|
+
const { value: envelope, timedOut } =
|
|
2540
|
+
await this.kvGetOrEvict<KVSegmentEnvelope>(
|
|
2541
|
+
kvKey,
|
|
2542
|
+
(e) =>
|
|
2543
|
+
typeof e.e === "number" && typeof e.s === "number" && e.d != null,
|
|
2544
|
+
"kvGetSegment",
|
|
2545
|
+
);
|
|
2546
|
+
if (timedOut) {
|
|
2547
|
+
// Abandoned slow KV read: no envelope, so no promote-to-L1. Distinct
|
|
2548
|
+
// from a genuine kv-miss so the degradation is visible on wrangler tail.
|
|
2549
|
+
if (this.debug)
|
|
2550
|
+
this.emitDebug({ op: "get", key, outcome: "kv-timeout" });
|
|
2551
|
+
return null;
|
|
2552
|
+
}
|
|
2553
|
+
if (!envelope) {
|
|
2554
|
+
// Missing key, or a corrupt entry already evicted + reported by
|
|
2555
|
+
// kvGetOrEvict. Either way a miss.
|
|
2556
|
+
if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
|
|
2557
|
+
return null;
|
|
2558
|
+
}
|
|
869
2559
|
|
|
870
|
-
const envelope = raw as KVSegmentEnvelope;
|
|
871
2560
|
const now = Date.now();
|
|
872
2561
|
|
|
873
2562
|
// Hard-expired — treat as miss
|
|
874
|
-
if (now > envelope.e)
|
|
2563
|
+
if (now > envelope.e) {
|
|
2564
|
+
if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
875
2567
|
|
|
876
|
-
|
|
2568
|
+
// Tag invalidation check (also covers the KV tier, not just L1).
|
|
2569
|
+
if (
|
|
2570
|
+
await this.isGloballyInvalidated(envelope.d.tags, envelope.d.taggedAt)
|
|
2571
|
+
) {
|
|
2572
|
+
if (this.debug)
|
|
2573
|
+
this.emitDebug({ op: "get", key, outcome: "tag-invalidated" });
|
|
2574
|
+
return null;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
// When this is a degraded L1 fall-through (body-timeout / non-200), the
|
|
2578
|
+
// caller asks us to suppress revalidation: KV has no REVALIDATING herd
|
|
2579
|
+
// guard, so N concurrent degraded reads would otherwise each spawn a
|
|
2580
|
+
// render exactly when the colo is already struggling. We still serve the
|
|
2581
|
+
// stale data and still promote to L1; only the revalidation is withheld.
|
|
2582
|
+
const stale = now > envelope.s;
|
|
2583
|
+
const shouldRevalidate = stale && !opts?.suppressRevalidate;
|
|
877
2584
|
|
|
878
2585
|
// Promote to L1 in background
|
|
879
2586
|
this.promoteSegmentToL1(key, envelope);
|
|
880
2587
|
|
|
2588
|
+
if (this.debug)
|
|
2589
|
+
this.emitDebug({
|
|
2590
|
+
op: "get",
|
|
2591
|
+
key,
|
|
2592
|
+
outcome: !stale
|
|
2593
|
+
? "kv-fresh"
|
|
2594
|
+
: opts?.suppressRevalidate
|
|
2595
|
+
? "kv-stale-suppressed"
|
|
2596
|
+
: "kv-stale",
|
|
2597
|
+
shouldRevalidate,
|
|
2598
|
+
});
|
|
881
2599
|
return { data: envelope.d, shouldRevalidate };
|
|
882
2600
|
} catch (error) {
|
|
883
|
-
|
|
2601
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
|
|
2602
|
+
if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
|
|
884
2603
|
return null;
|
|
885
2604
|
}
|
|
886
2605
|
}
|
|
@@ -902,20 +2621,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
902
2621
|
const kvKey = this.toKVKey(key);
|
|
903
2622
|
const expiresAt = staleAt + swrWindow * 1000;
|
|
904
2623
|
|
|
905
|
-
this.waitUntil(
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
2624
|
+
this.waitUntil(() =>
|
|
2625
|
+
reportingAsync(
|
|
2626
|
+
() => {
|
|
2627
|
+
const envelope: KVSegmentEnvelope = {
|
|
2628
|
+
d: data,
|
|
2629
|
+
s: staleAt,
|
|
2630
|
+
e: expiresAt,
|
|
2631
|
+
};
|
|
2632
|
+
return this.kv!.put(kvKey, JSON.stringify(envelope), {
|
|
2633
|
+
expirationTtl: totalTtl,
|
|
2634
|
+
});
|
|
2635
|
+
},
|
|
2636
|
+
"cache-write",
|
|
2637
|
+
"[CFCacheStore] kvSetSegment",
|
|
2638
|
+
),
|
|
2639
|
+
);
|
|
919
2640
|
}
|
|
920
2641
|
|
|
921
2642
|
/**
|
|
@@ -925,58 +2646,115 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
925
2646
|
private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
|
|
926
2647
|
if (!this.waitUntil) return;
|
|
927
2648
|
|
|
928
|
-
this.waitUntil(
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
2649
|
+
this.waitUntil(() =>
|
|
2650
|
+
reportingAsync(
|
|
2651
|
+
async () => {
|
|
2652
|
+
const now = Date.now();
|
|
2653
|
+
const remainingTtl = Math.max(
|
|
2654
|
+
1,
|
|
2655
|
+
Math.floor((envelope.e - now) / 1000),
|
|
2656
|
+
);
|
|
2657
|
+
const cache = await this.getCache();
|
|
2658
|
+
const request = this.keyToRequest(key);
|
|
2659
|
+
|
|
2660
|
+
const response = new Response(JSON.stringify(envelope.d), {
|
|
2661
|
+
headers: {
|
|
2662
|
+
"Content-Type": "application/json",
|
|
2663
|
+
"Cache-Control": `public, max-age=${remainingTtl}`,
|
|
2664
|
+
[CACHE_STALE_AT_HEADER]: String(envelope.s),
|
|
2665
|
+
// Carry the hard-expiry deadline so a promoted entry that later
|
|
2666
|
+
// goes stale re-puts with the correct remaining ttl (see set()).
|
|
2667
|
+
[CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
|
|
2668
|
+
[CACHE_STATUS_HEADER]: "HIT",
|
|
2669
|
+
// Preserve tags across KV->L1 promotion so the promoted entry
|
|
2670
|
+
// stays tag-invalidatable.
|
|
2671
|
+
...this.tagHeaderEntries(envelope.d.tags, envelope.d.taggedAt),
|
|
2672
|
+
},
|
|
2673
|
+
});
|
|
2674
|
+
|
|
2675
|
+
await cache.put(request, response);
|
|
2676
|
+
},
|
|
2677
|
+
"cache-write",
|
|
2678
|
+
"[CFCacheStore] promoteSegmentToL1",
|
|
2679
|
+
),
|
|
2680
|
+
);
|
|
949
2681
|
}
|
|
950
2682
|
|
|
951
2683
|
/**
|
|
952
2684
|
* KV fallback for function cache reads.
|
|
953
2685
|
* @internal
|
|
954
2686
|
*/
|
|
955
|
-
private async kvGetItem(
|
|
2687
|
+
private async kvGetItem(
|
|
2688
|
+
key: string,
|
|
2689
|
+
opts?: { suppressRevalidate?: boolean },
|
|
2690
|
+
): Promise<CacheItemResult | null> {
|
|
956
2691
|
if (!this.kv) return null;
|
|
957
2692
|
|
|
958
2693
|
try {
|
|
959
2694
|
const kvKey = this.toKVKey(`fn:${key}`);
|
|
960
|
-
const
|
|
961
|
-
|
|
2695
|
+
const { value: envelope, timedOut } =
|
|
2696
|
+
await this.kvGetOrEvict<KVItemEnvelope>(
|
|
2697
|
+
kvKey,
|
|
2698
|
+
(e) =>
|
|
2699
|
+
typeof e.v === "string" &&
|
|
2700
|
+
typeof e.e === "number" &&
|
|
2701
|
+
typeof e.s === "number",
|
|
2702
|
+
"kvGetItem",
|
|
2703
|
+
);
|
|
2704
|
+
if (timedOut) {
|
|
2705
|
+
if (this.debug)
|
|
2706
|
+
this.emitDebug({ op: "getItem", key, outcome: "kv-timeout" });
|
|
2707
|
+
return null;
|
|
2708
|
+
}
|
|
2709
|
+
if (!envelope) {
|
|
2710
|
+
if (this.debug)
|
|
2711
|
+
this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
|
|
2712
|
+
return null;
|
|
2713
|
+
}
|
|
962
2714
|
|
|
963
|
-
const envelope = raw as KVItemEnvelope;
|
|
964
2715
|
const now = Date.now();
|
|
965
2716
|
|
|
966
|
-
if (now > envelope.e)
|
|
2717
|
+
if (now > envelope.e) {
|
|
2718
|
+
if (this.debug)
|
|
2719
|
+
this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
|
|
2720
|
+
return null;
|
|
2721
|
+
}
|
|
967
2722
|
|
|
968
|
-
|
|
2723
|
+
// Tag invalidation check (also covers the KV tier, not just L1).
|
|
2724
|
+
if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
|
|
2725
|
+
if (this.debug)
|
|
2726
|
+
this.emitDebug({ op: "getItem", key, outcome: "tag-invalidated" });
|
|
2727
|
+
return null;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// Degraded fall-through suppresses revalidation (no KV herd guard); see
|
|
2731
|
+
// kvGetSegment. Still serves stale and still promotes.
|
|
2732
|
+
const stale = now > envelope.s;
|
|
2733
|
+
const shouldRevalidate = stale && !opts?.suppressRevalidate;
|
|
969
2734
|
|
|
970
2735
|
// Promote to L1
|
|
971
2736
|
this.promoteItemToL1(key, envelope);
|
|
972
2737
|
|
|
2738
|
+
if (this.debug)
|
|
2739
|
+
this.emitDebug({
|
|
2740
|
+
op: "getItem",
|
|
2741
|
+
key,
|
|
2742
|
+
outcome: !stale
|
|
2743
|
+
? "kv-fresh"
|
|
2744
|
+
: opts?.suppressRevalidate
|
|
2745
|
+
? "kv-stale-suppressed"
|
|
2746
|
+
: "kv-stale",
|
|
2747
|
+
shouldRevalidate,
|
|
2748
|
+
});
|
|
973
2749
|
return {
|
|
974
2750
|
value: envelope.v,
|
|
975
2751
|
handles: envelope.h,
|
|
976
2752
|
shouldRevalidate,
|
|
2753
|
+
tags: envelope.t,
|
|
977
2754
|
};
|
|
978
2755
|
} catch (error) {
|
|
979
|
-
|
|
2756
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] kvGetItem");
|
|
2757
|
+
if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
|
|
980
2758
|
return null;
|
|
981
2759
|
}
|
|
982
2760
|
}
|
|
@@ -988,28 +2766,41 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
988
2766
|
private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
|
|
989
2767
|
if (!this.waitUntil) return;
|
|
990
2768
|
|
|
991
|
-
this.waitUntil(
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
2769
|
+
this.waitUntil(() =>
|
|
2770
|
+
reportingAsync(
|
|
2771
|
+
async () => {
|
|
2772
|
+
const now = Date.now();
|
|
2773
|
+
const remainingTtl = Math.max(
|
|
2774
|
+
1,
|
|
2775
|
+
Math.floor((envelope.e - now) / 1000),
|
|
2776
|
+
);
|
|
2777
|
+
const cache = await this.getCache();
|
|
2778
|
+
const request = this.keyToRequest(`fn:${key}`);
|
|
2779
|
+
|
|
2780
|
+
const body = JSON.stringify({
|
|
2781
|
+
value: envelope.v,
|
|
2782
|
+
handles: envelope.h,
|
|
2783
|
+
});
|
|
2784
|
+
const response = new Response(body, {
|
|
2785
|
+
headers: {
|
|
2786
|
+
"Content-Type": "application/json",
|
|
2787
|
+
"Cache-Control": `public, max-age=${remainingTtl}`,
|
|
2788
|
+
[CACHE_STALE_AT_HEADER]: String(envelope.s),
|
|
2789
|
+
// Carry the hard-expiry deadline; see promoteSegmentToL1 / set().
|
|
2790
|
+
[CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
|
|
2791
|
+
[CACHE_STATUS_HEADER]: "HIT",
|
|
2792
|
+
// Preserve tags across KV->L1 promotion (the item tier previously
|
|
2793
|
+
// dropped them, permanently disabling tag invalidation here).
|
|
2794
|
+
...this.tagHeaderEntries(envelope.t, envelope.ta),
|
|
2795
|
+
},
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
await cache.put(request, response);
|
|
2799
|
+
},
|
|
2800
|
+
"cache-write",
|
|
2801
|
+
"[CFCacheStore] promoteItemToL1",
|
|
2802
|
+
),
|
|
2803
|
+
);
|
|
1013
2804
|
}
|
|
1014
2805
|
|
|
1015
2806
|
/**
|
|
@@ -1023,31 +2814,63 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1023
2814
|
|
|
1024
2815
|
try {
|
|
1025
2816
|
const kvKey = this.toKVKey(`doc:${key}`);
|
|
1026
|
-
|
|
1027
|
-
|
|
2817
|
+
// The document path is debug-silent (op is only get/getItem): a KV-read
|
|
2818
|
+
// timeout here is bounded for resilience parity (kvGetOrEvict applies the
|
|
2819
|
+
// budget) but emits no kv-timeout event, so its absence from the debug
|
|
2820
|
+
// stream is expected. A null envelope is a miss -- missing key, a budget
|
|
2821
|
+
// timeout, or a corrupt entry already evicted + reported by kvGetOrEvict.
|
|
2822
|
+
const { value: envelope } = await this.kvGetOrEvict<KVResponseEnvelope>(
|
|
2823
|
+
kvKey,
|
|
2824
|
+
(e) =>
|
|
2825
|
+
typeof e.b === "string" &&
|
|
2826
|
+
typeof e.st === "number" &&
|
|
2827
|
+
typeof e.e === "number" &&
|
|
2828
|
+
typeof e.s === "number" &&
|
|
2829
|
+
Array.isArray(e.hd),
|
|
2830
|
+
"kvGetResponse",
|
|
2831
|
+
);
|
|
2832
|
+
if (!envelope) return null;
|
|
1028
2833
|
|
|
1029
|
-
const envelope = raw as KVResponseEnvelope;
|
|
1030
2834
|
const now = Date.now();
|
|
1031
2835
|
|
|
1032
2836
|
if (now > envelope.e) return null;
|
|
1033
2837
|
|
|
2838
|
+
// Tag invalidation check (also covers the KV tier, not just L1).
|
|
2839
|
+
if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
|
|
2840
|
+
return null;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
1034
2843
|
const shouldRevalidate = now > envelope.s;
|
|
1035
2844
|
|
|
1036
|
-
// Reconstruct Response
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
2845
|
+
// Reconstruct Response: decode base64 -> binary, rebuild headers/status.
|
|
2846
|
+
// Corrupt/partial base64 throws in atob; malformed `hd` or an out-of-range
|
|
2847
|
+
// `st` throws in new Headers/new Response. Any of these is a faulty entry,
|
|
2848
|
+
// so evict it and miss rather than re-failing every read until TTL.
|
|
2849
|
+
let response: Response;
|
|
2850
|
+
try {
|
|
2851
|
+
const bodyBuffer = base64ToBuffer(envelope.b);
|
|
2852
|
+
const headers = new Headers(envelope.hd);
|
|
2853
|
+
response = new Response(bodyBuffer, {
|
|
2854
|
+
status: envelope.st,
|
|
2855
|
+
statusText: envelope.stx,
|
|
2856
|
+
headers,
|
|
2857
|
+
});
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
reportCacheError(
|
|
2860
|
+
error,
|
|
2861
|
+
"cache-corrupt",
|
|
2862
|
+
"[CFCacheStore] kvGetResponse: corrupt response envelope, evicting",
|
|
2863
|
+
);
|
|
2864
|
+
this.scheduleKvEvict(kvKey, "kvGetResponse");
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
1044
2867
|
|
|
1045
2868
|
// Promote to L1
|
|
1046
2869
|
this.promoteResponseToL1(key, envelope);
|
|
1047
2870
|
|
|
1048
2871
|
return { response, shouldRevalidate };
|
|
1049
2872
|
} catch (error) {
|
|
1050
|
-
|
|
2873
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] kvGetResponse");
|
|
1051
2874
|
return null;
|
|
1052
2875
|
}
|
|
1053
2876
|
}
|
|
@@ -1059,33 +2882,45 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1059
2882
|
private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
|
|
1060
2883
|
if (!this.waitUntil) return;
|
|
1061
2884
|
|
|
1062
|
-
this.waitUntil(
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
2885
|
+
this.waitUntil(() =>
|
|
2886
|
+
reportingAsync(
|
|
2887
|
+
async () => {
|
|
2888
|
+
const now = Date.now();
|
|
2889
|
+
const remainingTtl = Math.max(
|
|
2890
|
+
1,
|
|
2891
|
+
Math.floor((envelope.e - now) / 1000),
|
|
2892
|
+
);
|
|
2893
|
+
const cache = await this.getCache();
|
|
2894
|
+
const request = this.keyToRequest(`doc:${key}`);
|
|
2895
|
+
|
|
2896
|
+
const headers = new Headers(envelope.hd);
|
|
2897
|
+
const originalCacheControl = headers.get("Cache-Control");
|
|
2898
|
+
if (originalCacheControl !== null) {
|
|
2899
|
+
headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
|
|
2900
|
+
}
|
|
2901
|
+
headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
|
|
2902
|
+
headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
|
|
2903
|
+
// Re-attach the internal tag headers (envelope.hd is client-facing
|
|
2904
|
+
// and intentionally excludes them) so the promoted entry stays
|
|
2905
|
+
// invalidatable.
|
|
2906
|
+
const tagHeaders = this.tagHeaderEntries(envelope.t, envelope.ta);
|
|
2907
|
+
for (const [name, value] of Object.entries(tagHeaders)) {
|
|
2908
|
+
headers.set(name, value);
|
|
2909
|
+
}
|
|
1076
2910
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
2911
|
+
const bodyBuffer = base64ToBuffer(envelope.b);
|
|
2912
|
+
const response = new Response(bodyBuffer, {
|
|
2913
|
+
status: envelope.st,
|
|
2914
|
+
statusText: envelope.stx,
|
|
2915
|
+
headers,
|
|
2916
|
+
});
|
|
1083
2917
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
2918
|
+
await cache.put(request, response);
|
|
2919
|
+
},
|
|
2920
|
+
"cache-write",
|
|
2921
|
+
"[CFCacheStore] promoteResponseToL1",
|
|
2922
|
+
),
|
|
2923
|
+
);
|
|
1089
2924
|
}
|
|
1090
2925
|
}
|
|
1091
2926
|
|