@nifrajs/web 5.5.1 → 5.7.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/CHANGELOG.md +243 -0
- package/README.md +21 -11
- package/dist/boundary.d.ts +67 -7
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +6 -4
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +3 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +9 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +10 -0
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +174 -23
- package/dist/client.js.map +1 -1
- package/dist/csp.d.ts +8 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +11 -2
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts +6 -4
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +10 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev-feed.d.ts.map +1 -1
- package/dist/dev-feed.js +11 -2
- package/dist/dev-feed.js.map +1 -1
- package/dist/dev-session.d.ts.map +1 -1
- package/dist/dev-session.js +13 -6
- package/dist/dev-session.js.map +1 -1
- package/dist/dev.d.ts +4 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +12 -5
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +4 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -1
- package/dist/diagnostic-catalog.js +48 -0
- package/dist/diagnostic-catalog.js.map +1 -1
- package/dist/diagnostic.d.ts +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +1 -1
- package/dist/diagnostic.js.map +1 -1
- package/dist/fn.d.ts +5 -3
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +20 -5
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +13 -3
- package/dist/fs.js.map +1 -1
- package/dist/identity.d.ts +22 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +60 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +2 -1
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
- package/dist/internal/cdn/cloudflare.js +2 -1
- package/dist/internal/cdn/cloudflare.js.map +1 -1
- package/dist/internal/certified-components.d.ts +1 -1
- package/dist/internal/certified-components.js +1 -1
- package/dist/internal/codegen.d.ts +11 -10
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +73 -10
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-websocket.d.ts +73 -0
- package/dist/internal/dev-websocket.d.ts.map +1 -0
- package/dist/internal/dev-websocket.js +145 -0
- package/dist/internal/dev-websocket.js.map +1 -0
- package/dist/internal/hole-runtime.d.ts +66 -0
- package/dist/internal/hole-runtime.d.ts.map +1 -0
- package/dist/internal/hole-runtime.js +211 -0
- package/dist/internal/hole-runtime.js.map +1 -0
- package/dist/internal/identity-epoch.d.ts +50 -0
- package/dist/internal/identity-epoch.d.ts.map +1 -0
- package/dist/internal/identity-epoch.js +180 -0
- package/dist/internal/identity-epoch.js.map +1 -0
- package/dist/internal/invalidation-contract.d.ts +23 -0
- package/dist/internal/invalidation-contract.d.ts.map +1 -0
- package/dist/internal/invalidation-contract.js +11 -0
- package/dist/internal/invalidation-contract.js.map +1 -0
- package/dist/internal/invalidation.d.ts +62 -0
- package/dist/internal/invalidation.d.ts.map +1 -0
- package/dist/internal/invalidation.js +149 -0
- package/dist/internal/invalidation.js.map +1 -0
- package/dist/internal/island-bindings.d.ts +51 -0
- package/dist/internal/island-bindings.d.ts.map +1 -0
- package/dist/internal/island-bindings.js +147 -0
- package/dist/internal/island-bindings.js.map +1 -0
- package/dist/internal/island-list.d.ts +26 -0
- package/dist/internal/island-list.d.ts.map +1 -0
- package/dist/internal/island-list.js +78 -0
- package/dist/internal/island-list.js.map +1 -0
- package/dist/internal/island-mount.d.ts +4 -0
- package/dist/internal/island-mount.d.ts.map +1 -0
- package/dist/internal/island-mount.js +66 -0
- package/dist/internal/island-mount.js.map +1 -0
- package/dist/internal/island-owner.d.ts +5 -0
- package/dist/internal/island-owner.d.ts.map +1 -0
- package/dist/internal/island-owner.js +20 -0
- package/dist/internal/island-owner.js.map +1 -0
- package/dist/internal/island-resource.d.ts +30 -0
- package/dist/internal/island-resource.d.ts.map +1 -0
- package/dist/internal/island-resource.js +57 -0
- package/dist/internal/island-resource.js.map +1 -0
- package/dist/internal/island-signals.d.ts +51 -0
- package/dist/internal/island-signals.d.ts.map +1 -0
- package/dist/internal/island-signals.js +220 -0
- package/dist/internal/island-signals.js.map +1 -0
- package/dist/internal/keep-alive-table.d.ts +23 -0
- package/dist/internal/keep-alive-table.d.ts.map +1 -0
- package/dist/internal/keep-alive-table.js +53 -0
- package/dist/internal/keep-alive-table.js.map +1 -0
- package/dist/internal/live-contract.d.ts +101 -0
- package/dist/internal/live-contract.d.ts.map +1 -0
- package/dist/internal/live-contract.js +5 -0
- package/dist/internal/live-contract.js.map +1 -0
- package/dist/internal/live-queries.d.ts +22 -0
- package/dist/internal/live-queries.d.ts.map +1 -0
- package/dist/internal/live-queries.js +54 -0
- package/dist/internal/live-queries.js.map +1 -0
- package/dist/internal/page-events.d.ts +10 -0
- package/dist/internal/page-events.d.ts.map +1 -0
- package/dist/internal/page-events.js +26 -0
- package/dist/internal/page-events.js.map +1 -0
- package/dist/internal/page-execution.d.ts +16 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +432 -31
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +3 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +28 -5
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/ppr/admission.d.ts +83 -0
- package/dist/internal/ppr/admission.d.ts.map +1 -0
- package/dist/internal/ppr/admission.js +221 -0
- package/dist/internal/ppr/admission.js.map +1 -0
- package/dist/internal/ppr/canary.d.ts +24 -0
- package/dist/internal/ppr/canary.d.ts.map +1 -0
- package/dist/internal/ppr/canary.js +102 -0
- package/dist/internal/ppr/canary.js.map +1 -0
- package/dist/internal/ppr/compile-shell.d.ts +39 -0
- package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
- package/dist/internal/ppr/compile-shell.js +298 -0
- package/dist/internal/ppr/compile-shell.js.map +1 -0
- package/dist/internal/ppr/contract.d.ts +177 -0
- package/dist/internal/ppr/contract.d.ts.map +1 -0
- package/dist/internal/ppr/contract.js +80 -0
- package/dist/internal/ppr/contract.js.map +1 -0
- package/dist/internal/ppr/frames.d.ts +71 -0
- package/dist/internal/ppr/frames.d.ts.map +1 -0
- package/dist/internal/ppr/frames.js +119 -0
- package/dist/internal/ppr/frames.js.map +1 -0
- package/dist/internal/ppr/integration.d.ts +19 -0
- package/dist/internal/ppr/integration.d.ts.map +1 -0
- package/dist/internal/ppr/integration.js +106 -0
- package/dist/internal/ppr/integration.js.map +1 -0
- package/dist/internal/ppr/options.d.ts +96 -0
- package/dist/internal/ppr/options.d.ts.map +1 -0
- package/dist/internal/ppr/options.js +125 -0
- package/dist/internal/ppr/options.js.map +1 -0
- package/dist/internal/ppr/route-rules.d.ts +12 -0
- package/dist/internal/ppr/route-rules.d.ts.map +1 -0
- package/dist/internal/ppr/route-rules.js +53 -0
- package/dist/internal/ppr/route-rules.js.map +1 -0
- package/dist/internal/ppr/runtime.d.ts +29 -0
- package/dist/internal/ppr/runtime.d.ts.map +1 -0
- package/dist/internal/ppr/runtime.js +35 -0
- package/dist/internal/ppr/runtime.js.map +1 -0
- package/dist/internal/ppr/server.d.ts +29 -0
- package/dist/internal/ppr/server.d.ts.map +1 -0
- package/dist/internal/ppr/server.js +1409 -0
- package/dist/internal/ppr/server.js.map +1 -0
- package/dist/internal/ppr/shell-store.d.ts +39 -0
- package/dist/internal/ppr/shell-store.d.ts.map +1 -0
- package/dist/internal/ppr/shell-store.js +106 -0
- package/dist/internal/ppr/shell-store.js.map +1 -0
- package/dist/internal/prerendered-pages.d.ts +1 -1
- package/dist/internal/prerendered-pages.d.ts.map +1 -1
- package/dist/internal/prerendered-pages.js +7 -4
- package/dist/internal/prerendered-pages.js.map +1 -1
- package/dist/internal/present.d.ts +11 -0
- package/dist/internal/present.d.ts.map +1 -0
- package/dist/internal/present.js +13 -0
- package/dist/internal/present.js.map +1 -0
- package/dist/internal/query-digest.d.ts +9 -0
- package/dist/internal/query-digest.d.ts.map +1 -0
- package/dist/internal/query-digest.js +23 -0
- package/dist/internal/query-digest.js.map +1 -0
- package/dist/internal/query-runtime.d.ts +6 -1
- package/dist/internal/query-runtime.d.ts.map +1 -1
- package/dist/internal/query-runtime.js +9 -0
- package/dist/internal/query-runtime.js.map +1 -1
- package/dist/internal/query-seam.d.ts +29 -0
- package/dist/internal/query-seam.d.ts.map +1 -0
- package/dist/internal/query-seam.js +23 -0
- package/dist/internal/query-seam.js.map +1 -0
- package/dist/internal/render-document.d.ts +34 -64
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +82 -64
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-abort.d.ts +8 -0
- package/dist/internal/request-abort.d.ts.map +1 -0
- package/dist/internal/request-abort.js +22 -0
- package/dist/internal/request-abort.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +2 -0
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +8 -1
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/share-data.d.ts +10 -0
- package/dist/internal/share-data.d.ts.map +1 -0
- package/dist/internal/share-data.js +135 -0
- package/dist/internal/share-data.js.map +1 -0
- package/dist/internal/status-page.d.ts +68 -0
- package/dist/internal/status-page.d.ts.map +1 -0
- package/dist/internal/status-page.js +66 -0
- package/dist/internal/status-page.js.map +1 -0
- package/dist/internal/store-signature.d.ts +15 -0
- package/dist/internal/store-signature.d.ts.map +1 -0
- package/dist/internal/store-signature.js +114 -0
- package/dist/internal/store-signature.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +2 -2
- package/dist/internal/vite-plugin-compat.js +2 -2
- package/dist/internal/web-app.d.ts +26 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +79 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/islands-swap.d.ts +41 -0
- package/dist/islands-swap.d.ts.map +1 -0
- package/dist/islands-swap.js +270 -0
- package/dist/islands-swap.js.map +1 -0
- package/dist/islands.d.ts +86 -24
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +138 -78
- package/dist/islands.js.map +1 -1
- package/dist/isr.d.ts +67 -13
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +247 -99
- package/dist/isr.js.map +1 -1
- package/dist/keep-alive.d.ts +147 -0
- package/dist/keep-alive.d.ts.map +1 -0
- package/dist/keep-alive.js +1102 -0
- package/dist/keep-alive.js.map +1 -0
- package/dist/live/bus.d.ts +70 -0
- package/dist/live/bus.d.ts.map +1 -0
- package/dist/live/bus.js +55 -0
- package/dist/live/bus.js.map +1 -0
- package/dist/live/core.d.ts +25 -0
- package/dist/live/core.d.ts.map +1 -0
- package/dist/live/core.js +29 -0
- package/dist/live/core.js.map +1 -0
- package/dist/live/history.d.ts +38 -0
- package/dist/live/history.d.ts.map +1 -0
- package/dist/live/history.js +104 -0
- package/dist/live/history.js.map +1 -0
- package/dist/live/hub-protocol.d.ts +46 -0
- package/dist/live/hub-protocol.d.ts.map +1 -0
- package/dist/live/hub-protocol.js +108 -0
- package/dist/live/hub-protocol.js.map +1 -0
- package/dist/live/hub.d.ts +185 -0
- package/dist/live/hub.d.ts.map +1 -0
- package/dist/live/hub.js +544 -0
- package/dist/live/hub.js.map +1 -0
- package/dist/live/keyring.d.ts +92 -0
- package/dist/live/keyring.d.ts.map +1 -0
- package/dist/live/keyring.js +265 -0
- package/dist/live/keyring.js.map +1 -0
- package/dist/live/read-capped.d.ts +6 -0
- package/dist/live/read-capped.d.ts.map +1 -0
- package/dist/live/read-capped.js +42 -0
- package/dist/live/read-capped.js.map +1 -0
- package/dist/live/revocations.d.ts +52 -0
- package/dist/live/revocations.d.ts.map +1 -0
- package/dist/live/revocations.js +116 -0
- package/dist/live/revocations.js.map +1 -0
- package/dist/live/settings.d.ts +14 -0
- package/dist/live/settings.d.ts.map +1 -0
- package/dist/live/settings.js +38 -0
- package/dist/live/settings.js.map +1 -0
- package/dist/live-client.d.ts +34 -0
- package/dist/live-client.d.ts.map +1 -0
- package/dist/live-client.js +878 -0
- package/dist/live-client.js.map +1 -0
- package/dist/live-hub.d.ts +130 -0
- package/dist/live-hub.d.ts.map +1 -0
- package/dist/live-hub.js +507 -0
- package/dist/live-hub.js.map +1 -0
- package/dist/live-redis.d.ts +60 -0
- package/dist/live-redis.d.ts.map +1 -0
- package/dist/live-redis.js +212 -0
- package/dist/live-redis.js.map +1 -0
- package/dist/live.d.ts +150 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +972 -0
- package/dist/live.js.map +1 -0
- package/dist/manifest.d.ts +113 -2
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +109 -5
- package/dist/manifest.js.map +1 -1
- package/dist/navigation.d.ts +9 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +13 -3
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +6 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +8 -2
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/ppr.d.ts +73 -0
- package/dist/ppr.d.ts.map +1 -0
- package/dist/ppr.js +76 -0
- package/dist/ppr.js.map +1 -0
- package/dist/query.d.ts +8 -13
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +112 -18
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +32 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +7 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-types.d.ts.map +1 -1
- package/dist/route-types.js +2 -2
- package/dist/route-types.js.map +1 -1
- package/dist/router.d.ts +72 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +416 -129
- package/dist/router.js.map +1 -1
- package/dist/server-components/build.d.ts.map +1 -1
- package/dist/server-components/build.js +3 -3
- package/dist/server-components/build.js.map +1 -1
- package/dist/server-components/client.d.ts +23 -1
- package/dist/server-components/client.d.ts.map +1 -1
- package/dist/server-components/client.js +92 -5
- package/dist/server-components/client.js.map +1 -1
- package/dist/server-components/definition.d.ts.map +1 -1
- package/dist/server-components/definition.js +2 -1
- package/dist/server-components/definition.js.map +1 -1
- package/dist/server-components/hole.d.ts +8 -0
- package/dist/server-components/hole.d.ts.map +1 -0
- package/dist/server-components/hole.js +20 -0
- package/dist/server-components/hole.js.map +1 -0
- package/dist/server-components/html-policy.d.ts.map +1 -1
- package/dist/server-components/html-policy.js +62 -4
- package/dist/server-components/html-policy.js.map +1 -1
- package/dist/server-components/html-scan.d.ts +17 -0
- package/dist/server-components/html-scan.d.ts.map +1 -0
- package/dist/server-components/html-scan.js +180 -0
- package/dist/server-components/html-scan.js.map +1 -0
- package/dist/server-components/html.d.ts.map +1 -1
- package/dist/server-components/html.js +19 -4
- package/dist/server-components/html.js.map +1 -1
- package/dist/server-components/router-integration.d.ts +4 -1
- package/dist/server-components/router-integration.d.ts.map +1 -1
- package/dist/server-components/router-integration.js +23 -2
- package/dist/server-components/router-integration.js.map +1 -1
- package/dist/server-components/runtime.d.ts +6 -1
- package/dist/server-components/runtime.d.ts.map +1 -1
- package/dist/server-components/runtime.js +88 -29
- package/dist/server-components/runtime.js.map +1 -1
- package/dist/server-components/state.d.ts +3 -1
- package/dist/server-components/state.d.ts.map +1 -1
- package/dist/server-components/state.js +50 -38
- package/dist/server-components/state.js.map +1 -1
- package/dist/server-components/types.d.ts +9 -0
- package/dist/server-components/types.d.ts.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +26 -9
- package/dist/server-components/vite.js.map +1 -1
- package/dist/vite.d.ts +5 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +58 -4
- package/dist/vite.js.map +1 -1
- package/package.json +48 -8
- package/src/boundary.ts +77 -5
- package/src/build-plan.ts +3 -0
- package/src/build-vite.ts +2 -0
- package/src/build.ts +20 -1
- package/src/client.ts +183 -23
- package/src/csp.ts +16 -2
- package/src/deferred.ts +11 -5
- package/src/dev-feed.ts +11 -2
- package/src/dev-session.ts +22 -11
- package/src/dev.ts +29 -8
- package/src/diagnostic-catalog.ts +53 -0
- package/src/diagnostic.ts +1 -0
- package/src/fn.ts +24 -7
- package/src/fs.ts +22 -8
- package/src/identity.ts +61 -0
- package/src/index.ts +20 -4
- package/src/internal/cdn/cloudflare.ts +2 -1
- package/src/internal/certified-components.ts +1 -1
- package/src/internal/codegen.ts +95 -9
- package/src/internal/dev-websocket.ts +230 -0
- package/src/internal/hole-runtime.ts +286 -0
- package/src/internal/identity-epoch.ts +239 -0
- package/src/internal/invalidation-contract.ts +35 -0
- package/src/internal/invalidation.ts +213 -0
- package/src/internal/island-bindings.ts +191 -0
- package/src/internal/island-list.ts +92 -0
- package/src/internal/island-mount.ts +68 -0
- package/src/internal/island-owner.ts +20 -0
- package/src/internal/island-resource.ts +76 -0
- package/src/internal/island-signals.ts +249 -0
- package/src/internal/keep-alive-table.ts +72 -0
- package/src/internal/live-contract.ts +111 -0
- package/src/internal/live-queries.ts +60 -0
- package/src/internal/page-events.ts +27 -0
- package/src/internal/page-execution.ts +605 -45
- package/src/internal/parity.ts +35 -6
- package/src/internal/ppr/admission.ts +300 -0
- package/src/internal/ppr/canary.ts +122 -0
- package/src/internal/ppr/compile-shell.ts +379 -0
- package/src/internal/ppr/contract.ts +265 -0
- package/src/internal/ppr/frames.ts +170 -0
- package/src/internal/ppr/integration.ts +137 -0
- package/src/internal/ppr/options.ts +221 -0
- package/src/internal/ppr/route-rules.ts +77 -0
- package/src/internal/ppr/runtime.ts +39 -0
- package/src/internal/ppr/server.ts +1766 -0
- package/src/internal/ppr/shell-store.ts +152 -0
- package/src/internal/prerendered-pages.ts +7 -4
- package/src/internal/present.ts +20 -0
- package/src/internal/query-digest.ts +34 -0
- package/src/internal/query-runtime.ts +14 -1
- package/src/internal/query-seam.ts +48 -0
- package/src/internal/render-document.ts +158 -113
- package/src/internal/request-abort.ts +26 -0
- package/src/internal/server-boundary.ts +10 -1
- package/src/internal/share-data.ts +136 -0
- package/src/internal/status-page.ts +95 -0
- package/src/internal/store-signature.ts +149 -0
- package/src/internal/vite-plugin-compat.ts +2 -2
- package/src/internal/web-app.ts +123 -3
- package/src/islands-swap.ts +283 -0
- package/src/islands.ts +169 -72
- package/src/isr.ts +348 -106
- package/src/keep-alive.ts +1354 -0
- package/src/live/bus.ts +104 -0
- package/src/live/core.ts +52 -0
- package/src/live/history.ts +109 -0
- package/src/live/hub-protocol.ts +133 -0
- package/src/live/hub.ts +620 -0
- package/src/live/keyring.ts +384 -0
- package/src/live/read-capped.ts +36 -0
- package/src/live/revocations.ts +155 -0
- package/src/live/settings.ts +46 -0
- package/src/live-client.ts +1011 -0
- package/src/live-hub.ts +658 -0
- package/src/live-redis.ts +284 -0
- package/src/live.ts +1257 -0
- package/src/manifest.ts +274 -6
- package/src/navigation.ts +14 -2
- package/src/plugins/vite-zone-guard.ts +15 -2
- package/src/ppr.ts +153 -0
- package/src/query.ts +147 -20
- package/src/render-seam.ts +37 -0
- package/src/route-types.ts +3 -1
- package/src/router.ts +534 -132
- package/src/server-components/build.ts +9 -3
- package/src/server-components/client.ts +89 -5
- package/src/server-components/definition.ts +2 -1
- package/src/server-components/hole.ts +23 -0
- package/src/server-components/html-policy.ts +63 -4
- package/src/server-components/html-scan.ts +159 -0
- package/src/server-components/html.ts +18 -4
- package/src/server-components/router-integration.ts +21 -2
- package/src/server-components/runtime.ts +114 -31
- package/src/server-components/state.ts +66 -44
- package/src/server-components/types.ts +16 -0
- package/src/server-components/vite.ts +33 -14
- package/src/vite.ts +75 -6
- package/dist/nano.d.ts +0 -125
- package/dist/nano.d.ts.map +0 -1
- package/dist/nano.js +0 -190
- package/dist/nano.js.map +0 -1
- package/src/nano.ts +0 -246
package/src/isr.ts
CHANGED
|
@@ -9,7 +9,21 @@
|
|
|
9
9
|
import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
|
|
10
10
|
import { readBoundedBytes, TransportCodecError } from "@nifrajs/core/transport-codec"
|
|
11
11
|
import { isDraftEnabled } from "./draft.ts"
|
|
12
|
+
import {
|
|
13
|
+
type InvalidationReport,
|
|
14
|
+
type InvalidationTargets,
|
|
15
|
+
runInvalidation,
|
|
16
|
+
settleInvalidation,
|
|
17
|
+
} from "./internal/invalidation.ts"
|
|
18
|
+
import { INVALIDATION, type InvalidationHandoff } from "./internal/invalidation-contract.ts"
|
|
19
|
+
import {
|
|
20
|
+
isStoreSecret,
|
|
21
|
+
MIN_STORE_SECRET_LENGTH,
|
|
22
|
+
type StoreSigner,
|
|
23
|
+
storeSigner,
|
|
24
|
+
} from "./internal/store-signature.ts"
|
|
12
25
|
import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
26
|
+
import { SWAP_HEADER } from "./render-seam.ts"
|
|
13
27
|
|
|
14
28
|
/** A cached SSR response - the bytes + metadata a {@link CacheStore} persists. */
|
|
15
29
|
export interface CachedResponse {
|
|
@@ -48,7 +62,9 @@ export interface CacheStore {
|
|
|
48
62
|
* The current point in this store's purge order. `withISR` takes one before each render and hands it
|
|
49
63
|
* back as {@link CachedResponse.fence}, and `set` skips that entry when its key or one of its tags
|
|
50
64
|
* 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.
|
|
65
|
+
* render stores the page it built from data read before the purge. A store whose purges reach other
|
|
66
|
+
* instances late must also check on read, as {@link KVCacheStore} does: `set` there cannot see a
|
|
67
|
+
* purge that has not arrived yet.
|
|
52
68
|
*/
|
|
53
69
|
readonly fence?: () => number | Promise<number>
|
|
54
70
|
}
|
|
@@ -94,6 +110,10 @@ export function serializeISRTags(tags: readonly string[]): string | undefined {
|
|
|
94
110
|
/** What a `revalidateTags` function is given: the URL and route params only, both already public. */
|
|
95
111
|
export interface RevalidateTagsInput {
|
|
96
112
|
readonly params: Readonly<Record<string, string>>
|
|
113
|
+
/**
|
|
114
|
+
* The request's URL. On a partially prerendered route and its shared holes, the shell's: the
|
|
115
|
+
* configured origin, the path and the declared variations, never the visitor's Host or other query.
|
|
116
|
+
*/
|
|
97
117
|
readonly url: URL
|
|
98
118
|
}
|
|
99
119
|
|
|
@@ -144,6 +164,9 @@ export interface MemoryCacheStoreOptions {
|
|
|
144
164
|
readonly max?: number
|
|
145
165
|
}
|
|
146
166
|
|
|
167
|
+
// Nothing outside this process can write these, so `withISR` keeps their pages unsigned.
|
|
168
|
+
const inProcessStores = new WeakSet<CacheStore>()
|
|
169
|
+
|
|
147
170
|
/**
|
|
148
171
|
* In-process ISR cache. Refuses to run in production unless explicitly allowed (mirrors the
|
|
149
172
|
* rate-limit `MemoryStore` - a per-instance cache is unsafe across instances). Bounded **LRU**: a
|
|
@@ -177,6 +200,7 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
177
200
|
if (!Number.isSafeInteger(this.max) || this.max <= 0) {
|
|
178
201
|
throw new RangeError("[nifra/web] MemoryCacheStore max must be a positive safe integer")
|
|
179
202
|
}
|
|
203
|
+
inProcessStores.add(this)
|
|
180
204
|
}
|
|
181
205
|
|
|
182
206
|
get(key: string): Promise<CachedResponse | undefined> {
|
|
@@ -360,25 +384,15 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
|
360
384
|
}
|
|
361
385
|
|
|
362
386
|
const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (typeof at === "number") return at
|
|
373
|
-
}
|
|
374
|
-
} catch {
|
|
375
|
-
// Not a marker.
|
|
376
|
-
}
|
|
377
|
-
return Number.NEGATIVE_INFINITY
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** When a tag epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time is older
|
|
381
|
-
* than any render. */
|
|
387
|
+
const KV_PATH_EPOCH_PREFIX = "__nifra_isr_path_v1:"
|
|
388
|
+
const KV_PATH_DIGEST_PREFIX = "__nifra_isr_path_v1#"
|
|
389
|
+
/** Cloudflare KV's key limit, in UTF-8 bytes. */
|
|
390
|
+
const KV_KEY_LIMIT = 512
|
|
391
|
+
/** How long (seconds) a path epoch outlives the store's `expirationTtl`. */
|
|
392
|
+
const PATH_EPOCH_SLACK = 86_400
|
|
393
|
+
|
|
394
|
+
/** When a tag or path epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time
|
|
395
|
+
* is older than any render. */
|
|
382
396
|
function kvEpochTime(epoch: string): number {
|
|
383
397
|
const at = epoch.indexOf(":")
|
|
384
398
|
const time = at === -1 ? Number.NaN : Number(epoch.slice(0, at))
|
|
@@ -395,8 +409,10 @@ const kvClock = (): number => {
|
|
|
395
409
|
return lastKvTime
|
|
396
410
|
}
|
|
397
411
|
|
|
398
|
-
type
|
|
412
|
+
type StoredResponse = CachedResponse & {
|
|
399
413
|
readonly __nifraTagEpochs?: Readonly<Record<string, string>>
|
|
414
|
+
/** Absent while the path was never purged. */
|
|
415
|
+
readonly __nifraPathEpoch?: string
|
|
400
416
|
}
|
|
401
417
|
|
|
402
418
|
/**
|
|
@@ -404,7 +420,8 @@ type StoredTaggedResponse = CachedResponse & {
|
|
|
404
420
|
* binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
|
|
405
421
|
* hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
|
|
406
422
|
* to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
|
|
407
|
-
* a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)
|
|
423
|
+
* a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`,
|
|
424
|
+
* and give `withISR` the `storeSecret` it signs these pages with.
|
|
408
425
|
*
|
|
409
426
|
* Cloudflare is the default, not a requirement: {@link KVNamespaceLike} is three structural methods,
|
|
410
427
|
* so a Redis, Deno KV, or Upstash binding satisfies it (pass `minExpirationTtl` for their TTL floor).
|
|
@@ -414,6 +431,7 @@ type StoredTaggedResponse = CachedResponse & {
|
|
|
414
431
|
export class KVCacheStore implements CacheStore {
|
|
415
432
|
private readonly kv: KVNamespaceLike
|
|
416
433
|
private readonly putOptions: { readonly expirationTtl?: number } | undefined
|
|
434
|
+
private readonly pathEpochOptions: { readonly expirationTtl?: number } | undefined
|
|
417
435
|
|
|
418
436
|
constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
|
|
419
437
|
const floor = options.minExpirationTtl ?? 60
|
|
@@ -441,10 +459,17 @@ export class KVCacheStore implements CacheStore {
|
|
|
441
459
|
// Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
|
|
442
460
|
this.putOptions =
|
|
443
461
|
options.expirationTtl !== undefined ? { expirationTtl: options.expirationTtl } : undefined
|
|
462
|
+
// An expired path epoch would make pages stored under the one it replaced read as current again.
|
|
463
|
+
// Those were stored before the purge reached their location, so they expire within the TTL plus
|
|
464
|
+
// that delay; without a TTL they never expire, and neither may the epoch.
|
|
465
|
+
this.pathEpochOptions =
|
|
466
|
+
options.expirationTtl !== undefined
|
|
467
|
+
? { expirationTtl: options.expirationTtl + PATH_EPOCH_SLACK }
|
|
468
|
+
: undefined
|
|
444
469
|
}
|
|
445
470
|
|
|
446
471
|
async get(key: string): Promise<CachedResponse | undefined> {
|
|
447
|
-
const raw = await this.kv.get(key)
|
|
472
|
+
const [raw, pathEpoch] = await Promise.all([this.kv.get(key), this.pathEpoch(key)])
|
|
448
473
|
if (raw === null) return undefined
|
|
449
474
|
let parsed: unknown
|
|
450
475
|
try {
|
|
@@ -454,52 +479,65 @@ export class KVCacheStore implements CacheStore {
|
|
|
454
479
|
return undefined
|
|
455
480
|
}
|
|
456
481
|
if (!isCachedResponse(parsed)) return undefined
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
482
|
+
const {
|
|
483
|
+
__nifraTagEpochs: epochs,
|
|
484
|
+
__nifraPathEpoch: storedPathEpoch,
|
|
485
|
+
...value
|
|
486
|
+
} = parsed as StoredResponse
|
|
487
|
+
// A page another location stored before this path's purge reached it keeps the older epoch.
|
|
488
|
+
if ((storedPathEpoch ?? "0") !== pathEpoch) return undefined
|
|
489
|
+
const tags = normalizeTags(value.tags)
|
|
490
|
+
if (tags.length === 0) return value
|
|
460
491
|
if (epochs === undefined) return undefined
|
|
461
492
|
const current = await Promise.all(
|
|
462
493
|
tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
|
|
463
494
|
)
|
|
464
495
|
if (current.some(([tag, epoch]) => epochs[tag] !== epoch)) return undefined
|
|
465
|
-
const { __nifraTagEpochs: _ignored, ...value } = parsed as StoredTaggedResponse
|
|
466
496
|
return value
|
|
467
497
|
}
|
|
468
498
|
|
|
469
|
-
/** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so
|
|
470
|
-
*
|
|
499
|
+
/** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so
|
|
500
|
+
* `set` refuses only purges this location has already seen; a page stored before a purge reached
|
|
501
|
+
* its location keeps the older epoch and reads as a miss once the purge arrives. */
|
|
471
502
|
fence(): number {
|
|
472
503
|
return kvClock()
|
|
473
504
|
}
|
|
474
505
|
|
|
475
506
|
async set(key: string, value: CachedResponse): Promise<void> {
|
|
476
507
|
const { fence, ...entry } = value
|
|
477
|
-
if (fence !== undefined && kvPurgedAt(await this.kv.get(key)) > fence) return
|
|
478
508
|
const tags = normalizeTags(entry.tags)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
)
|
|
488
|
-
if (fence !== undefined && Object.values(epochs).some((epoch) => kvEpochTime(epoch) > fence)) {
|
|
509
|
+
const [pathEpoch, ...tagEpochs] = await Promise.all([
|
|
510
|
+
this.pathEpoch(key),
|
|
511
|
+
...tags.map(async (tag) => (await this.kv.get(this.tagKey(tag))) ?? "0"),
|
|
512
|
+
])
|
|
513
|
+
if (
|
|
514
|
+
fence !== undefined &&
|
|
515
|
+
[pathEpoch, ...tagEpochs].some((epoch) => kvEpochTime(epoch) > fence)
|
|
516
|
+
) {
|
|
489
517
|
return
|
|
490
518
|
}
|
|
491
|
-
const stored:
|
|
519
|
+
const stored: StoredResponse = {
|
|
492
520
|
...entry,
|
|
493
|
-
tags
|
|
494
|
-
|
|
521
|
+
...(tags.length === 0
|
|
522
|
+
? {}
|
|
523
|
+
: {
|
|
524
|
+
tags,
|
|
525
|
+
__nifraTagEpochs: Object.fromEntries(tags.map((tag, at) => [tag, tagEpochs[at]!])),
|
|
526
|
+
}),
|
|
527
|
+
...(pathEpoch === "0" ? {} : { __nifraPathEpoch: pathEpoch }),
|
|
495
528
|
}
|
|
496
529
|
await this.kv.put(key, JSON.stringify(stored), this.putOptions)
|
|
497
530
|
}
|
|
498
531
|
|
|
499
|
-
/**
|
|
500
|
-
*
|
|
532
|
+
/** Moves the path's epoch and leaves the page: a page stored under the old epoch reads as a miss,
|
|
533
|
+
* even one another location stored before this purge reached it, and `set` refuses a render begun
|
|
534
|
+
* before the purge. */
|
|
501
535
|
async delete(key: string): Promise<void> {
|
|
502
|
-
await this.kv.put(
|
|
536
|
+
await this.kv.put(
|
|
537
|
+
await this.pathKey(key),
|
|
538
|
+
`${kvClock()}:${crypto.randomUUID()}`,
|
|
539
|
+
this.pathEpochOptions,
|
|
540
|
+
)
|
|
503
541
|
}
|
|
504
542
|
|
|
505
543
|
async invalidateTag(tag: string): Promise<void> {
|
|
@@ -513,6 +551,21 @@ export class KVCacheStore implements CacheStore {
|
|
|
513
551
|
private tagKey(tag: string): string {
|
|
514
552
|
return `${KV_TAG_EPOCH_PREFIX}${encodeURIComponent(tag)}`
|
|
515
553
|
}
|
|
554
|
+
|
|
555
|
+
/** The page key under a prefix, or its digest when that would pass KV's key limit. */
|
|
556
|
+
private async pathKey(key: string): Promise<string> {
|
|
557
|
+
const named = `${KV_PATH_EPOCH_PREFIX}${key}`
|
|
558
|
+
// A UTF-16 unit is at most three UTF-8 bytes, so most keys skip the encode.
|
|
559
|
+
if (named.length * 3 <= KV_KEY_LIMIT) return named
|
|
560
|
+
const encoder = new TextEncoder()
|
|
561
|
+
if (encoder.encode(named).byteLength <= KV_KEY_LIMIT) return named
|
|
562
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(key)))
|
|
563
|
+
return `${KV_PATH_DIGEST_PREFIX}${Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("")}`
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async pathEpoch(key: string): Promise<string> {
|
|
567
|
+
return (await this.kv.get(await this.pathKey(key))) ?? "0"
|
|
568
|
+
}
|
|
516
569
|
}
|
|
517
570
|
|
|
518
571
|
/** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
|
|
@@ -564,6 +617,19 @@ export function withoutCacheChannel(res: Response): Response {
|
|
|
564
617
|
|
|
565
618
|
export interface ISROptions {
|
|
566
619
|
readonly store: CacheStore
|
|
620
|
+
/**
|
|
621
|
+
* Signs every page written to `store` and checks the signature on every read, so whoever else can
|
|
622
|
+
* write the store (another service on the same KV namespace, a leaked token) cannot hand visitors a
|
|
623
|
+
* page this app did not render. Required for every store but a {@link MemoryCacheStore}, whose
|
|
624
|
+
* pages never leave the process and stay unsigned. The same in every process, at least 32
|
|
625
|
+
* characters, and random: generate it, such as 32 random bytes as hex, since a guessable secret
|
|
626
|
+
* can be found from any signed page and then signs pages of its own. A page whose signature is
|
|
627
|
+
* missing or wrong reads as a miss and renders again, so a new secret only empties the cache. It
|
|
628
|
+
* cannot stop a store writer from deleting pages, or from putting back an older page this app
|
|
629
|
+
* stored for the same key. That page keeps its age: still within its freshness window, it is a hit
|
|
630
|
+
* until the window ends; past it, it serves stale and regenerates.
|
|
631
|
+
*/
|
|
632
|
+
readonly storeSecret?: string
|
|
567
633
|
/** Default freshness window (**seconds**) for a cached page; older ⇒ stale (served, regenerated
|
|
568
634
|
* behind). A route overrides it per-page via `export const revalidate` (surfaced as the
|
|
569
635
|
* `x-nifra-isr-revalidate` response header). */
|
|
@@ -574,7 +640,10 @@ export interface ISROptions {
|
|
|
574
640
|
* so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
|
|
575
641
|
* this request (it goes straight to the app, uncached). Replaces `query` entirely. Under the default
|
|
576
642
|
* key a request carrying `Authorization` or a cookie outside `anonymousCookies` never reads the
|
|
577
|
-
* store; a custom key takes that decision over, so include whatever identifies the visitor in it.
|
|
643
|
+
* store; a custom key takes that decision over, so include whatever identifies the visitor in it.
|
|
644
|
+
* An action's `revalidate(paths)` keys each path with the action request's own headers, so it purges
|
|
645
|
+
* the variant that client reads; a variant keyed on another header value (another language) is not
|
|
646
|
+
* reached - tag those pages and revalidate the tag. */
|
|
578
647
|
readonly key?: (req: Request) => string | null
|
|
579
648
|
/**
|
|
580
649
|
* Cookie names that never change the page, such as a consent or analytics cookie. Under the default
|
|
@@ -597,10 +666,11 @@ export interface ISROptions {
|
|
|
597
666
|
* fresh, and a draft render is never written to the store (it can't poison the public cache). */
|
|
598
667
|
readonly draftSecret?: string
|
|
599
668
|
/**
|
|
600
|
-
* Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
669
|
+
* Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}, or (op
|
|
670
|
+
* `"verify"`) a page it returns fails its {@link storeSecret} signature check. The request is served
|
|
671
|
+
* without the cache either way - a failed read is a miss, a failed write still returns the rendered
|
|
672
|
+
* page, a failed regeneration keeps the stale entry - so a store outage never changes a status.
|
|
673
|
+
* Default: one `console.error` line per operation per minute, never naming the cache key.
|
|
604
674
|
*/
|
|
605
675
|
readonly onStoreError?: (error: unknown, info: { readonly op: ISRStoreOp }) => void
|
|
606
676
|
/**
|
|
@@ -608,12 +678,27 @@ export interface ISROptions {
|
|
|
608
678
|
* the request; set it when the store is a network service.
|
|
609
679
|
*/
|
|
610
680
|
readonly storeTimeoutMs?: number
|
|
681
|
+
/**
|
|
682
|
+
* Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding loader
|
|
683
|
+
* data. An action's `revalidate(paths, data, { tags })` invalidates them first, then this store, then
|
|
684
|
+
* publishes to live pages, then purges `cdn`; a cache that fails stops the rest, so no client reloads
|
|
685
|
+
* stale data.
|
|
686
|
+
*/
|
|
687
|
+
readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
|
|
688
|
+
/** A CDN an action's invalidation purges last (a `@nifrajs/web/cdn` provider). */
|
|
689
|
+
readonly cdn?: CdnPurgeTarget
|
|
690
|
+
/**
|
|
691
|
+
* An action's invalidation that still failed after its retries; the action's own answer never
|
|
692
|
+
* changes. Default: one `console.error` line naming the operation id and stage.
|
|
693
|
+
*/
|
|
694
|
+
readonly onInvalidationError?: (report: InvalidationReport) => void
|
|
611
695
|
}
|
|
612
696
|
|
|
613
697
|
/** The store operation an {@link ISROptions.onStoreError} report is about. */
|
|
614
|
-
export type ISRStoreOp = "get" | "set" | "delete" | "fence"
|
|
698
|
+
export type ISRStoreOp = "get" | "set" | "delete" | "fence" | "verify"
|
|
615
699
|
|
|
616
700
|
const STORE_FAILED = Symbol("nifra.isr.store-failed")
|
|
701
|
+
const MAC_HEADER = "x-nifra-isr-mac"
|
|
617
702
|
const STORE_REPORT_INTERVAL_MS = 60_000
|
|
618
703
|
const STORE_REPORT_DETAIL_CHARS = 200
|
|
619
704
|
|
|
@@ -633,6 +718,79 @@ function storeErrorDetail(error: unknown, key: string): string {
|
|
|
633
718
|
: redacted
|
|
634
719
|
}
|
|
635
720
|
|
|
721
|
+
/**
|
|
722
|
+
* What a page's MAC covers: everything a replay serves, plus the key, so a page copied to another key
|
|
723
|
+
* never verifies there. Header names and tags are sorted, since a store need not keep their order.
|
|
724
|
+
*/
|
|
725
|
+
function signedParts(key: string, entry: CachedResponse): readonly string[] {
|
|
726
|
+
const names = Object.keys(entry.headers)
|
|
727
|
+
.filter((name) => name !== MAC_HEADER)
|
|
728
|
+
.sort()
|
|
729
|
+
const headers = names.map((name) => [name, entry.headers[name]])
|
|
730
|
+
const tags = entry.tags === undefined ? [] : [...entry.tags].sort()
|
|
731
|
+
const meta = JSON.stringify([
|
|
732
|
+
1,
|
|
733
|
+
key,
|
|
734
|
+
entry.status,
|
|
735
|
+
entry.storedAt,
|
|
736
|
+
entry.revalidate,
|
|
737
|
+
headers,
|
|
738
|
+
tags,
|
|
739
|
+
])
|
|
740
|
+
return [meta, entry.body]
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function signed(
|
|
744
|
+
signer: StoreSigner,
|
|
745
|
+
key: string,
|
|
746
|
+
entry: CachedResponse,
|
|
747
|
+
): Promise<CachedResponse> {
|
|
748
|
+
const mac = await signer.sign(signedParts(key, entry))
|
|
749
|
+
return { ...entry, headers: { ...entry.headers, [MAC_HEADER]: mac } }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function verified(signer: StoreSigner, key: string, entry: CachedResponse): Promise<boolean> {
|
|
753
|
+
try {
|
|
754
|
+
return await signer.verify(signedParts(key, entry), entry.headers[MAC_HEADER])
|
|
755
|
+
} catch {
|
|
756
|
+
// A store's own malformed record.
|
|
757
|
+
return false
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function signerFor(store: CacheStore, secret: unknown): StoreSigner | undefined {
|
|
762
|
+
if (secret !== undefined && !isStoreSecret(secret)) {
|
|
763
|
+
throw new TypeError(
|
|
764
|
+
`[nifra/web] ISR storeSecret must be a string of at least ${MIN_STORE_SECRET_LENGTH} characters`,
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
if (inProcessStores.has(store)) return undefined
|
|
768
|
+
if (secret === undefined) {
|
|
769
|
+
throw new TypeError(
|
|
770
|
+
`[nifra/web] withISR needs storeSecret, at least ${MIN_STORE_SECRET_LENGTH} characters, to sign the pages it keeps in a store other processes can write. Only a MemoryCacheStore runs without one.`,
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
return storeSigner(secret)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Headers only an action or a data request carries; a stored page is always a plain document GET.
|
|
777
|
+
const ACTION_ONLY_HEADERS = [
|
|
778
|
+
"content-type",
|
|
779
|
+
"content-length",
|
|
780
|
+
"content-encoding",
|
|
781
|
+
"transfer-encoding",
|
|
782
|
+
"x-nifra-data",
|
|
783
|
+
"x-nifra-component",
|
|
784
|
+
SWAP_HEADER,
|
|
785
|
+
]
|
|
786
|
+
|
|
787
|
+
/** The document GET for `path` from the client that sent `req`: what a custom key sees on a read. */
|
|
788
|
+
function documentGet(path: string, req: Request): Request {
|
|
789
|
+
const headers = new Headers(req.headers)
|
|
790
|
+
for (const name of ACTION_ONLY_HEADERS) headers.delete(name)
|
|
791
|
+
return new Request(originOf(req) + path, { headers })
|
|
792
|
+
}
|
|
793
|
+
|
|
636
794
|
/** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
|
|
637
795
|
* through the global symbol registry so a cache wrapper never imports the CSP module. */
|
|
638
796
|
const DOCUMENT_POLICY = Symbol.for("nifra.web.documentPolicy")
|
|
@@ -676,6 +834,32 @@ const requestKeyOf =
|
|
|
676
834
|
(req: Request): string | null =>
|
|
677
835
|
urlKey(new URL(req.url))
|
|
678
836
|
|
|
837
|
+
/**
|
|
838
|
+
* The key of a request, and of a path on the request's origin. Under the default URL key a path needs
|
|
839
|
+
* no `Request`; a custom `key` is handed one.
|
|
840
|
+
*/
|
|
841
|
+
function cacheKeysOf(options: ISROptions): {
|
|
842
|
+
readonly request: (req: Request) => string | null
|
|
843
|
+
readonly path: (path: string, req: Request) => string | null
|
|
844
|
+
} {
|
|
845
|
+
const custom = options.key
|
|
846
|
+
if (custom !== undefined) {
|
|
847
|
+
return { request: custom, path: (path, req) => custom(documentGet(path, req)) }
|
|
848
|
+
}
|
|
849
|
+
const urlKey = urlKeyOf(options.query)
|
|
850
|
+
return {
|
|
851
|
+
request: requestKeyOf(urlKey),
|
|
852
|
+
path: (path, req) => urlKey(new URL(originOf(req) + path)),
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** `req.url` up to its path, without a parse: the path joined to it is parsed as one URL anyway. */
|
|
857
|
+
const originOf = (req: Request): string => {
|
|
858
|
+
const url = req.url
|
|
859
|
+
const end = url.indexOf("/", url.indexOf("//") + 2)
|
|
860
|
+
return end === -1 ? url : url.slice(0, end)
|
|
861
|
+
}
|
|
862
|
+
|
|
679
863
|
function assertRevalidate(value: number, name: string): void {
|
|
680
864
|
if (!Number.isFinite(value) || value < 0) {
|
|
681
865
|
throw new RangeError(`[nifra/web] ${name} must be a finite non-negative number`)
|
|
@@ -714,14 +898,13 @@ const requestCarriesPrivateState = (req: Request): boolean =>
|
|
|
714
898
|
const responseIsExplicitlyPublic = (res: Response): boolean =>
|
|
715
899
|
cacheControlHas(res.headers, ["public"])
|
|
716
900
|
|
|
717
|
-
//
|
|
718
|
-
// or writes the store, so every stored entry is the document.
|
|
901
|
+
// Data and swap requests bypass both cache reads and writes, so these headers cannot vary stored HTML.
|
|
719
902
|
const responseDeclaresVary = (res: Response): boolean => {
|
|
720
903
|
const value = res.headers.get("vary")
|
|
721
904
|
if (value === null) return false
|
|
722
905
|
for (const part of value.split(",")) {
|
|
723
906
|
const name = part.trim().toLowerCase()
|
|
724
|
-
if (name !== "" && name !== "x-nifra-data") return true
|
|
907
|
+
if (name !== "" && name !== "x-nifra-data" && name !== SWAP_HEADER) return true
|
|
725
908
|
}
|
|
726
909
|
return false
|
|
727
910
|
}
|
|
@@ -729,13 +912,13 @@ const responseDeclaresVary = (res: Response): boolean => {
|
|
|
729
912
|
/**
|
|
730
913
|
* Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
|
|
731
914
|
* full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
|
|
732
|
-
* the data
|
|
915
|
+
* the data/swap headers, and, for a request carrying a cookie or Authorization or accepted by an
|
|
733
916
|
* authentication stage, an explicit `public`.
|
|
734
917
|
* The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
|
|
735
918
|
*/
|
|
736
919
|
export const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
737
920
|
if (req.method !== "GET") return false
|
|
738
|
-
if (req.headers.get("x-nifra-data") !== null) return false
|
|
921
|
+
if (req.headers.get("x-nifra-data") !== null || req.headers.has(SWAP_HEADER)) return false
|
|
739
922
|
if (res.status !== 200) return false
|
|
740
923
|
if (!(res.headers.get("content-type") ?? "").includes("text/html")) return false
|
|
741
924
|
if (hasSetCookie(res.headers)) return false
|
|
@@ -842,6 +1025,11 @@ export function withISR(
|
|
|
842
1025
|
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
843
1026
|
const { store, now } = options
|
|
844
1027
|
assertRevalidate(options.revalidate, "revalidate")
|
|
1028
|
+
const signer = signerFor(store, options.storeSecret)
|
|
1029
|
+
const write = (key: string, entry: CachedResponse): Promise<void> =>
|
|
1030
|
+
signer === undefined
|
|
1031
|
+
? store.set(key, entry)
|
|
1032
|
+
: signed(signer, key, entry).then((value) => store.set(key, value))
|
|
845
1033
|
const storeTimeoutMs = options.storeTimeoutMs
|
|
846
1034
|
if (storeTimeoutMs !== undefined && !(Number.isFinite(storeTimeoutMs) && storeTimeoutMs > 0)) {
|
|
847
1035
|
throw new RangeError("[nifra/web] ISR storeTimeoutMs must be a positive finite number")
|
|
@@ -865,28 +1053,32 @@ export function withISR(
|
|
|
865
1053
|
}
|
|
866
1054
|
}
|
|
867
1055
|
// Runs one store call; a throw, a rejection or a timeout is reported and answered with STORE_FAILED.
|
|
1056
|
+
const withinTimeout = async <T>(op: string, pending: Promise<T>): Promise<T> => {
|
|
1057
|
+
if (storeTimeoutMs === undefined) return pending
|
|
1058
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1059
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
1060
|
+
timer = setTimeout(() => {
|
|
1061
|
+
const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
|
|
1062
|
+
error.name = "TimeoutError"
|
|
1063
|
+
reject(error)
|
|
1064
|
+
}, storeTimeoutMs)
|
|
1065
|
+
})
|
|
1066
|
+
try {
|
|
1067
|
+
return await Promise.race([pending, timeout])
|
|
1068
|
+
} finally {
|
|
1069
|
+
clearTimeout(timer)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
868
1072
|
const attempt = async <T>(
|
|
869
1073
|
op: ISRStoreOp,
|
|
870
1074
|
key: string,
|
|
871
1075
|
call: () => Promise<T>,
|
|
872
1076
|
): Promise<T | typeof STORE_FAILED> => {
|
|
873
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
874
1077
|
try {
|
|
875
|
-
|
|
876
|
-
if (storeTimeoutMs === undefined) return await pending
|
|
877
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
878
|
-
timer = setTimeout(() => {
|
|
879
|
-
const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
|
|
880
|
-
error.name = "TimeoutError"
|
|
881
|
-
reject(error)
|
|
882
|
-
}, storeTimeoutMs)
|
|
883
|
-
})
|
|
884
|
-
return await Promise.race([pending, timeout])
|
|
1078
|
+
return await withinTimeout(op, call())
|
|
885
1079
|
} catch (error) {
|
|
886
1080
|
reportStoreError(op, key, error)
|
|
887
1081
|
return STORE_FAILED
|
|
888
|
-
} finally {
|
|
889
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
890
1082
|
}
|
|
891
1083
|
}
|
|
892
1084
|
// A store without purge order, or one whose fence failed, stores the render unfenced.
|
|
@@ -896,7 +1088,8 @@ export function withISR(
|
|
|
896
1088
|
const taken = await attempt("fence", key, async () => fence.call(store))
|
|
897
1089
|
return taken === STORE_FAILED ? undefined : taken
|
|
898
1090
|
}
|
|
899
|
-
const
|
|
1091
|
+
const keys = cacheKeysOf(options)
|
|
1092
|
+
const keyOf = keys.request
|
|
900
1093
|
const draftSecret = options.draftSecret
|
|
901
1094
|
const anonymousCookies = new Set(options.anonymousCookies ?? [])
|
|
902
1095
|
// A request whose cookies are all anonymous is judged for storage as if it carried none.
|
|
@@ -915,6 +1108,38 @@ export function withISR(
|
|
|
915
1108
|
}
|
|
916
1109
|
const regenerating = new Set<string>()
|
|
917
1110
|
openCacheChannel(app)
|
|
1111
|
+
// This wrapper owns the app's invalidation pipeline from here on: an action's operation comes back
|
|
1112
|
+
// by its request, and every stage runs in order before the action's answer leaves.
|
|
1113
|
+
const handoff: unknown = Reflect.get(app, INVALIDATION)
|
|
1114
|
+
const taken = isHandoff(handoff) ? handoff : undefined
|
|
1115
|
+
// The pipeline's purges are bounded like every other store call; their failures reach its own report.
|
|
1116
|
+
const invalidateTag = store.invalidateTag
|
|
1117
|
+
const targets: InvalidationTargets = {
|
|
1118
|
+
dataCaches: options.dataCaches,
|
|
1119
|
+
store: {
|
|
1120
|
+
delete: (key) => withinTimeout("delete", store.delete(key)),
|
|
1121
|
+
invalidateTag:
|
|
1122
|
+
invalidateTag === undefined
|
|
1123
|
+
? undefined
|
|
1124
|
+
: (tag) => withinTimeout("invalidateTag", invalidateTag.call(store, tag)),
|
|
1125
|
+
},
|
|
1126
|
+
keyOf: keys.path,
|
|
1127
|
+
publish: taken?.publish,
|
|
1128
|
+
cdn: options.cdn,
|
|
1129
|
+
}
|
|
1130
|
+
const passThrough = async (
|
|
1131
|
+
req: Request,
|
|
1132
|
+
platform: ISRPlatform | undefined,
|
|
1133
|
+
): Promise<Response> => {
|
|
1134
|
+
const res = withoutCacheChannel(await app.fetch(req, platform))
|
|
1135
|
+
const op = taken?.take(req)
|
|
1136
|
+
if (op !== undefined) {
|
|
1137
|
+
await settleInvalidation(op, targets, req, platform, {
|
|
1138
|
+
onError: options.onInvalidationError,
|
|
1139
|
+
})
|
|
1140
|
+
}
|
|
1141
|
+
return res
|
|
1142
|
+
}
|
|
918
1143
|
// An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
|
|
919
1144
|
// much freshness they have left and their tags, so the CDN never holds a page longer than ISR would.
|
|
920
1145
|
let channelOpen = false
|
|
@@ -988,7 +1213,7 @@ export function withISR(
|
|
|
988
1213
|
}
|
|
989
1214
|
// A failed write still answers with the page, and still hands it to concurrent misses: whether it
|
|
990
1215
|
// may be shared was decided above, not by the store.
|
|
991
|
-
await attempt("set", key, () =>
|
|
1216
|
+
await attempt("set", key, () => write(key, entry))
|
|
992
1217
|
stored?.(entry)
|
|
993
1218
|
return fresh(entry)
|
|
994
1219
|
}
|
|
@@ -1052,7 +1277,7 @@ export function withISR(
|
|
|
1052
1277
|
...(tags.length === 0 ? {} : { tags }),
|
|
1053
1278
|
fence,
|
|
1054
1279
|
}
|
|
1055
|
-
await attempt("set", key, () =>
|
|
1280
|
+
await attempt("set", key, () => write(key, entry))
|
|
1056
1281
|
} else if (isInvalidationResponse(judged(req), res)) {
|
|
1057
1282
|
// A withdrawal is authoritative. Keeping the old entry would make a withdrawn page stay
|
|
1058
1283
|
// publicly visible forever, because every stale request would retry the same non-cacheable
|
|
@@ -1070,24 +1295,25 @@ export function withISR(
|
|
|
1070
1295
|
}
|
|
1071
1296
|
|
|
1072
1297
|
const handler = async (req: Request, platform?: ISRPlatform): Promise<Response> => {
|
|
1073
|
-
// A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML
|
|
1074
|
-
// by URL, and serving one to
|
|
1075
|
-
// the loader payload. (The write path already refuses
|
|
1298
|
+
// A data-mode soft-nav GET or a slot swap bypasses the cache entirely: entries are full HTML
|
|
1299
|
+
// documents keyed by URL, and serving one to either would hand the client a document where it
|
|
1300
|
+
// expects the loader payload or one slot. (The write path already refuses both.)
|
|
1076
1301
|
const privateRoute: unknown = Reflect.get(app, Symbol.for("nifra.web.private-route"))
|
|
1077
1302
|
const ownsPrivate = typeof privateRoute === "function" && privateRoute(req) === true
|
|
1078
1303
|
const key =
|
|
1079
1304
|
req.method === "GET" &&
|
|
1080
1305
|
req.headers.get("x-nifra-data") === null &&
|
|
1081
1306
|
!req.headers.has("x-nifra-component") &&
|
|
1307
|
+
!req.headers.has(SWAP_HEADER) &&
|
|
1082
1308
|
!ownsPrivate
|
|
1083
1309
|
? keyOf(req)
|
|
1084
1310
|
: null
|
|
1085
|
-
if (key === null) return
|
|
1311
|
+
if (key === null) return passThrough(req, platform)
|
|
1086
1312
|
|
|
1087
1313
|
// Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
|
|
1088
1314
|
// unpublished content can't leak into the public cache (and the editor isn't served a stale page).
|
|
1089
1315
|
if (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret))) {
|
|
1090
|
-
return
|
|
1316
|
+
return passThrough(req, platform)
|
|
1091
1317
|
}
|
|
1092
1318
|
|
|
1093
1319
|
// A cookie or Authorization request may be personalized: under the default key it never reads
|
|
@@ -1101,7 +1327,11 @@ export function withISR(
|
|
|
1101
1327
|
}
|
|
1102
1328
|
|
|
1103
1329
|
const read = personal ? undefined : await attempt("get", key, () => store.get(key))
|
|
1104
|
-
|
|
1330
|
+
let hit = read === STORE_FAILED ? undefined : read
|
|
1331
|
+
if (hit !== undefined && signer !== undefined && !(await verified(signer, key, hit))) {
|
|
1332
|
+
reportStoreError("verify", key, new Error("a stored page failed its signature check"))
|
|
1333
|
+
hit = undefined
|
|
1334
|
+
}
|
|
1105
1335
|
if (hit !== undefined) {
|
|
1106
1336
|
if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
|
|
1107
1337
|
// Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
|
|
@@ -1123,6 +1353,9 @@ export function withISR(
|
|
|
1123
1353
|
const jsonError = (status: number, error: string): Response =>
|
|
1124
1354
|
Response.json({ ok: false, error }, { status })
|
|
1125
1355
|
|
|
1356
|
+
const isHandoff = (value: unknown): value is InvalidationHandoff =>
|
|
1357
|
+
typeof value === "object" && value !== null && typeof Reflect.get(value, "take") === "function"
|
|
1358
|
+
|
|
1126
1359
|
/** What a CDN purge came to: sent and accepted, queued behind a rate limit or retry, or refused. */
|
|
1127
1360
|
export interface CdnPurgeOutcome {
|
|
1128
1361
|
readonly cdn: "accepted" | "queued" | "failed"
|
|
@@ -1172,6 +1405,11 @@ export interface RevalidateEndpointOptions {
|
|
|
1172
1405
|
* page store is touched, so a retry purges everything again. Path purges do not reach them.
|
|
1173
1406
|
*/
|
|
1174
1407
|
readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
|
|
1408
|
+
/**
|
|
1409
|
+
* The app's live runtime: a purge also tells live pages watching a purged tag to reload, after the
|
|
1410
|
+
* caches and before the CDN. A publish that fails answers `502` with `origin: "done"`.
|
|
1411
|
+
*/
|
|
1412
|
+
readonly live?: { publish(...keys: string[]): Promise<void> }
|
|
1175
1413
|
}
|
|
1176
1414
|
|
|
1177
1415
|
const stringList = (value: unknown): string[] | null | undefined => {
|
|
@@ -1263,47 +1501,51 @@ export function revalidateEndpoint(
|
|
|
1263
1501
|
return jsonError(501, "tag_invalidation_not_supported")
|
|
1264
1502
|
}
|
|
1265
1503
|
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1504
|
+
// The same pipeline an action's `revalidate()` runs: data caches, this store, live, then the CDN.
|
|
1505
|
+
const live = options.live
|
|
1506
|
+
const report = await runInvalidation(
|
|
1507
|
+
{ id: crypto.randomUUID(), paths, tags, keys: [] },
|
|
1508
|
+
{
|
|
1509
|
+
dataCaches: options.dataCaches,
|
|
1510
|
+
store: options.store,
|
|
1511
|
+
keyOf: (candidate) => keys[paths.indexOf(candidate)] ?? null,
|
|
1512
|
+
publish: live === undefined ? undefined : (names) => live.publish(...names),
|
|
1513
|
+
cdn: options.cdn,
|
|
1514
|
+
},
|
|
1515
|
+
req,
|
|
1516
|
+
platform,
|
|
1517
|
+
)
|
|
1518
|
+
const stageOf = (stage: string) => report.stages.find((each) => each.stage === stage)
|
|
1519
|
+
if (stageOf("data")?.ok === false) return jsonError(500, "data_cache_invalidation_failed")
|
|
1520
|
+
if (stageOf("pages")?.ok === false) return jsonError(500, "page_store_invalidation_failed")
|
|
1280
1521
|
const done =
|
|
1281
1522
|
path !== null
|
|
1282
1523
|
? { revalidated: path }
|
|
1283
1524
|
: tag !== null
|
|
1284
1525
|
? { revalidatedTag: tag }
|
|
1285
1526
|
: { revalidated: paths, revalidatedTags: tags }
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
try {
|
|
1290
|
-
outcome = await options.cdn.purge({ tags, paths }, platform)
|
|
1291
|
-
} catch {
|
|
1292
|
-
outcome = { cdn: "failed", retryable: true, error: "cdn_purge_threw" }
|
|
1293
|
-
}
|
|
1294
|
-
if (outcome.cdn === "failed") {
|
|
1527
|
+
const published = stageOf("publish")
|
|
1528
|
+
const cdn = stageOf("cdn")
|
|
1529
|
+
if (cdn?.ok === false) {
|
|
1295
1530
|
const failure: Record<string, unknown> = {
|
|
1296
1531
|
ok: false,
|
|
1297
1532
|
error: "cdn_purge_failed",
|
|
1298
1533
|
origin: "done",
|
|
1299
|
-
retryable:
|
|
1534
|
+
retryable: cdn.retryable,
|
|
1300
1535
|
}
|
|
1301
|
-
if (
|
|
1536
|
+
if (cdn.error !== undefined && cdn.error !== "cdn_failed") failure.reason = cdn.error
|
|
1302
1537
|
return Response.json(failure, { status: 502 })
|
|
1303
1538
|
}
|
|
1539
|
+
if (published?.ok === false) {
|
|
1540
|
+
return Response.json(
|
|
1541
|
+
{ ok: false, error: "live_publish_failed", origin: "done", retryable: true },
|
|
1542
|
+
{ status: 502 },
|
|
1543
|
+
)
|
|
1544
|
+
}
|
|
1545
|
+
if (cdn === undefined) return Response.json(done)
|
|
1304
1546
|
return Response.json(
|
|
1305
|
-
{ ...done, cdn:
|
|
1306
|
-
{ status:
|
|
1547
|
+
{ ...done, cdn: cdn.queued === true ? "queued" : "accepted" },
|
|
1548
|
+
{ status: cdn.queued === true ? 202 : 200 },
|
|
1307
1549
|
)
|
|
1308
1550
|
}
|
|
1309
1551
|
}
|