@nifrajs/web 4.0.2 → 5.1.0
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/README.md +19 -0
- package/dist/build-vite.d.ts +19 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +31 -10
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +8 -10
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +90 -31
- package/dist/build.js.map +1 -1
- package/dist/cdn.d.ts +8 -0
- package/dist/cdn.d.ts.map +1 -1
- package/dist/cdn.js +14 -15
- package/dist/cdn.js.map +1 -1
- package/dist/client.d.ts +6 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +46 -18
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +2 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +28 -7
- package/dist/deferred.js.map +1 -1
- package/dist/dev-feed.d.ts.map +1 -1
- package/dist/dev-feed.js +24 -5
- package/dist/dev-feed.js.map +1 -1
- package/dist/dev-port.d.ts +3 -2
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +2 -6
- package/dist/dev-port.js.map +1 -1
- package/dist/dev-session.d.ts.map +1 -1
- package/dist/dev-session.js +4 -14
- package/dist/dev-session.js.map +1 -1
- package/dist/dev.d.ts +7 -17
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +13 -1
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +8 -5
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +30 -13
- package/dist/draft.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +7 -0
- package/dist/fonts-google.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +1 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +56 -29
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/disposable-dir.d.ts +8 -0
- package/dist/internal/disposable-dir.d.ts.map +1 -0
- package/dist/internal/disposable-dir.js +25 -0
- package/dist/internal/disposable-dir.js.map +1 -0
- package/dist/internal/entry-chunks.d.ts +9 -0
- package/dist/internal/entry-chunks.d.ts.map +1 -0
- package/dist/internal/entry-chunks.js +15 -0
- package/dist/internal/entry-chunks.js.map +1 -0
- package/dist/internal/evidence-traversal.d.ts +12 -0
- package/dist/internal/evidence-traversal.d.ts.map +1 -0
- package/dist/internal/evidence-traversal.js +48 -0
- package/dist/internal/evidence-traversal.js.map +1 -0
- package/dist/internal/head-merge.d.ts +6 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +9 -0
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/js-string.d.ts +2 -0
- package/dist/internal/js-string.d.ts.map +1 -1
- package/dist/internal/js-string.js +5 -1
- package/dist/internal/js-string.js.map +1 -1
- package/dist/internal/mount-shadow.js +1 -1
- package/dist/internal/mount-shadow.js.map +1 -1
- package/dist/internal/output-guard.d.ts +7 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +80 -8
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/package-entries.d.ts +34 -0
- package/dist/internal/package-entries.d.ts.map +1 -0
- package/dist/internal/package-entries.js +324 -0
- package/dist/internal/package-entries.js.map +1 -0
- package/dist/internal/page-execution.d.ts +6 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +437 -114
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +15 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +8 -0
- package/dist/internal/private-env.d.ts.map +1 -1
- package/dist/internal/private-env.js +25 -10
- package/dist/internal/private-env.js.map +1 -1
- package/dist/internal/progressive-cookies.d.ts +14 -0
- package/dist/internal/progressive-cookies.d.ts.map +1 -0
- package/dist/internal/progressive-cookies.js +43 -0
- package/dist/internal/progressive-cookies.js.map +1 -0
- package/dist/internal/public-env.d.ts +2 -0
- package/dist/internal/public-env.d.ts.map +1 -0
- package/dist/internal/public-env.js +13 -0
- package/dist/internal/public-env.js.map +1 -0
- package/dist/internal/render-document.d.ts +5 -44
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +51 -63
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/response-controls.d.ts +2 -0
- package/dist/internal/response-controls.d.ts.map +1 -1
- package/dist/internal/response-controls.js +26 -16
- package/dist/internal/response-controls.js.map +1 -1
- package/dist/internal/revive-dates.d.ts +16 -0
- package/dist/internal/revive-dates.d.ts.map +1 -0
- package/dist/internal/revive-dates.js +32 -0
- package/dist/internal/revive-dates.js.map +1 -0
- package/dist/internal/route-path.d.ts +14 -0
- package/dist/internal/route-path.d.ts.map +1 -0
- package/dist/internal/route-path.js +19 -0
- package/dist/internal/route-path.js.map +1 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +190 -20
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +33 -5
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/web-app.d.ts +25 -18
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +81 -24
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/wire-date.d.ts +3 -0
- package/dist/internal/wire-date.d.ts.map +1 -0
- package/dist/internal/wire-date.js +14 -0
- package/dist/internal/wire-date.js.map +1 -0
- package/dist/internal/without-query.d.ts +4 -0
- package/dist/internal/without-query.d.ts.map +1 -0
- package/dist/internal/without-query.js +9 -0
- package/dist/internal/without-query.js.map +1 -0
- package/dist/internal/zone-graph.d.ts +4 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +71 -20
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +59 -3
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +311 -41
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +2 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +29 -3
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +37 -4
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +4 -0
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/plugins/kit.d.ts +1 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +26 -11
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +9 -1
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +84 -7
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +17 -21
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/plugins/zone-guard.d.ts.map +1 -1
- package/dist/plugins/zone-guard.js +44 -3
- package/dist/plugins/zone-guard.js.map +1 -1
- package/dist/prerender.d.ts +5 -1
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +41 -19
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +8 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +17 -6
- package/dist/public-dir.js.map +1 -1
- package/dist/query.d.ts +13 -4
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +89 -26
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +25 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +1 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-types.d.ts.map +1 -1
- package/dist/route-types.js +20 -9
- package/dist/route-types.js.map +1 -1
- package/dist/router.d.ts +9 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +47 -50
- package/dist/router.js.map +1 -1
- package/dist/seo.d.ts +49 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +48 -0
- package/dist/seo.js.map +1 -0
- package/dist/server-components/build-info.d.ts +3 -0
- package/dist/server-components/build-info.d.ts.map +1 -0
- package/dist/server-components/build-info.js +7 -0
- package/dist/server-components/build-info.js.map +1 -0
- package/dist/server-components/build-target.d.ts +12 -0
- package/dist/server-components/build-target.d.ts.map +1 -0
- package/dist/server-components/build-target.js +153 -0
- package/dist/server-components/build-target.js.map +1 -0
- package/dist/server-components/build.d.ts +24 -0
- package/dist/server-components/build.d.ts.map +1 -0
- package/dist/server-components/build.js +366 -0
- package/dist/server-components/build.js.map +1 -0
- package/dist/server-components/bundle-proof.d.ts +3 -0
- package/dist/server-components/bundle-proof.d.ts.map +1 -0
- package/dist/server-components/bundle-proof.js +23 -0
- package/dist/server-components/bundle-proof.js.map +1 -0
- package/dist/server-components/client.d.ts +46 -0
- package/dist/server-components/client.d.ts.map +1 -0
- package/dist/server-components/client.js +594 -0
- package/dist/server-components/client.js.map +1 -0
- package/dist/server-components/definition.d.ts +11 -0
- package/dist/server-components/definition.d.ts.map +1 -0
- package/dist/server-components/definition.js +94 -0
- package/dist/server-components/definition.js.map +1 -0
- package/dist/server-components/dev-http.d.ts +5 -0
- package/dist/server-components/dev-http.d.ts.map +1 -0
- package/dist/server-components/dev-http.js +71 -0
- package/dist/server-components/dev-http.js.map +1 -0
- package/dist/server-components/error.d.ts +9 -0
- package/dist/server-components/error.d.ts.map +1 -0
- package/dist/server-components/error.js +24 -0
- package/dist/server-components/error.js.map +1 -0
- package/dist/server-components/frames.d.ts +7 -0
- package/dist/server-components/frames.d.ts.map +1 -0
- package/dist/server-components/frames.js +169 -0
- package/dist/server-components/frames.js.map +1 -0
- package/dist/server-components/html-policy.d.ts +6 -0
- package/dist/server-components/html-policy.d.ts.map +1 -0
- package/dist/server-components/html-policy.js +75 -0
- package/dist/server-components/html-policy.js.map +1 -0
- package/dist/server-components/html.d.ts +4 -0
- package/dist/server-components/html.d.ts.map +1 -0
- package/dist/server-components/html.js +67 -0
- package/dist/server-components/html.js.map +1 -0
- package/dist/server-components/islands.d.ts +44 -0
- package/dist/server-components/islands.d.ts.map +1 -0
- package/dist/server-components/islands.js +42 -0
- package/dist/server-components/islands.js.map +1 -0
- package/dist/server-components/native.d.ts +4 -0
- package/dist/server-components/native.d.ts.map +1 -0
- package/dist/server-components/native.js +81 -0
- package/dist/server-components/native.js.map +1 -0
- package/dist/server-components/portal.d.ts +6 -0
- package/dist/server-components/portal.d.ts.map +1 -0
- package/dist/server-components/portal.js +35 -0
- package/dist/server-components/portal.js.map +1 -0
- package/dist/server-components/props.d.ts +3 -0
- package/dist/server-components/props.d.ts.map +1 -0
- package/dist/server-components/props.js +14 -0
- package/dist/server-components/props.js.map +1 -0
- package/dist/server-components/protocol.d.ts +44 -0
- package/dist/server-components/protocol.d.ts.map +1 -0
- package/dist/server-components/protocol.js +139 -0
- package/dist/server-components/protocol.js.map +1 -0
- package/dist/server-components/range.d.ts +24 -0
- package/dist/server-components/range.d.ts.map +1 -0
- package/dist/server-components/range.js +263 -0
- package/dist/server-components/range.js.map +1 -0
- package/dist/server-components/registry.d.ts +6 -0
- package/dist/server-components/registry.d.ts.map +1 -0
- package/dist/server-components/registry.js +30 -0
- package/dist/server-components/registry.js.map +1 -0
- package/dist/server-components/response.d.ts +4 -0
- package/dist/server-components/response.d.ts.map +1 -0
- package/dist/server-components/response.js +32 -0
- package/dist/server-components/response.js.map +1 -0
- package/dist/server-components/router-integration.d.ts +9 -0
- package/dist/server-components/router-integration.d.ts.map +1 -0
- package/dist/server-components/router-integration.js +29 -0
- package/dist/server-components/router-integration.js.map +1 -0
- package/dist/server-components/runtime.d.ts +14 -0
- package/dist/server-components/runtime.d.ts.map +1 -0
- package/dist/server-components/runtime.js +276 -0
- package/dist/server-components/runtime.js.map +1 -0
- package/dist/server-components/scheduler.d.ts +14 -0
- package/dist/server-components/scheduler.d.ts.map +1 -0
- package/dist/server-components/scheduler.js +74 -0
- package/dist/server-components/scheduler.js.map +1 -0
- package/dist/server-components/schema.d.ts +4 -0
- package/dist/server-components/schema.d.ts.map +1 -0
- package/dist/server-components/schema.js +77 -0
- package/dist/server-components/schema.js.map +1 -0
- package/dist/server-components/server-entry.d.ts +12 -0
- package/dist/server-components/server-entry.d.ts.map +1 -0
- package/dist/server-components/server-entry.js +90 -0
- package/dist/server-components/server-entry.js.map +1 -0
- package/dist/server-components/source.d.ts +15 -0
- package/dist/server-components/source.d.ts.map +1 -0
- package/dist/server-components/source.js +269 -0
- package/dist/server-components/source.js.map +1 -0
- package/dist/server-components/state.d.ts +4 -0
- package/dist/server-components/state.d.ts.map +1 -0
- package/dist/server-components/state.js +123 -0
- package/dist/server-components/state.js.map +1 -0
- package/dist/server-components/stream.d.ts +6 -0
- package/dist/server-components/stream.d.ts.map +1 -0
- package/dist/server-components/stream.js +70 -0
- package/dist/server-components/stream.js.map +1 -0
- package/dist/server-components/trusted-html.d.ts +3 -0
- package/dist/server-components/trusted-html.d.ts.map +1 -0
- package/dist/server-components/trusted-html.js +14 -0
- package/dist/server-components/trusted-html.js.map +1 -0
- package/dist/server-components/types.d.ts +107 -0
- package/dist/server-components/types.d.ts.map +1 -0
- package/dist/server-components/types.js +2 -0
- package/dist/server-components/types.js.map +1 -0
- package/dist/server-components/typescript.d.ts +11 -0
- package/dist/server-components/typescript.d.ts.map +1 -0
- package/dist/server-components/typescript.js +28 -0
- package/dist/server-components/typescript.js.map +1 -0
- package/dist/server-components/value.d.ts +5 -0
- package/dist/server-components/value.d.ts.map +1 -0
- package/dist/server-components/value.js +77 -0
- package/dist/server-components/value.js.map +1 -0
- package/dist/server-components/vite.d.ts +12 -0
- package/dist/server-components/vite.d.ts.map +1 -0
- package/dist/server-components/vite.js +197 -0
- package/dist/server-components/vite.js.map +1 -0
- package/dist/server-components.d.ts +5 -0
- package/dist/server-components.d.ts.map +1 -0
- package/dist/server-components.js +4 -0
- package/dist/server-components.js.map +1 -0
- package/dist/service-worker.d.ts +8 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +20 -6
- package/dist/service-worker.js.map +1 -1
- package/dist/vite.d.ts +12 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +7 -1
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +64 -2
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +331 -28
- package/dist/zones.js.map +1 -1
- package/package.json +102 -4
- package/src/build-vite.ts +31 -9
- package/src/build.ts +109 -31
- package/src/cdn.ts +18 -12
- package/src/client.ts +48 -17
- package/src/deferred.ts +27 -9
- package/src/dev-feed.ts +24 -5
- package/src/dev-port.ts +4 -10
- package/src/dev-session.ts +4 -13
- package/src/dev.ts +29 -1
- package/src/draft.ts +35 -14
- package/src/fonts-google.ts +10 -0
- package/src/index.ts +5 -6
- package/src/internal/codegen.ts +60 -29
- package/src/internal/disposable-dir.ts +24 -0
- package/src/internal/entry-chunks.ts +18 -0
- package/src/internal/evidence-traversal.ts +57 -0
- package/src/internal/head-merge.ts +16 -0
- package/src/internal/js-string.ts +6 -1
- package/src/internal/mount-shadow.ts +1 -1
- package/src/internal/output-guard.ts +88 -7
- package/src/internal/package-entries.ts +331 -0
- package/src/internal/page-execution.ts +573 -114
- package/src/internal/parity.ts +15 -2
- package/src/internal/private-env.ts +27 -13
- package/src/internal/progressive-cookies.ts +60 -0
- package/src/internal/public-env.ts +14 -0
- package/src/internal/render-document.ts +66 -89
- package/src/internal/response-controls.ts +31 -14
- package/src/internal/revive-dates.ts +35 -0
- package/src/internal/route-path.ts +24 -0
- package/src/internal/secret-scan.ts +190 -18
- package/src/internal/server-boundary.ts +29 -5
- package/src/internal/web-app.ts +126 -42
- package/src/internal/wire-date.ts +16 -0
- package/src/internal/without-query.ts +8 -0
- package/src/internal/zone-graph.ts +78 -25
- package/src/isr.ts +364 -45
- package/src/llms-txt.ts +32 -3
- package/src/manifest.ts +38 -4
- package/src/open-resolved.ts +64 -0
- package/src/plugins/kit.ts +31 -11
- package/src/plugins/vite-leak-guard.ts +104 -8
- package/src/plugins/vite-zone-guard.ts +21 -19
- package/src/plugins/zone-guard.ts +41 -2
- package/src/prerender.ts +39 -18
- package/src/public-dir.ts +27 -8
- package/src/query.ts +107 -26
- package/src/render-seam.ts +30 -2
- package/src/route-types.ts +34 -7
- package/src/router.ts +66 -59
- package/src/seo.ts +68 -0
- package/src/server-components/build-info.ts +11 -0
- package/src/server-components/build-target.ts +194 -0
- package/src/server-components/build.ts +419 -0
- package/src/server-components/bundle-proof.ts +33 -0
- package/src/server-components/client.ts +663 -0
- package/src/server-components/definition.ts +125 -0
- package/src/server-components/dev-http.ts +75 -0
- package/src/server-components/error.ts +24 -0
- package/src/server-components/frames.ts +181 -0
- package/src/server-components/html-policy.ts +94 -0
- package/src/server-components/html.ts +81 -0
- package/src/server-components/islands.ts +111 -0
- package/src/server-components/native.ts +101 -0
- package/src/server-components/portal.ts +42 -0
- package/src/server-components/props.ts +19 -0
- package/src/server-components/protocol.ts +167 -0
- package/src/server-components/range.ts +313 -0
- package/src/server-components/registry.ts +34 -0
- package/src/server-components/response.ts +44 -0
- package/src/server-components/router-integration.ts +40 -0
- package/src/server-components/runtime.ts +376 -0
- package/src/server-components/scheduler.ts +84 -0
- package/src/server-components/schema.ts +69 -0
- package/src/server-components/server-entry.ts +98 -0
- package/src/server-components/source.ts +324 -0
- package/src/server-components/state.ts +143 -0
- package/src/server-components/stream.ts +71 -0
- package/src/server-components/trusted-html.ts +21 -0
- package/src/server-components/types.ts +138 -0
- package/src/server-components/typescript.ts +39 -0
- package/src/server-components/value.ts +82 -0
- package/src/server-components/vite.ts +242 -0
- package/src/server-components.ts +32 -0
- package/src/service-worker.ts +24 -7
- package/src/vite.ts +8 -1
- package/src/zones.ts +418 -32
package/src/isr.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
|
|
8
8
|
*/
|
|
9
|
+
import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
|
|
10
|
+
import { readBoundedBytes, TransportCodecError } from "@nifrajs/core/transport-codec"
|
|
9
11
|
import { isDraftEnabled } from "./draft.ts"
|
|
10
12
|
import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
11
13
|
|
|
@@ -22,6 +24,9 @@ export interface CachedResponse {
|
|
|
22
24
|
readonly revalidate: number
|
|
23
25
|
/** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
|
|
24
26
|
readonly tags?: readonly string[]
|
|
27
|
+
/** The store's {@link CacheStore.fence} as the render behind this entry began. A store that keeps
|
|
28
|
+
* purge order skips the write when this key or one of its tags was purged after it. */
|
|
29
|
+
readonly fence?: number | undefined
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
@@ -39,6 +44,13 @@ export interface CacheStore {
|
|
|
39
44
|
delete(key: string): Promise<void>
|
|
40
45
|
/** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
|
|
41
46
|
readonly invalidateTag?: (tag: string) => Promise<void>
|
|
47
|
+
/**
|
|
48
|
+
* The current point in this store's purge order. `withISR` takes one before each render and hands it
|
|
49
|
+
* back as {@link CachedResponse.fence}, and `set` skips that entry when its key or one of its tags
|
|
50
|
+
* was purged after the point. Without it, a purge that lands while a page renders is undone when the
|
|
51
|
+
* render stores the page it built from data read before the purge.
|
|
52
|
+
*/
|
|
53
|
+
readonly fence?: () => number | Promise<number>
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
|
|
@@ -145,6 +157,10 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
145
157
|
>()
|
|
146
158
|
private readonly max: number
|
|
147
159
|
private readonly tagEpoch = new Map<string, string>()
|
|
160
|
+
// Purge order: `fence` reads it; each purge advances it and records where, per key and per tag.
|
|
161
|
+
private order = 0
|
|
162
|
+
private readonly purgedKeys = new Map<string, number>()
|
|
163
|
+
private readonly purgedTags = new Map<string, number>()
|
|
148
164
|
|
|
149
165
|
constructor(options: MemoryCacheStoreOptions = {}) {
|
|
150
166
|
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
|
|
@@ -188,8 +204,14 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
188
204
|
return Promise.resolve(value)
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
fence(): number {
|
|
208
|
+
return this.order
|
|
209
|
+
}
|
|
210
|
+
|
|
191
211
|
set(key: string, value: CachedResponse): Promise<void> {
|
|
192
212
|
const tags = normalizeTags(value.tags)
|
|
213
|
+
if (value.fence !== undefined && this.purgedAfter(key, tags, value.fence))
|
|
214
|
+
return Promise.resolve()
|
|
193
215
|
const tagEpochs: Record<string, string> = {}
|
|
194
216
|
for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
|
|
195
217
|
this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
|
|
@@ -205,6 +227,7 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
205
227
|
|
|
206
228
|
delete(key: string): Promise<void> {
|
|
207
229
|
this.cache.delete(key)
|
|
230
|
+
this.markPurged(this.purgedKeys, key)
|
|
208
231
|
this.pruneTagEpochs()
|
|
209
232
|
return Promise.resolve()
|
|
210
233
|
}
|
|
@@ -212,6 +235,7 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
212
235
|
invalidateTag(tag: string): Promise<void> {
|
|
213
236
|
normalizeTag(tag)
|
|
214
237
|
this.tagEpoch.set(tag, crypto.randomUUID())
|
|
238
|
+
this.markPurged(this.purgedTags, tag)
|
|
215
239
|
// This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
|
|
216
240
|
// immediate and lets us discard epoch keys that no retained entry references anymore.
|
|
217
241
|
for (const [key, row] of this.cache) {
|
|
@@ -221,6 +245,22 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
221
245
|
return Promise.resolve()
|
|
222
246
|
}
|
|
223
247
|
|
|
248
|
+
private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
|
|
249
|
+
if ((this.purgedKeys.get(key) ?? 0) > fence) return true
|
|
250
|
+
return tags.some((tag) => (this.purgedTags.get(tag) ?? 0) > fence)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private markPurged(marks: Map<string, number>, name: string): void {
|
|
254
|
+
marks.delete(name)
|
|
255
|
+
marks.set(name, ++this.order)
|
|
256
|
+
// Bounded like the entries: a mark matters only to a render still in flight.
|
|
257
|
+
while (marks.size > this.max) {
|
|
258
|
+
const oldest = marks.keys().next().value
|
|
259
|
+
if (oldest === undefined) break
|
|
260
|
+
marks.delete(oldest)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
224
264
|
private pruneTagEpochs(): void {
|
|
225
265
|
if (this.tagEpoch.size === 0) return
|
|
226
266
|
const used = new Set<string>()
|
|
@@ -276,13 +316,22 @@ export interface KVCacheStoreOptions {
|
|
|
276
316
|
const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
277
317
|
if (typeof value !== "object" || value === null) return false
|
|
278
318
|
const v = value as Record<string, unknown>
|
|
319
|
+
// Everything a replay hands to `new Response` must be legal there, or a corrupt record would throw
|
|
320
|
+
// on every hit instead of reading as a miss.
|
|
279
321
|
if (
|
|
280
322
|
typeof v.body !== "string" ||
|
|
281
323
|
typeof v.status !== "number" ||
|
|
324
|
+
!Number.isInteger(v.status) ||
|
|
325
|
+
v.status < 200 ||
|
|
326
|
+
v.status > 599 ||
|
|
282
327
|
typeof v.storedAt !== "number" ||
|
|
328
|
+
!Number.isFinite(v.storedAt) ||
|
|
283
329
|
typeof v.revalidate !== "number" ||
|
|
330
|
+
!Number.isFinite(v.revalidate) ||
|
|
331
|
+
v.revalidate < 0 ||
|
|
284
332
|
typeof v.headers !== "object" ||
|
|
285
|
-
v.headers === null
|
|
333
|
+
v.headers === null ||
|
|
334
|
+
Array.isArray(v.headers)
|
|
286
335
|
) {
|
|
287
336
|
return false
|
|
288
337
|
}
|
|
@@ -294,13 +343,54 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
|
294
343
|
return false
|
|
295
344
|
}
|
|
296
345
|
}
|
|
297
|
-
|
|
346
|
+
const headers: Record<string, string> = {}
|
|
347
|
+
for (const [name, headerValue] of Object.entries(v.headers)) {
|
|
298
348
|
if (typeof headerValue !== "string") return false
|
|
349
|
+
headers[name] = headerValue
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
new Headers(headers)
|
|
353
|
+
} catch {
|
|
354
|
+
return false
|
|
299
355
|
}
|
|
300
356
|
return true
|
|
301
357
|
}
|
|
302
358
|
|
|
303
359
|
const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
|
|
360
|
+
const KV_PURGED_AT = "__nifraPurgedAt"
|
|
361
|
+
|
|
362
|
+
/** When a KV page key was purged, from the marker `delete` leaves; `-Infinity` for a page or nothing. */
|
|
363
|
+
function kvPurgedAt(raw: string | null): number {
|
|
364
|
+
if (raw === null || !raw.includes(KV_PURGED_AT)) return Number.NEGATIVE_INFINITY
|
|
365
|
+
try {
|
|
366
|
+
const parsed: unknown = JSON.parse(raw)
|
|
367
|
+
if (typeof parsed === "object" && parsed !== null && KV_PURGED_AT in parsed) {
|
|
368
|
+
const at = parsed[KV_PURGED_AT]
|
|
369
|
+
if (typeof at === "number") return at
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Not a marker.
|
|
373
|
+
}
|
|
374
|
+
return Number.NEGATIVE_INFINITY
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** When a tag epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time is older
|
|
378
|
+
* than any render. */
|
|
379
|
+
function kvEpochTime(epoch: string): number {
|
|
380
|
+
const at = epoch.indexOf(":")
|
|
381
|
+
const time = at === -1 ? Number.NaN : Number(epoch.slice(0, at))
|
|
382
|
+
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let lastKvTime = 0
|
|
386
|
+
/** Wall-clock ms, strictly increasing per process so a purge orders after a render it follows. Not
|
|
387
|
+
* `timeOrigin + performance.now()`: that clock stops while the host sleeps, so a purge from a process
|
|
388
|
+
* that slept would order before renders other instances began earlier. */
|
|
389
|
+
const kvClock = (): number => {
|
|
390
|
+
const now = Date.now()
|
|
391
|
+
lastKvTime = now > lastKvTime ? now : lastKvTime + 0.001
|
|
392
|
+
return lastKvTime
|
|
393
|
+
}
|
|
304
394
|
|
|
305
395
|
type StoredTaggedResponse = CachedResponse & {
|
|
306
396
|
readonly __nifraTagEpochs?: Readonly<Record<string, string>>
|
|
@@ -373,10 +463,18 @@ export class KVCacheStore implements CacheStore {
|
|
|
373
463
|
return value
|
|
374
464
|
}
|
|
375
465
|
|
|
466
|
+
/** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so the
|
|
467
|
+
* fence holds only for purges this location has already seen. */
|
|
468
|
+
fence(): number {
|
|
469
|
+
return kvClock()
|
|
470
|
+
}
|
|
471
|
+
|
|
376
472
|
async set(key: string, value: CachedResponse): Promise<void> {
|
|
377
|
-
const
|
|
473
|
+
const { fence, ...entry } = value
|
|
474
|
+
if (fence !== undefined && kvPurgedAt(await this.kv.get(key)) > fence) return
|
|
475
|
+
const tags = normalizeTags(entry.tags)
|
|
378
476
|
if (tags.length === 0) {
|
|
379
|
-
await this.kv.put(key, JSON.stringify(
|
|
477
|
+
await this.kv.put(key, JSON.stringify(entry), this.putOptions)
|
|
380
478
|
return
|
|
381
479
|
}
|
|
382
480
|
const epochs = Object.fromEntries(
|
|
@@ -384,24 +482,29 @@ export class KVCacheStore implements CacheStore {
|
|
|
384
482
|
tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
|
|
385
483
|
),
|
|
386
484
|
)
|
|
485
|
+
if (fence !== undefined && Object.values(epochs).some((epoch) => kvEpochTime(epoch) > fence)) {
|
|
486
|
+
return
|
|
487
|
+
}
|
|
387
488
|
const stored: StoredTaggedResponse = {
|
|
388
|
-
...
|
|
489
|
+
...entry,
|
|
389
490
|
tags,
|
|
390
491
|
__nifraTagEpochs: epochs,
|
|
391
492
|
}
|
|
392
493
|
await this.kv.put(key, JSON.stringify(stored), this.putOptions)
|
|
393
494
|
}
|
|
394
495
|
|
|
496
|
+
/** Leaves a marker rather than removing the key, so `set` can refuse a render that began before
|
|
497
|
+
* the purge. The marker reads as a miss. */
|
|
395
498
|
async delete(key: string): Promise<void> {
|
|
396
|
-
await this.kv.
|
|
499
|
+
await this.kv.put(key, JSON.stringify({ [KV_PURGED_AT]: kvClock() }), this.putOptions)
|
|
397
500
|
}
|
|
398
501
|
|
|
399
502
|
async invalidateTag(tag: string): Promise<void> {
|
|
400
503
|
normalizeTag(tag)
|
|
401
504
|
// Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
|
|
402
505
|
// pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
|
|
403
|
-
// written before the invalidation when their page key still exists.
|
|
404
|
-
await this.kv.put(this.tagKey(tag), crypto.randomUUID())
|
|
506
|
+
// written before the invalidation when their page key still exists. The time orders it for `set`.
|
|
507
|
+
await this.kv.put(this.tagKey(tag), `${kvClock()}:${crypto.randomUUID()}`)
|
|
405
508
|
}
|
|
406
509
|
|
|
407
510
|
private tagKey(tag: string): string {
|
|
@@ -450,17 +553,10 @@ export function withoutCacheChannel(res: Response): Response {
|
|
|
450
553
|
if (!res.headers.has(ISR_REVALIDATE_HEADER) && !res.headers.has(ISR_REVALIDATE_TAGS_HEADER)) {
|
|
451
554
|
return res
|
|
452
555
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
} catch {
|
|
458
|
-
// A `fetch()` response has immutable headers: rebuild it around the same body instead.
|
|
459
|
-
const headers = new Headers(res.headers)
|
|
460
|
-
headers.delete(ISR_REVALIDATE_HEADER)
|
|
461
|
-
headers.delete(ISR_REVALIDATE_TAGS_HEADER)
|
|
462
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
463
|
-
}
|
|
556
|
+
const target = writableResponse(res)
|
|
557
|
+
target.headers.delete(ISR_REVALIDATE_HEADER)
|
|
558
|
+
target.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
|
|
559
|
+
return target
|
|
464
560
|
}
|
|
465
561
|
|
|
466
562
|
export interface ISROptions {
|
|
@@ -473,8 +569,17 @@ export interface ISROptions {
|
|
|
473
569
|
readonly now: () => number
|
|
474
570
|
/** Cache key for a request. Default: `origin + pathname`, plus the query parameters `query` admits,
|
|
475
571
|
* so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
|
|
476
|
-
* this request (it goes straight to the app, uncached). Replaces `query` entirely.
|
|
572
|
+
* this request (it goes straight to the app, uncached). Replaces `query` entirely. Under the default
|
|
573
|
+
* key a request carrying `Authorization` or a cookie outside `anonymousCookies` never reads the
|
|
574
|
+
* store; a custom key takes that decision over, so include whatever identifies the visitor in it. */
|
|
477
575
|
readonly key?: (req: Request) => string | null
|
|
576
|
+
/**
|
|
577
|
+
* Cookie names that never change the page, such as a consent or analytics cookie. Under the default
|
|
578
|
+
* key a request carrying `Authorization` or any other cookie skips the stored page and renders fresh,
|
|
579
|
+
* because a signed-in visitor's page can differ from the anonymous one; a request whose cookies are
|
|
580
|
+
* all listed here is served from the cache like one with none. Default none.
|
|
581
|
+
*/
|
|
582
|
+
readonly anonymousCookies?: readonly string[]
|
|
478
583
|
/**
|
|
479
584
|
* How the query string reaches the default key. Default `"bypass"`: a request carrying any query
|
|
480
585
|
* parameter skips the cache - rendered fresh, never stored - so `?a=1`, `?a=2`, ... cannot each
|
|
@@ -488,6 +593,41 @@ export interface ISROptions {
|
|
|
488
593
|
* set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
|
|
489
594
|
* fresh, and a draft render is never written to the store (it can't poison the public cache). */
|
|
490
595
|
readonly draftSecret?: string
|
|
596
|
+
/**
|
|
597
|
+
* Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}. The request
|
|
598
|
+
* is served without the cache either way - a failed read is a miss, a failed write still returns the
|
|
599
|
+
* rendered page, a failed regeneration keeps the stale entry - so a store outage never changes a
|
|
600
|
+
* status. Default: one `console.error` line per operation per minute, never naming the cache key.
|
|
601
|
+
*/
|
|
602
|
+
readonly onStoreError?: (error: unknown, info: { readonly op: ISRStoreOp }) => void
|
|
603
|
+
/**
|
|
604
|
+
* Longest a store call may take (ms) before it counts as failed. Default none, so a hung store holds
|
|
605
|
+
* the request; set it when the store is a network service.
|
|
606
|
+
*/
|
|
607
|
+
readonly storeTimeoutMs?: number
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The store operation an {@link ISROptions.onStoreError} report is about. */
|
|
611
|
+
export type ISRStoreOp = "get" | "set" | "delete" | "fence"
|
|
612
|
+
|
|
613
|
+
const STORE_FAILED = Symbol("nifra.isr.store-failed")
|
|
614
|
+
const STORE_REPORT_INTERVAL_MS = 60_000
|
|
615
|
+
const STORE_REPORT_DETAIL_CHARS = 200
|
|
616
|
+
|
|
617
|
+
/** The error's own words with the cache key cut out, since a store may name the key it failed on. */
|
|
618
|
+
function storeErrorDetail(error: unknown, key: string): string {
|
|
619
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
620
|
+
let path: string | undefined
|
|
621
|
+
try {
|
|
622
|
+
path = new URL(key).pathname
|
|
623
|
+
} catch {
|
|
624
|
+
path = undefined
|
|
625
|
+
}
|
|
626
|
+
let redacted = text.split(key).join("<key>")
|
|
627
|
+
if (path !== undefined && path.length > 1) redacted = redacted.split(path).join("<key>")
|
|
628
|
+
return redacted.length > STORE_REPORT_DETAIL_CHARS
|
|
629
|
+
? `${redacted.slice(0, STORE_REPORT_DETAIL_CHARS)}...`
|
|
630
|
+
: redacted
|
|
491
631
|
}
|
|
492
632
|
|
|
493
633
|
/** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
|
|
@@ -554,8 +694,19 @@ const cacheControlHas = (headers: Headers, names: readonly string[]): boolean =>
|
|
|
554
694
|
const hasSetCookie = (headers: Headers): boolean =>
|
|
555
695
|
headers.has("set-cookie") || (headers.getSetCookie?.().length ?? 0) > 0
|
|
556
696
|
|
|
697
|
+
/** Whether every cookie in a `Cookie` header is named in `names`. */
|
|
698
|
+
const cookieNamesWithin = (header: string, names: ReadonlySet<string>): boolean => {
|
|
699
|
+
for (const pair of header.split(";")) {
|
|
700
|
+
const name = pair.split("=", 1)[0]?.trim() ?? ""
|
|
701
|
+
if (name !== "" && !names.has(name)) return false
|
|
702
|
+
}
|
|
703
|
+
return true
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// An authentication stage may read any header, so a request it accepted is private whatever carried
|
|
707
|
+
// the credential. The mark is set on the request the app was handed, which is why this runs after it.
|
|
557
708
|
const requestCarriesPrivateState = (req: Request): boolean =>
|
|
558
|
-
req.headers.has("authorization") || req.headers.has("cookie")
|
|
709
|
+
req.headers.has("authorization") || req.headers.has("cookie") || isAuthenticatedRequest(req)
|
|
559
710
|
|
|
560
711
|
const responseIsExplicitlyPublic = (res: Response): boolean =>
|
|
561
712
|
cacheControlHas(res.headers, ["public"])
|
|
@@ -575,7 +726,8 @@ const responseDeclaresVary = (res: Response): boolean => {
|
|
|
575
726
|
/**
|
|
576
727
|
* Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
|
|
577
728
|
* full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
|
|
578
|
-
* the data header, and, for a request carrying a cookie or Authorization
|
|
729
|
+
* the data header, and, for a request carrying a cookie or Authorization or accepted by an
|
|
730
|
+
* authentication stage, an explicit `public`.
|
|
579
731
|
* The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
|
|
580
732
|
*/
|
|
581
733
|
export const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
@@ -653,8 +805,22 @@ function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Recor
|
|
|
653
805
|
return out
|
|
654
806
|
}
|
|
655
807
|
|
|
656
|
-
|
|
657
|
-
|
|
808
|
+
/**
|
|
809
|
+
* Whether a regeneration's answer withdraws the stored page: it is gone or moved, or, rendered for a
|
|
810
|
+
* request with no cookie or Authorization, refused (401, 403) or declared `private`/`no-store`. A
|
|
811
|
+
* signed-in visitor's own 403 or private copy says nothing about the shared page. A 5xx or 429 keeps
|
|
812
|
+
* serving the stale copy.
|
|
813
|
+
*/
|
|
814
|
+
const isInvalidationResponse = (req: Request, res: Response): boolean => {
|
|
815
|
+
if (res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400))
|
|
816
|
+
return true
|
|
817
|
+
if (requestCarriesPrivateState(req)) return false
|
|
818
|
+
return (
|
|
819
|
+
res.status === 401 ||
|
|
820
|
+
res.status === 403 ||
|
|
821
|
+
(res.status >= 200 && res.status < 300 && cacheControlHas(res.headers, ["private", "no-store"]))
|
|
822
|
+
)
|
|
823
|
+
}
|
|
658
824
|
|
|
659
825
|
/**
|
|
660
826
|
* Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
|
|
@@ -673,8 +839,77 @@ export function withISR(
|
|
|
673
839
|
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
674
840
|
const { store, now } = options
|
|
675
841
|
assertRevalidate(options.revalidate, "revalidate")
|
|
842
|
+
const storeTimeoutMs = options.storeTimeoutMs
|
|
843
|
+
if (storeTimeoutMs !== undefined && !(Number.isFinite(storeTimeoutMs) && storeTimeoutMs > 0)) {
|
|
844
|
+
throw new RangeError("[nifra/web] ISR storeTimeoutMs must be a positive finite number")
|
|
845
|
+
}
|
|
846
|
+
const lastReport = new Map<ISRStoreOp, number>()
|
|
847
|
+
const reportStoreError = (op: ISRStoreOp, key: string, error: unknown): void => {
|
|
848
|
+
try {
|
|
849
|
+
if (options.onStoreError !== undefined) {
|
|
850
|
+
options.onStoreError(error, { op })
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
const last = lastReport.get(op)
|
|
854
|
+
const at = now()
|
|
855
|
+
if (last !== undefined && at - last < STORE_REPORT_INTERVAL_MS) return
|
|
856
|
+
lastReport.set(op, at)
|
|
857
|
+
console.error(
|
|
858
|
+
`[nifra/web] ISR store ${op} failed; serving without the cache (${storeErrorDetail(error, key)})`,
|
|
859
|
+
)
|
|
860
|
+
} catch {
|
|
861
|
+
// A failing reporter must not turn a served page into an error.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
// Runs one store call; a throw, a rejection or a timeout is reported and answered with STORE_FAILED.
|
|
865
|
+
const attempt = async <T>(
|
|
866
|
+
op: ISRStoreOp,
|
|
867
|
+
key: string,
|
|
868
|
+
call: () => Promise<T>,
|
|
869
|
+
): Promise<T | typeof STORE_FAILED> => {
|
|
870
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
871
|
+
try {
|
|
872
|
+
const pending = call()
|
|
873
|
+
if (storeTimeoutMs === undefined) return await pending
|
|
874
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
875
|
+
timer = setTimeout(() => {
|
|
876
|
+
const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
|
|
877
|
+
error.name = "TimeoutError"
|
|
878
|
+
reject(error)
|
|
879
|
+
}, storeTimeoutMs)
|
|
880
|
+
})
|
|
881
|
+
return await Promise.race([pending, timeout])
|
|
882
|
+
} catch (error) {
|
|
883
|
+
reportStoreError(op, key, error)
|
|
884
|
+
return STORE_FAILED
|
|
885
|
+
} finally {
|
|
886
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// A store without purge order, or one whose fence failed, stores the render unfenced.
|
|
890
|
+
const fenceFor = async (key: string): Promise<number | undefined> => {
|
|
891
|
+
const { fence } = store
|
|
892
|
+
if (fence === undefined) return undefined
|
|
893
|
+
const taken = await attempt("fence", key, async () => fence.call(store))
|
|
894
|
+
return taken === STORE_FAILED ? undefined : taken
|
|
895
|
+
}
|
|
676
896
|
const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
|
|
677
897
|
const draftSecret = options.draftSecret
|
|
898
|
+
const anonymousCookies = new Set(options.anonymousCookies ?? [])
|
|
899
|
+
// A request whose cookies are all anonymous is judged for storage as if it carried none.
|
|
900
|
+
const judged = (req: Request): Request => {
|
|
901
|
+
if (
|
|
902
|
+
anonymousCookies.size === 0 ||
|
|
903
|
+
req.headers.has("authorization") ||
|
|
904
|
+
isAuthenticatedRequest(req)
|
|
905
|
+
)
|
|
906
|
+
return req
|
|
907
|
+
const cookie = req.headers.get("cookie")
|
|
908
|
+
if (cookie === null || !cookieNamesWithin(cookie, anonymousCookies)) return req
|
|
909
|
+
const headers = new Headers(req.headers)
|
|
910
|
+
headers.delete("cookie")
|
|
911
|
+
return new Request(req.url, { method: req.method, headers })
|
|
912
|
+
}
|
|
678
913
|
const regenerating = new Set<string>()
|
|
679
914
|
openCacheChannel(app)
|
|
680
915
|
// An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
|
|
@@ -727,10 +962,13 @@ export function withISR(
|
|
|
727
962
|
req: Request,
|
|
728
963
|
platform: ISRPlatform | undefined,
|
|
729
964
|
key: string,
|
|
965
|
+
remember: boolean,
|
|
966
|
+
stored?: (entry: CachedResponse) => void,
|
|
730
967
|
): Promise<Response> => {
|
|
968
|
+
const fence = await fenceFor(key)
|
|
731
969
|
const res = await app.fetch(req, platform)
|
|
732
|
-
if (!isCacheablePage(req, res)) {
|
|
733
|
-
rememberUncacheable(key, res)
|
|
970
|
+
if (!isCacheablePage(judged(req), res)) {
|
|
971
|
+
if (remember) rememberUncacheable(key, res)
|
|
734
972
|
return withoutCacheChannel(res)
|
|
735
973
|
}
|
|
736
974
|
uncacheable.delete(key)
|
|
@@ -743,14 +981,49 @@ export function withISR(
|
|
|
743
981
|
storedAt: now(),
|
|
744
982
|
revalidate: ttlMs(res),
|
|
745
983
|
...(tags.length === 0 ? {} : { tags }),
|
|
984
|
+
fence,
|
|
746
985
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
986
|
+
// A failed write still answers with the page, and still hands it to concurrent misses: whether it
|
|
987
|
+
// may be shared was decided above, not by the store.
|
|
988
|
+
await attempt("set", key, () => store.set(key, entry))
|
|
989
|
+
stored?.(entry)
|
|
990
|
+
return fresh(entry)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const fresh = (entry: CachedResponse): Response => {
|
|
994
|
+
const res = new Response(entry.body, {
|
|
995
|
+
status: entry.status,
|
|
750
996
|
headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
|
|
751
997
|
})
|
|
752
|
-
advertise(
|
|
753
|
-
return
|
|
998
|
+
advertise(res.headers, entry.revalidate, entry.tags ?? [])
|
|
999
|
+
return res
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Cold misses in flight per key. Concurrent misses wait for the first one's stored entry instead
|
|
1003
|
+
// of each rendering the page; a render that is not stored (private, failed) sends each waiter to
|
|
1004
|
+
// render for itself, so a waiter never receives a page that was not safe to share.
|
|
1005
|
+
const coldRenders = new Map<string, Promise<CachedResponse | undefined>>()
|
|
1006
|
+
const renderCold = async (
|
|
1007
|
+
req: Request,
|
|
1008
|
+
platform: ISRPlatform | undefined,
|
|
1009
|
+
key: string,
|
|
1010
|
+
): Promise<Response> => {
|
|
1011
|
+
const pending = coldRenders.get(key)
|
|
1012
|
+
if (pending !== undefined) {
|
|
1013
|
+
const entry = await pending
|
|
1014
|
+
return entry === undefined ? render(req, platform, key, true) : fresh(entry)
|
|
1015
|
+
}
|
|
1016
|
+
const shared = Promise.withResolvers<CachedResponse | undefined>()
|
|
1017
|
+
coldRenders.set(key, shared.promise)
|
|
1018
|
+
let entry: CachedResponse | undefined
|
|
1019
|
+
try {
|
|
1020
|
+
return await render(req, platform, key, true, (stored) => {
|
|
1021
|
+
entry = stored
|
|
1022
|
+
})
|
|
1023
|
+
} finally {
|
|
1024
|
+
coldRenders.delete(key)
|
|
1025
|
+
shared.resolve(entry)
|
|
1026
|
+
}
|
|
754
1027
|
}
|
|
755
1028
|
|
|
756
1029
|
// Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
|
|
@@ -763,22 +1036,26 @@ export function withISR(
|
|
|
763
1036
|
if (regenerating.has(key)) return
|
|
764
1037
|
regenerating.add(key)
|
|
765
1038
|
try {
|
|
1039
|
+
const fence = await fenceFor(key)
|
|
766
1040
|
const res = await app.fetch(req, platform)
|
|
767
|
-
if (isCacheablePage(req, res)) {
|
|
1041
|
+
if (isCacheablePage(judged(req), res)) {
|
|
768
1042
|
const tags = tagsOf(res)
|
|
769
|
-
|
|
1043
|
+
const entry: CachedResponse = {
|
|
770
1044
|
body: await res.text(),
|
|
771
1045
|
status: res.status,
|
|
772
1046
|
headers: headersOf(res),
|
|
773
1047
|
storedAt: now(),
|
|
774
1048
|
revalidate: ttlMs(res),
|
|
775
1049
|
...(tags.length === 0 ? {} : { tags }),
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
//
|
|
781
|
-
|
|
1050
|
+
fence,
|
|
1051
|
+
}
|
|
1052
|
+
await attempt("set", key, () => store.set(key, entry))
|
|
1053
|
+
} else if (isInvalidationResponse(judged(req), res)) {
|
|
1054
|
+
// A withdrawal is authoritative. Keeping the old entry would make a withdrawn page stay
|
|
1055
|
+
// publicly visible forever, because every stale request would retry the same non-cacheable
|
|
1056
|
+
// response while continuing to serve the old body.
|
|
1057
|
+
await res.body?.cancel()
|
|
1058
|
+
await attempt("delete", key, () => store.delete(key))
|
|
782
1059
|
} else {
|
|
783
1060
|
await res.body?.cancel()
|
|
784
1061
|
}
|
|
@@ -793,7 +1070,15 @@ export function withISR(
|
|
|
793
1070
|
// A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
|
|
794
1071
|
// by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
|
|
795
1072
|
// the loader payload. (The write path already refuses to cache data-mode responses.)
|
|
796
|
-
const
|
|
1073
|
+
const privateRoute: unknown = Reflect.get(app, Symbol.for("nifra.web.private-route"))
|
|
1074
|
+
const ownsPrivate = typeof privateRoute === "function" && privateRoute(req) === true
|
|
1075
|
+
const key =
|
|
1076
|
+
req.method === "GET" &&
|
|
1077
|
+
req.headers.get("x-nifra-data") === null &&
|
|
1078
|
+
!req.headers.has("x-nifra-component") &&
|
|
1079
|
+
!ownsPrivate
|
|
1080
|
+
? keyOf(req)
|
|
1081
|
+
: null
|
|
797
1082
|
if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
|
|
798
1083
|
|
|
799
1084
|
// Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
|
|
@@ -802,13 +1087,18 @@ export function withISR(
|
|
|
802
1087
|
return withoutCacheChannel(await app.fetch(req, platform))
|
|
803
1088
|
}
|
|
804
1089
|
|
|
1090
|
+
// A cookie or Authorization request may be personalized: under the default key it never reads
|
|
1091
|
+
// the store, and its private render never marks the URL uncacheable for everyone else.
|
|
1092
|
+
const personal = options.key === undefined && requestCarriesPrivateState(judged(req))
|
|
1093
|
+
|
|
805
1094
|
const until = uncacheable.get(key)
|
|
806
1095
|
if (until !== undefined) {
|
|
807
|
-
if (now() < until) return render(req, platform, key)
|
|
1096
|
+
if (now() < until) return render(req, platform, key, !personal)
|
|
808
1097
|
uncacheable.delete(key)
|
|
809
1098
|
}
|
|
810
1099
|
|
|
811
|
-
const
|
|
1100
|
+
const read = personal ? undefined : await attempt("get", key, () => store.get(key))
|
|
1101
|
+
const hit = read === STORE_FAILED ? undefined : read
|
|
812
1102
|
if (hit !== undefined) {
|
|
813
1103
|
if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
|
|
814
1104
|
// Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
|
|
@@ -817,7 +1107,7 @@ export function withISR(
|
|
|
817
1107
|
else void task.catch(() => {})
|
|
818
1108
|
return served(hit, "stale")
|
|
819
1109
|
}
|
|
820
|
-
return render(req, platform, key)
|
|
1110
|
+
return personal ? render(req, platform, key, false) : renderCold(req, platform, key)
|
|
821
1111
|
}
|
|
822
1112
|
Object.defineProperty(handler, CACHE_CHANNEL, {
|
|
823
1113
|
value: () => {
|
|
@@ -850,6 +1140,8 @@ export interface CdnPurgeTarget {
|
|
|
850
1140
|
/** Most paths and tags one revalidation request may name, so a leaked token cannot fan out. */
|
|
851
1141
|
export const MAX_REVALIDATE_PATHS = 100
|
|
852
1142
|
export const MAX_REVALIDATE_TAGS = MAX_ISR_TAGS
|
|
1143
|
+
// Room for a full batch of long paths; a purge body past it is refused before it is buffered.
|
|
1144
|
+
const MAX_REVALIDATE_BODY_BYTES = 1024 * 1024
|
|
853
1145
|
|
|
854
1146
|
export interface RevalidateEndpointOptions {
|
|
855
1147
|
readonly store: CacheStore
|
|
@@ -870,6 +1162,13 @@ export interface RevalidateEndpointOptions {
|
|
|
870
1162
|
* the purge, `202` when it is queued (rate limit or retry), and `502` when the CDN refused it.
|
|
871
1163
|
*/
|
|
872
1164
|
readonly cdn?: CdnPurgeTarget
|
|
1165
|
+
/**
|
|
1166
|
+
* Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding
|
|
1167
|
+
* loader data. A tag purge invalidates each of them first, then the page store, then the CDN, so a
|
|
1168
|
+
* page regenerated mid-purge cannot be rebuilt from stale data. A failure answers `500` before the
|
|
1169
|
+
* page store is touched, so a retry purges everything again. Path purges do not reach them.
|
|
1170
|
+
*/
|
|
1171
|
+
readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
|
|
873
1172
|
}
|
|
874
1173
|
|
|
875
1174
|
const stringList = (value: unknown): string[] | null | undefined => {
|
|
@@ -884,7 +1183,7 @@ const stringList = (value: unknown): string[] | null | undefined => {
|
|
|
884
1183
|
* `?path=/blog/x`, `?tag=products`, a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body, or a
|
|
885
1184
|
* batch `{ "paths": [...], "tags": [...] }` of at most 100 paths and 32 tags.
|
|
886
1185
|
* The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
|
|
887
|
-
* non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
1186
|
+
* a JSON body over 1 MiB → `413`; non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
888
1187
|
* `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
|
|
889
1188
|
*/
|
|
890
1189
|
export function revalidateEndpoint(
|
|
@@ -909,7 +1208,15 @@ export function revalidateEndpoint(
|
|
|
909
1208
|
let tag = url.searchParams.get("tag")
|
|
910
1209
|
let batch: { paths: string[]; tags: string[] } | undefined
|
|
911
1210
|
if (path === null && tag === null) {
|
|
912
|
-
|
|
1211
|
+
let body: unknown = null
|
|
1212
|
+
try {
|
|
1213
|
+
const bytes = await readBoundedBytes(new Response(req.body, { headers: req.headers }), {
|
|
1214
|
+
maxBytes: MAX_REVALIDATE_BODY_BYTES,
|
|
1215
|
+
})
|
|
1216
|
+
body = JSON.parse(new TextDecoder().decode(bytes))
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
if (error instanceof TransportCodecError) return jsonError(413, "body_too_large")
|
|
1219
|
+
}
|
|
913
1220
|
if (typeof body === "object" && body !== null) {
|
|
914
1221
|
const candidate = body as { path?: unknown; tag?: unknown; paths?: unknown; tags?: unknown }
|
|
915
1222
|
if (candidate.paths !== undefined || candidate.tags !== undefined) {
|
|
@@ -953,6 +1260,18 @@ export function revalidateEndpoint(
|
|
|
953
1260
|
return jsonError(501, "tag_invalidation_not_supported")
|
|
954
1261
|
}
|
|
955
1262
|
|
|
1263
|
+
if (tags.length > 0 && options.dataCaches !== undefined) {
|
|
1264
|
+
try {
|
|
1265
|
+
for (const cache of options.dataCaches) {
|
|
1266
|
+
for (const candidate of tags) await cache.invalidateTag(candidate)
|
|
1267
|
+
}
|
|
1268
|
+
} catch {
|
|
1269
|
+
return Response.json(
|
|
1270
|
+
{ ok: false, error: "data_cache_invalidation_failed" },
|
|
1271
|
+
{ status: 500 },
|
|
1272
|
+
)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
956
1275
|
for (const key of keys) await options.store.delete(key)
|
|
957
1276
|
for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
|
|
958
1277
|
const done =
|