@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/live.ts
ADDED
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live data refresh, server side. Loaders declare what their data depends on with `ctx.live(...keys)`;
|
|
3
|
+
* a live load returns an encrypted grant for those keys, and the client reloads through the same
|
|
4
|
+
* authorized path whenever one of them changes. Data never travels with a change notice.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { live } from "@nifrajs/web/live"
|
|
8
|
+
*
|
|
9
|
+
* const liveRuntime = live({ secret: process.env.LIVE_SECRET, subject: (ctx) => sessionUserId(ctx.request) })
|
|
10
|
+
* createWebApp({ ...options, live: liveRuntime })
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Every live load is a `POST` to the load endpoint that runs the target page as a `GET` through the
|
|
14
|
+
* whole app: every hook, middleware, gate and loader sees the target path, exactly as a navigation.
|
|
15
|
+
* Changes reach open pages over one WebSocket per tab, as opaque tags of the keys they watch.
|
|
16
|
+
*/
|
|
17
|
+
import { isSameOriginForm, jsonLogger, type Logger, replacedRequestOf } from "@nifrajs/core/server"
|
|
18
|
+
import { websocket } from "@nifrajs/core/ws"
|
|
19
|
+
import {
|
|
20
|
+
LIVE_ATTACH,
|
|
21
|
+
type LiveAttachInput,
|
|
22
|
+
type LiveAttachment,
|
|
23
|
+
type LiveEnvelope,
|
|
24
|
+
type LiveFinish,
|
|
25
|
+
type LiveLoad,
|
|
26
|
+
type LiveRuntimeHandle,
|
|
27
|
+
} from "./internal/live-contract.ts"
|
|
28
|
+
import { queryDigester, querySalt } from "./internal/query-digest.ts"
|
|
29
|
+
import {
|
|
30
|
+
type LiveBus,
|
|
31
|
+
type LiveBusEvent,
|
|
32
|
+
type LiveBusInput,
|
|
33
|
+
type LiveBusSubscription,
|
|
34
|
+
memoryBus,
|
|
35
|
+
} from "./live/bus.ts"
|
|
36
|
+
import { applyEvent, type LiveCore } from "./live/core.ts"
|
|
37
|
+
import { History, keyHasher } from "./live/history.ts"
|
|
38
|
+
import {
|
|
39
|
+
CLOSE_CAPACITY,
|
|
40
|
+
CLOSE_GOING_AWAY,
|
|
41
|
+
LiveHub,
|
|
42
|
+
type LiveSocket,
|
|
43
|
+
tagCache,
|
|
44
|
+
unref,
|
|
45
|
+
} from "./live/hub.ts"
|
|
46
|
+
import {
|
|
47
|
+
HUB_APPEND_PATH,
|
|
48
|
+
HUB_AUTHORITY_PATH,
|
|
49
|
+
HUB_CONFIG_HEADER,
|
|
50
|
+
HUB_ORIGIN,
|
|
51
|
+
hubConfigOf,
|
|
52
|
+
hubName,
|
|
53
|
+
type LiveHubNamespace,
|
|
54
|
+
MAX_APPEND_KEYS,
|
|
55
|
+
parseAuthorityReply,
|
|
56
|
+
parseSeq,
|
|
57
|
+
} from "./live/hub-protocol.ts"
|
|
58
|
+
import {
|
|
59
|
+
assertLiveSecrets,
|
|
60
|
+
createKeyring,
|
|
61
|
+
type GrantClaims,
|
|
62
|
+
type GrantOwner,
|
|
63
|
+
KEY_PATTERN,
|
|
64
|
+
type Keyring,
|
|
65
|
+
MAX_GRANT_CHARS,
|
|
66
|
+
MAX_KEYS_PER_FRAME,
|
|
67
|
+
type OpenedGrant,
|
|
68
|
+
SEED_SID,
|
|
69
|
+
SID_PATTERN,
|
|
70
|
+
toBase64Url,
|
|
71
|
+
} from "./live/keyring.ts"
|
|
72
|
+
import { readCapped } from "./live/read-capped.ts"
|
|
73
|
+
import { type RevocationCheck, Revocations, RevocationView } from "./live/revocations.ts"
|
|
74
|
+
import {
|
|
75
|
+
appIdOf,
|
|
76
|
+
DEFAULT_MAX_SOCKETS,
|
|
77
|
+
DEFAULT_RELOAD_RATE,
|
|
78
|
+
grantTtlOf,
|
|
79
|
+
positive,
|
|
80
|
+
spreadFloorOf,
|
|
81
|
+
} from "./live/settings.ts"
|
|
82
|
+
import type { LoaderContext } from "./manifest.ts"
|
|
83
|
+
import {
|
|
84
|
+
DATA_HEADER,
|
|
85
|
+
HOLE_FILL_HEADER,
|
|
86
|
+
KEEP_HOLES_HEADER,
|
|
87
|
+
NAV_FROM_HEADER,
|
|
88
|
+
RETAIN_HEADER,
|
|
89
|
+
} from "./router.ts"
|
|
90
|
+
|
|
91
|
+
export type {
|
|
92
|
+
LiveBus,
|
|
93
|
+
LiveBusEvent,
|
|
94
|
+
LiveBusInput,
|
|
95
|
+
LiveBusListener,
|
|
96
|
+
LiveBusSubscription,
|
|
97
|
+
LiveChange,
|
|
98
|
+
LiveRevocation,
|
|
99
|
+
} from "./live/bus.ts"
|
|
100
|
+
export { livePublisher, memoryBus, nextSeq } from "./live/bus.ts"
|
|
101
|
+
|
|
102
|
+
export interface LiveOptions {
|
|
103
|
+
/**
|
|
104
|
+
* The grant secret, or a rotation list with the newest first (at most 3, each at least 32 bytes).
|
|
105
|
+
* Required in production; development falls back to a random per-process secret.
|
|
106
|
+
*/
|
|
107
|
+
readonly secret?: string | readonly string[] | undefined
|
|
108
|
+
/**
|
|
109
|
+
* Who the viewer is: a stable id, or `null` for an anonymous viewer. Called once per live load,
|
|
110
|
+
* after middleware. A grant is only honored for the subject it was issued to.
|
|
111
|
+
*/
|
|
112
|
+
readonly subject: (ctx: LoaderContext) => string | null | Promise<string | null>
|
|
113
|
+
/** Separates this app's tags from another app's that shares a secret. Default `"nifra"`. */
|
|
114
|
+
readonly appId?: string
|
|
115
|
+
/** Grant lifetime in seconds, at most 3600. Default 600. */
|
|
116
|
+
readonly grantTtl?: number
|
|
117
|
+
/** The live load endpoint. Default `/_nifra/live/load`. */
|
|
118
|
+
readonly loadPath?: string
|
|
119
|
+
/** The live socket. Default `/_nifra/live`. */
|
|
120
|
+
readonly path?: string
|
|
121
|
+
/** Origins besides the app's own that may call the load endpoint and open the socket. */
|
|
122
|
+
readonly trustedOrigins?: readonly string[]
|
|
123
|
+
/** Live loads running at once in this process; past it the endpoint answers 503. Default 64. */
|
|
124
|
+
readonly maxConcurrentLoads?: number
|
|
125
|
+
/** Live sockets in this process; past it a socket is closed with 1013. Default 10,000. */
|
|
126
|
+
readonly maxSockets?: number
|
|
127
|
+
/** Refuse new sockets while the process's resident memory is above this many bytes. */
|
|
128
|
+
readonly maxRssBytes?: number
|
|
129
|
+
/**
|
|
130
|
+
* The ordered log every process serving the app shares. Default: an in-process bus, right for one
|
|
131
|
+
* process only.
|
|
132
|
+
*/
|
|
133
|
+
readonly bus?: LiveBus
|
|
134
|
+
/**
|
|
135
|
+
* Cloudflare Workers: the Durable Object namespace of the app's live hub (`liveHub()` from
|
|
136
|
+
* `@nifrajs/web/live-hub`). The hub owns the log and holds the sockets; this isolate forwards the
|
|
137
|
+
* socket handshake to it, asks it for the seq and the revocations each load needs, and publishes
|
|
138
|
+
* through it. Exclusive with `bus`.
|
|
139
|
+
*/
|
|
140
|
+
readonly hub?: LiveHubNamespace
|
|
141
|
+
/** Processes serving the app, for spreading reloads. Default 1. */
|
|
142
|
+
readonly instances?: number
|
|
143
|
+
/** Live loads per second the deployment absorbs after one change, for spreading reloads. Default 500. */
|
|
144
|
+
readonly reloadRate?: number
|
|
145
|
+
/** The least time, in ms, clients spread their reloads over. Default 0. */
|
|
146
|
+
readonly spreadFloor?: number
|
|
147
|
+
/**
|
|
148
|
+
* Re-check the keys of owners a load keeps without running their loaders. With it, `renew`
|
|
149
|
+
* re-signs a grant without loaders (every third renewal still runs them all, so a lost change is
|
|
150
|
+
* found within about 27 minutes); without it, `renew` runs a full load. `false` issues no grant.
|
|
151
|
+
*/
|
|
152
|
+
readonly authorize?: (keys: readonly string[], ctx: LoaderContext) => boolean | Promise<boolean>
|
|
153
|
+
/**
|
|
154
|
+
* Resolves once a committed write is readable by every loader, such as a read replica catching up.
|
|
155
|
+
* `publish` waits for it, so no client reloads before its data can show the change. Replica-backed
|
|
156
|
+
* reads need it; without it a publish goes out as soon as it is called.
|
|
157
|
+
*/
|
|
158
|
+
readonly visible?: () => void | Promise<void>
|
|
159
|
+
/**
|
|
160
|
+
* Let query hooks refetch on the keys a page's loaders grant (`useQuery(key, fn, { live })`). Each
|
|
161
|
+
* live load then carries a salted digest of every key it grants: page code can confirm a guess at
|
|
162
|
+
* one of them, so leave it off when key names hold anything the viewer should not see.
|
|
163
|
+
*/
|
|
164
|
+
readonly queryKeys?: boolean
|
|
165
|
+
/**
|
|
166
|
+
* Where the runtime reports what it cannot fix by itself: the bus stopping to answer and answering
|
|
167
|
+
* again, events lost to a trim, an event it could not apply, a publish it did not read back.
|
|
168
|
+
* Default `jsonLogger()`; `false` reports nothing. {@link Live.status} has the same state to poll.
|
|
169
|
+
*/
|
|
170
|
+
readonly logger?: Logger | false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What one process's live runtime is doing now; counters run from its start. */
|
|
174
|
+
export interface LiveStatus {
|
|
175
|
+
/** The keyring is derived and the bus read: grants can be issued. */
|
|
176
|
+
readonly ready: boolean
|
|
177
|
+
/**
|
|
178
|
+
* No grant is issued and no socket kept: the bus (or the Workers hub) is unreachable, or the
|
|
179
|
+
* runtime was closed. Open pages poll meanwhile.
|
|
180
|
+
*/
|
|
181
|
+
readonly down: boolean
|
|
182
|
+
/** Since when `down` has held, in epoch ms. */
|
|
183
|
+
readonly downSince: number | undefined
|
|
184
|
+
/** Live sockets open in this process. On Workers the hub holds them, so this is 0. */
|
|
185
|
+
readonly sockets: number
|
|
186
|
+
/** Live loads running in this process. */
|
|
187
|
+
readonly loads: number
|
|
188
|
+
/** Bus events this process applied. */
|
|
189
|
+
readonly events: number
|
|
190
|
+
/** `changed` frames this process sent, one per watching socket: each makes a page reload. */
|
|
191
|
+
readonly notified: number
|
|
192
|
+
/**
|
|
193
|
+
* How long after its append the last event was applied here, in ms. The append time comes from the
|
|
194
|
+
* bus's clock, so skew between hosts shows in it.
|
|
195
|
+
*/
|
|
196
|
+
readonly lagMs: number | undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A live runtime: pass it to `createWebApp({ live })`. */
|
|
200
|
+
export interface Live extends LiveRuntimeHandle {
|
|
201
|
+
/** Resolves once the secrets are derived and the bus is read; rejects on a duplicate secret id. */
|
|
202
|
+
readonly ready: Promise<void>
|
|
203
|
+
/**
|
|
204
|
+
* `keys` changed: every open page watching one reloads it. Call it after the write is visible to
|
|
205
|
+
* loaders; resolves once this process has applied it, or 2 s after the bus took it if this process
|
|
206
|
+
* has not read it back by then.
|
|
207
|
+
*/
|
|
208
|
+
publish(...keys: string[]): Promise<void>
|
|
209
|
+
/** Refuse every grant issued to `subject` before now, and close its sockets. */
|
|
210
|
+
revoke(subject: string): Promise<void>
|
|
211
|
+
/** Refuse every grant issued before now, and close every socket. */
|
|
212
|
+
revokeAll(): Promise<void>
|
|
213
|
+
/**
|
|
214
|
+
* Access to `key` was removed (for everyone, or for one subject): every watcher gets one last change
|
|
215
|
+
* for it and stops watching it, and no grant issued before now keeps or re-signs it, so the next load
|
|
216
|
+
* runs that owner's loader, which decides again.
|
|
217
|
+
*/
|
|
218
|
+
revokeKey(key: string, subject?: string): Promise<void>
|
|
219
|
+
/** What this process's runtime is doing now: for a readiness check or metrics. */
|
|
220
|
+
status(): LiveStatus
|
|
221
|
+
/**
|
|
222
|
+
* Stop reading the bus and close every live socket (1001); open pages poll until they reach another
|
|
223
|
+
* process. Call it before closing the bus's connections, so the reader does not count that as an
|
|
224
|
+
* outage. A publish after it throws. Idempotent.
|
|
225
|
+
*/
|
|
226
|
+
close(): Promise<void>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const DEFAULT_LOAD_PATH = "/_nifra/live/load"
|
|
230
|
+
const DEFAULT_SOCKET_PATH = "/_nifra/live"
|
|
231
|
+
const DEFAULT_MAX_LOADS = 64
|
|
232
|
+
const MAX_BODY_BYTES = 65_536
|
|
233
|
+
const MAX_PATH_CHARS = 2048
|
|
234
|
+
const MAX_GRANTS = 3
|
|
235
|
+
const MAX_RETAIN = 64
|
|
236
|
+
const MAX_OWNER_CHARS = 512
|
|
237
|
+
/** A partially prerendered page has at most 32 holes, each named in at most 32 characters. */
|
|
238
|
+
const MAX_HOSTS = 32
|
|
239
|
+
const MAX_HOST_CHARS = 32
|
|
240
|
+
const HOST = /^[a-z0-9-]+$/
|
|
241
|
+
const PAGE_FRAME = "page"
|
|
242
|
+
/**
|
|
243
|
+
* How old a seed may be when its page exchanges it: the time from the render to the socket's welcome.
|
|
244
|
+
* The exchanged grant keeps owners no loader re-checked, so this bounds how stale that check is.
|
|
245
|
+
*/
|
|
246
|
+
const SEED_MAX_AGE_SECONDS = 60
|
|
247
|
+
const NO_OWNERS: readonly string[] = []
|
|
248
|
+
/** Renewals re-signed without loaders before one runs them all. */
|
|
249
|
+
const MAX_QUIET_RENEWALS = 2
|
|
250
|
+
/** How long a publish waits to read its own event back; the event is in the log either way. */
|
|
251
|
+
const APPLY_WAIT_MS = 2000
|
|
252
|
+
|
|
253
|
+
const HOP_BY_HOP = [
|
|
254
|
+
"connection",
|
|
255
|
+
"keep-alive",
|
|
256
|
+
"proxy-connection",
|
|
257
|
+
"te",
|
|
258
|
+
"trailer",
|
|
259
|
+
"transfer-encoding",
|
|
260
|
+
"upgrade",
|
|
261
|
+
]
|
|
262
|
+
const BODY_HEADERS = ["content-length", "content-type", "content-encoding"]
|
|
263
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
264
|
+
const NO_STORE = "private, no-store"
|
|
265
|
+
|
|
266
|
+
interface LoadBody {
|
|
267
|
+
readonly path: string
|
|
268
|
+
readonly sid: string
|
|
269
|
+
readonly frame: string
|
|
270
|
+
readonly mode: "load" | "renew"
|
|
271
|
+
readonly retain: readonly string[]
|
|
272
|
+
readonly grants: readonly string[]
|
|
273
|
+
/** The holes whose hosts the page holds: each reloads as a fill. */
|
|
274
|
+
readonly fill: readonly string[]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** What the runtime holds once the keyring is derived and the bus subscribed. */
|
|
278
|
+
type Core = LiveCore
|
|
279
|
+
|
|
280
|
+
interface AuthorityState {
|
|
281
|
+
/** A load's watermark: every owner it loads fresh is at least this new. */
|
|
282
|
+
readonly seq: number
|
|
283
|
+
/** The log is unreachable: nothing presented is honored and no grant is issued. */
|
|
284
|
+
readonly down: boolean
|
|
285
|
+
readonly revocations: RevocationCheck
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface Authority {
|
|
289
|
+
readonly ring: Keyring
|
|
290
|
+
check(claims: readonly GrantClaims[]): Promise<AuthorityState>
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const REFUSE_ALL: RevocationCheck = { grantRevoked: () => true, keyRevoked: () => true }
|
|
294
|
+
|
|
295
|
+
type Queued =
|
|
296
|
+
| { readonly kind: "event"; readonly event: LiveBusEvent }
|
|
297
|
+
| { readonly kind: "gap"; readonly head: number }
|
|
298
|
+
| { readonly kind: "down"; readonly error: unknown }
|
|
299
|
+
|
|
300
|
+
/** Create the live runtime for one app. */
|
|
301
|
+
export function live(options: LiveOptions): Live {
|
|
302
|
+
if (typeof options.subject !== "function") {
|
|
303
|
+
throw new TypeError("[nifra/web] live: `subject` must be a function returning a string or null")
|
|
304
|
+
}
|
|
305
|
+
const ttl = grantTtlOf(options.grantTtl)
|
|
306
|
+
const maxLoads = positive(options.maxConcurrentLoads, DEFAULT_MAX_LOADS, "maxConcurrentLoads")
|
|
307
|
+
const maxSockets = positive(options.maxSockets, DEFAULT_MAX_SOCKETS, "maxSockets")
|
|
308
|
+
const instances = positive(options.instances, 1, "instances")
|
|
309
|
+
const reloadRate = positive(options.reloadRate, DEFAULT_RELOAD_RATE, "reloadRate")
|
|
310
|
+
const spreadFloor = spreadFloorOf(options.spreadFloor)
|
|
311
|
+
const maxRssBytes = options.maxRssBytes
|
|
312
|
+
if (maxRssBytes !== undefined) positive(maxRssBytes, 1, "maxRssBytes")
|
|
313
|
+
const loadPath = endpointPath(options.loadPath ?? DEFAULT_LOAD_PATH, "loadPath")
|
|
314
|
+
const socketPath = endpointPath(options.path ?? DEFAULT_SOCKET_PATH, "path")
|
|
315
|
+
if (socketPath === loadPath) {
|
|
316
|
+
throw new TypeError("[nifra/web] live: `path` and `loadPath` must differ")
|
|
317
|
+
}
|
|
318
|
+
const appId = appIdOf(options.appId)
|
|
319
|
+
const hubNamespace = options.hub
|
|
320
|
+
if (hubNamespace !== undefined && noSecret(options.secret)) {
|
|
321
|
+
// A per-process fallback secret would differ from the hub's: no grant would ever open there.
|
|
322
|
+
throw new TypeError("[nifra/web] live: `hub` needs `secret`, the same one the hub is given")
|
|
323
|
+
}
|
|
324
|
+
const secrets = secretsOf(options.secret)
|
|
325
|
+
assertLiveSecrets(secrets)
|
|
326
|
+
const trusted = new Set(options.trustedOrigins ?? [])
|
|
327
|
+
const authorize = options.authorize
|
|
328
|
+
const visible = options.visible
|
|
329
|
+
const subjectOf = options.subject
|
|
330
|
+
const queryKeys = options.queryKeys === true
|
|
331
|
+
const production = isProduction()
|
|
332
|
+
const logger = options.logger === false ? undefined : (options.logger ?? jsonLogger())
|
|
333
|
+
if (hubNamespace !== undefined) {
|
|
334
|
+
if (options.bus !== undefined) {
|
|
335
|
+
throw new TypeError("[nifra/web] live: `hub` and `bus` are exclusive; the hub owns the log")
|
|
336
|
+
}
|
|
337
|
+
if (typeof hubNamespace?.idFromName !== "function" || typeof hubNamespace.get !== "function") {
|
|
338
|
+
throw new TypeError("[nifra/web] live: `hub` must be the live hub's Durable Object namespace")
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const bus = options.bus ?? memoryBus()
|
|
342
|
+
const hash = keyHasher()
|
|
343
|
+
const publishers = new Set<(topic: string, frame: string) => void>()
|
|
344
|
+
|
|
345
|
+
// Bus events wait here until the core exists, then apply in seq order: their tags are derived
|
|
346
|
+
// first (async), then each is recorded and fanned out in one synchronous step.
|
|
347
|
+
const queue: Queued[] = []
|
|
348
|
+
let draining = false
|
|
349
|
+
let core: Core | undefined
|
|
350
|
+
/** The last bus seq this process applied: a load reads it before any loader runs. */
|
|
351
|
+
let seen = 0
|
|
352
|
+
const waiters: { readonly seq: number; readonly resolve: () => void }[] = []
|
|
353
|
+
/** A publish already found this process behind the bus; the next applied event clears it. */
|
|
354
|
+
let lagging = false
|
|
355
|
+
let subscription: LiveBusSubscription | undefined
|
|
356
|
+
let closed = false
|
|
357
|
+
let closing: Promise<void> | undefined
|
|
358
|
+
let closedAt = 0
|
|
359
|
+
let downSince: number | undefined
|
|
360
|
+
let events = 0
|
|
361
|
+
let lagMs: number | undefined
|
|
362
|
+
|
|
363
|
+
const keyring = createKeyring({ secrets, appId, ttl })
|
|
364
|
+
let keyringReady = false
|
|
365
|
+
keyring.then(
|
|
366
|
+
() => {
|
|
367
|
+
keyringReady = true
|
|
368
|
+
},
|
|
369
|
+
() => {},
|
|
370
|
+
)
|
|
371
|
+
// One cache for the tags a load hands out and the tags this process's sockets join.
|
|
372
|
+
const tags = keyring.then((ring) => tagCache(ring))
|
|
373
|
+
tags.catch(() => {})
|
|
374
|
+
const started: Promise<Core> = (async () => {
|
|
375
|
+
const ring = await keyring
|
|
376
|
+
// On Workers the hub holds the log: this isolate reads nothing.
|
|
377
|
+
if (hubNamespace !== undefined) return new Promise<Core>(() => {})
|
|
378
|
+
const subscribed = await bus.subscribe({
|
|
379
|
+
event: (event) => enqueue({ kind: "event", event }),
|
|
380
|
+
gap: (head) => enqueue({ kind: "gap", head }),
|
|
381
|
+
down: (error) => enqueue({ kind: "down", error }),
|
|
382
|
+
})
|
|
383
|
+
subscription = subscribed
|
|
384
|
+
if (closed) subscribed.close()
|
|
385
|
+
seen = subscribed.head
|
|
386
|
+
const history = new History(subscribed.head)
|
|
387
|
+
const revocations = new Revocations(subscribed.head, ttl * 1000)
|
|
388
|
+
const hub = new LiveHub(
|
|
389
|
+
ring,
|
|
390
|
+
history,
|
|
391
|
+
revocations,
|
|
392
|
+
{
|
|
393
|
+
appId,
|
|
394
|
+
trusted,
|
|
395
|
+
maxSockets,
|
|
396
|
+
maxRssBytes,
|
|
397
|
+
reloadRate,
|
|
398
|
+
instances,
|
|
399
|
+
spreadFloor,
|
|
400
|
+
hash,
|
|
401
|
+
tags: await tags,
|
|
402
|
+
},
|
|
403
|
+
publishers,
|
|
404
|
+
)
|
|
405
|
+
core = { ring, history, revocations, hub }
|
|
406
|
+
if (closed) hub.down = true
|
|
407
|
+
void drain()
|
|
408
|
+
return core
|
|
409
|
+
})()
|
|
410
|
+
// `ready` carries a failed start; nothing else may leave it unhandled.
|
|
411
|
+
started.catch(() => {})
|
|
412
|
+
|
|
413
|
+
/** What a load checks its grants against: this process's own core, or the Workers hub. */
|
|
414
|
+
const authority: Promise<Authority> =
|
|
415
|
+
hubNamespace === undefined
|
|
416
|
+
? started.then((c) => ({
|
|
417
|
+
ring: c.ring,
|
|
418
|
+
check: async () => ({ seq: seen, down: c.hub.down, revocations: c.revocations }),
|
|
419
|
+
}))
|
|
420
|
+
: keyring.then((ring) => ({ ring, check: (claims) => hubCheck(hubNamespace, ring, claims) }))
|
|
421
|
+
authority.catch(() => {})
|
|
422
|
+
|
|
423
|
+
/** One call through the binding; `undefined` when the hub is unreachable or answers wrong. */
|
|
424
|
+
async function hubCall(
|
|
425
|
+
namespace: LiveHubNamespace,
|
|
426
|
+
ring: Keyring,
|
|
427
|
+
path: string,
|
|
428
|
+
body: unknown,
|
|
429
|
+
): Promise<unknown> {
|
|
430
|
+
try {
|
|
431
|
+
const stub = namespace.get(namespace.idFromName(hubName(appId)))
|
|
432
|
+
const res = await stub.fetch(
|
|
433
|
+
new Request(`${HUB_ORIGIN}${path}`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "content-type": "application/json", [HUB_CONFIG_HEADER]: hubConfigOf(ring) },
|
|
436
|
+
body: JSON.stringify(body),
|
|
437
|
+
}),
|
|
438
|
+
)
|
|
439
|
+
if (!res.ok) {
|
|
440
|
+
await res.body?.cancel()
|
|
441
|
+
return undefined
|
|
442
|
+
}
|
|
443
|
+
return await res.json()
|
|
444
|
+
} catch {
|
|
445
|
+
return undefined
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function hubCheck(
|
|
450
|
+
namespace: LiveHubNamespace,
|
|
451
|
+
ring: Keyring,
|
|
452
|
+
claims: readonly GrantClaims[],
|
|
453
|
+
): Promise<AuthorityState> {
|
|
454
|
+
const subjects = [
|
|
455
|
+
...new Set(claims.flatMap((claim) => (claim.sub === null ? [] : [claim.sub]))),
|
|
456
|
+
]
|
|
457
|
+
const keys = [
|
|
458
|
+
...new Set(
|
|
459
|
+
claims.flatMap((claim) => Object.values(claim.owners).flatMap((owner) => owner.k)),
|
|
460
|
+
),
|
|
461
|
+
]
|
|
462
|
+
const reply = parseAuthorityReply(
|
|
463
|
+
await hubCall(namespace, ring, HUB_AUTHORITY_PATH, { subjects, keys }),
|
|
464
|
+
)
|
|
465
|
+
// Unreachable: revocations are unknown, so nothing presented is honored and no grant is issued.
|
|
466
|
+
if (reply === undefined) {
|
|
467
|
+
downSince ??= Date.now()
|
|
468
|
+
return { seq: 0, down: true, revocations: REFUSE_ALL }
|
|
469
|
+
}
|
|
470
|
+
downSince = undefined
|
|
471
|
+
return { seq: reply.seq, down: false, revocations: new RevocationView(reply) }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The handshake goes to the hub, which holds every socket; it checks the Origin itself. */
|
|
475
|
+
async function forwardSocket(namespace: LiveHubNamespace, req: Request): Promise<Response> {
|
|
476
|
+
try {
|
|
477
|
+
return await namespace.get(namespace.idFromName(hubName(appId))).fetch(req)
|
|
478
|
+
} catch {
|
|
479
|
+
return Response.json({ error: "unavailable" }, { status: 503 })
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function enqueue(item: Queued): void {
|
|
484
|
+
queue.push(item)
|
|
485
|
+
void drain()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function drain(): Promise<void> {
|
|
489
|
+
if (draining || core === undefined || closed) return
|
|
490
|
+
draining = true
|
|
491
|
+
try {
|
|
492
|
+
for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
|
|
493
|
+
await apply(core, item)
|
|
494
|
+
}
|
|
495
|
+
} finally {
|
|
496
|
+
draining = false
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function apply(c: Core, item: Queued): Promise<void> {
|
|
501
|
+
if (item.kind === "down") {
|
|
502
|
+
if (!c.hub.down) {
|
|
503
|
+
downSince = Date.now()
|
|
504
|
+
logger?.error(
|
|
505
|
+
"live bus unreachable: live sockets closed, open pages poll until it answers",
|
|
506
|
+
{
|
|
507
|
+
error: messageOf(item.error),
|
|
508
|
+
},
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
c.hub.down = true
|
|
512
|
+
c.hub.closeAll(CLOSE_CAPACITY, "unavailable")
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if (item.kind === "gap") {
|
|
516
|
+
if (c.hub.down) {
|
|
517
|
+
logger?.info("live bus answering again: live sockets resume", {
|
|
518
|
+
downMs: Date.now() - (downSince ?? Date.now()),
|
|
519
|
+
})
|
|
520
|
+
} else logger?.warn("live bus lost events before this process read them: live sockets resync")
|
|
521
|
+
return forgetUntil(c, item.head)
|
|
522
|
+
}
|
|
523
|
+
const event = item.event
|
|
524
|
+
// At-least-once delivery: a repeat is already applied.
|
|
525
|
+
if (!Number.isSafeInteger(event.seq) || event.seq <= seen) return
|
|
526
|
+
try {
|
|
527
|
+
await applyEvent(c, event, hash)
|
|
528
|
+
events++
|
|
529
|
+
// The seq is the bus clock's microseconds at the append.
|
|
530
|
+
lagMs = Math.max(0, Date.now() - Math.floor(event.seq / 1000))
|
|
531
|
+
settle(event.seq)
|
|
532
|
+
} catch (err) {
|
|
533
|
+
// An event this process could not apply leaves its history and revocations unknown.
|
|
534
|
+
logger?.error("live bus event could not be applied: live sockets resync", {
|
|
535
|
+
error: messageOf(err),
|
|
536
|
+
})
|
|
537
|
+
forgetUntil(c, event.seq)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Everything up to `head` is unknown: refuse older grants, report all owners stale, reconnect. */
|
|
542
|
+
function forgetUntil(c: Core, head: number): void {
|
|
543
|
+
downSince = undefined
|
|
544
|
+
// Never behind what this process applied: loads already issued watermarks up to `seen`.
|
|
545
|
+
const at = Math.max(head, seen)
|
|
546
|
+
c.history.reset(at)
|
|
547
|
+
c.revocations.raise(at)
|
|
548
|
+
c.hub.down = false
|
|
549
|
+
c.hub.closeAll(CLOSE_CAPACITY, "resync")
|
|
550
|
+
settle(at)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function settle(seq: number): void {
|
|
554
|
+
if (seq > seen) seen = seq
|
|
555
|
+
lagging = false
|
|
556
|
+
for (let i = waiters.length - 1; i >= 0; i--) {
|
|
557
|
+
const waiter = waiters[i]
|
|
558
|
+
if (waiter !== undefined && waiter.seq <= seen) {
|
|
559
|
+
waiters.splice(i, 1)
|
|
560
|
+
waiter.resolve()
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Append to the bus, then wait until this process has applied it, at most {@link APPLY_WAIT_MS}. */
|
|
566
|
+
async function append(input: Parameters<LiveBus["append"]>[0]): Promise<void> {
|
|
567
|
+
if (closed) throw new Error("[nifra/web] live: the runtime is closed; nothing was published")
|
|
568
|
+
if (hubNamespace !== undefined) return appendToHub(hubNamespace, input)
|
|
569
|
+
await started
|
|
570
|
+
const seq = await bus.append(input)
|
|
571
|
+
if (seq <= seen) return
|
|
572
|
+
await new Promise<void>((resolve) => {
|
|
573
|
+
const waiter = {
|
|
574
|
+
seq,
|
|
575
|
+
resolve: () => {
|
|
576
|
+
clearTimeout(timer)
|
|
577
|
+
resolve()
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
// A reader that stalled would hold every publish, and every action awaiting one, for good.
|
|
581
|
+
const timer = unref(
|
|
582
|
+
setTimeout(() => {
|
|
583
|
+
const at = waiters.indexOf(waiter)
|
|
584
|
+
if (at !== -1) waiters.splice(at, 1)
|
|
585
|
+
if (!lagging) {
|
|
586
|
+
lagging = true
|
|
587
|
+
logger?.error(
|
|
588
|
+
"live publish not read back from the bus: this process's sockets miss changes until its reader recovers",
|
|
589
|
+
{ waitedMs: APPLY_WAIT_MS },
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
resolve()
|
|
593
|
+
}, APPLY_WAIT_MS),
|
|
594
|
+
)
|
|
595
|
+
waiters.push(waiter)
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The hub takes at most {@link MAX_APPEND_KEYS} keys a call: a larger change goes as several. */
|
|
600
|
+
async function appendToHub(namespace: LiveHubNamespace, input: LiveBusInput): Promise<void> {
|
|
601
|
+
const ring = await keyring
|
|
602
|
+
const parts: LiveBusInput[] = []
|
|
603
|
+
if (input.type === "change") {
|
|
604
|
+
for (let at = 0; at < input.keys.length; at += MAX_APPEND_KEYS) {
|
|
605
|
+
parts.push({ type: "change", keys: input.keys.slice(at, at + MAX_APPEND_KEYS) })
|
|
606
|
+
}
|
|
607
|
+
} else parts.push(input)
|
|
608
|
+
for (let at = 0; at < parts.length; at++) {
|
|
609
|
+
if (parseSeq(await hubCall(namespace, ring, HUB_APPEND_PATH, parts[at])) === undefined) {
|
|
610
|
+
const published = at * MAX_APPEND_KEYS
|
|
611
|
+
throw new Error(
|
|
612
|
+
published === 0
|
|
613
|
+
? "[nifra/web] live: the hub did not take the event; nothing was published"
|
|
614
|
+
: `[nifra/web] live: the hub did not take the event; the first ${published} keys were published, the rest were not`,
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let inflight = 0
|
|
621
|
+
let entering = 0
|
|
622
|
+
|
|
623
|
+
const attach = (input: LiveAttachInput): LiveAttachment => {
|
|
624
|
+
const loads = new WeakMap<Request, LoadState>()
|
|
625
|
+
const app = input.app
|
|
626
|
+
if (hubNamespace === undefined) registerSocket(app)
|
|
627
|
+
|
|
628
|
+
function registerSocket(app: LiveAttachInput["app"]): void {
|
|
629
|
+
publishers.add(weakPublisher(app, publishers))
|
|
630
|
+
// The socket carries no authority before a watch, so it is registered before any app guard.
|
|
631
|
+
app.use(websocket())
|
|
632
|
+
app.ws<LiveSocket>(socketPath, {
|
|
633
|
+
// Decided in `upgrade`, which has the request: a missing or `null` Origin is refused there.
|
|
634
|
+
allowedOrigins: () => true,
|
|
635
|
+
upgrade: async (c) => (await started).hub.upgrade(c.req),
|
|
636
|
+
// A socket opens only after its `upgrade` awaited the start, so the core exists by then.
|
|
637
|
+
open: (ws) => core?.hub.open(ws),
|
|
638
|
+
message: (ws, data) => core?.hub.message(ws, data),
|
|
639
|
+
close: (ws) => core?.hub.close(ws),
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const admitLoad = async (req: Request): Promise<Request | Response> => {
|
|
644
|
+
const origin = req.headers.get("origin")
|
|
645
|
+
if (
|
|
646
|
+
origin === null ||
|
|
647
|
+
!(trusted.has(origin) || isSameOriginForm(origin, req.headers.get("sec-fetch-site"), req))
|
|
648
|
+
) {
|
|
649
|
+
return refusal(403, "forbidden_origin")
|
|
650
|
+
}
|
|
651
|
+
const type = req.headers.get("content-type")
|
|
652
|
+
if (type === null || mediaType(type) !== "application/json") {
|
|
653
|
+
return refusal(415, "unsupported_media_type")
|
|
654
|
+
}
|
|
655
|
+
if (inflight + entering >= maxLoads) return busy()
|
|
656
|
+
// Reading the body and opening grants count too: forged grants cannot buy unbounded key
|
|
657
|
+
// derivation.
|
|
658
|
+
entering++
|
|
659
|
+
try {
|
|
660
|
+
return await prepareLoad(req)
|
|
661
|
+
} finally {
|
|
662
|
+
entering--
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const prepareLoad = async (req: Request): Promise<Request | Response> => {
|
|
667
|
+
const text = await readCapped(req, MAX_BODY_BYTES)
|
|
668
|
+
if (typeof text === "number") return refusal(text, text === 413 ? "too_large" : "bad_request")
|
|
669
|
+
const body = parseBody(text)
|
|
670
|
+
if (body === undefined) return refusal(400, "bad_request")
|
|
671
|
+
const path = canonicalPath(body.path, req.url)
|
|
672
|
+
if (path === undefined) return refusal(400, "bad_path")
|
|
673
|
+
const auth = await authority
|
|
674
|
+
const match = input.matchRoute(path)
|
|
675
|
+
const now = Date.now()
|
|
676
|
+
const presented: OpenedGrant[] = []
|
|
677
|
+
for (const token of body.grants) {
|
|
678
|
+
const opened = await auth.ring.open(token, now)
|
|
679
|
+
if (opened === undefined) continue
|
|
680
|
+
const claims = opened.claims
|
|
681
|
+
// A seed is exchanged by a load of the page it was rendered with, while it is fresh.
|
|
682
|
+
const seed =
|
|
683
|
+
claims.sid === SEED_SID &&
|
|
684
|
+
body.mode === "load" &&
|
|
685
|
+
claims.path === path &&
|
|
686
|
+
now / 1000 - claims.iat <= SEED_MAX_AGE_SECONDS
|
|
687
|
+
if ((!seed && claims.sid !== body.sid) || claims.frame !== body.frame) continue
|
|
688
|
+
// A navigation presents the grant of the page it leaves: only its layouts can carry over, and
|
|
689
|
+
// only where the layout itself would keep its data. A renewal is for the same page.
|
|
690
|
+
if (claims.path !== path && body.mode === "renew") continue
|
|
691
|
+
presented.push(opened)
|
|
692
|
+
}
|
|
693
|
+
// Read before any hook, middleware or loader runs: every owner loaded fresh is at least this new.
|
|
694
|
+
const state = await auth.check(presented.map((opened) => opened.claims))
|
|
695
|
+
const loadSeq = state.seq
|
|
696
|
+
// With the log unreachable, revocations are unknown: nothing presented is honored.
|
|
697
|
+
const grant = state.down
|
|
698
|
+
? undefined
|
|
699
|
+
: presented.find((opened) => !state.revocations.grantRevoked(opened.claims))
|
|
700
|
+
const requested =
|
|
701
|
+
grant === undefined
|
|
702
|
+
? []
|
|
703
|
+
: body.mode === "load"
|
|
704
|
+
? body.retain
|
|
705
|
+
: authorize === undefined || grant.claims.r >= MAX_QUIET_RENEWALS
|
|
706
|
+
? []
|
|
707
|
+
: Object.keys(grant.claims.owners)
|
|
708
|
+
const layouts: number[] = []
|
|
709
|
+
const holes: string[] = []
|
|
710
|
+
let retainPage = false
|
|
711
|
+
if (grant !== undefined && match !== null) {
|
|
712
|
+
const claims = grant.claims
|
|
713
|
+
for (const owner of requested) {
|
|
714
|
+
const held = Object.hasOwn(claims.owners, owner) ? claims.owners[owner] : undefined
|
|
715
|
+
if (held === undefined) continue
|
|
716
|
+
// A key revoked since issuance is never kept or re-signed: its loader runs and decides again.
|
|
717
|
+
if (held.k.some((key) => state.revocations.keyRevoked(key, claims))) continue
|
|
718
|
+
if (owner.startsWith("L:")) {
|
|
719
|
+
const index = match.layoutIds.indexOf(owner.slice(2))
|
|
720
|
+
if (index !== -1 && !layouts.includes(index)) layouts.push(index)
|
|
721
|
+
} else if (owner === `P:${match.routeId}` && claims.path === path) {
|
|
722
|
+
retainPage = true
|
|
723
|
+
} else if (claims.path === path) {
|
|
724
|
+
const hole = holeOf(owner, match.routeId)
|
|
725
|
+
if (hole !== undefined && !holes.includes(hole)) holes.push(hole)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const headers = internalHeaders(req.headers)
|
|
730
|
+
if (grant !== undefined && layouts.length > 0) {
|
|
731
|
+
// The page the grant was issued for: the retention rule compares each layout's params there
|
|
732
|
+
// with the target's, exactly as for a router navigation.
|
|
733
|
+
headers.set(RETAIN_HEADER, layouts.sort((a, b) => a - b).join(","))
|
|
734
|
+
headers.set(NAV_FROM_HEADER, grant.claims.path)
|
|
735
|
+
}
|
|
736
|
+
if (holes.length > 0) headers.set(KEEP_HOLES_HEADER, holes.join(","))
|
|
737
|
+
if (body.fill.length > 0) headers.set(HOLE_FILL_HEADER, body.fill.join(","))
|
|
738
|
+
const target = new Request(new URL(path, req.url), {
|
|
739
|
+
method: "GET",
|
|
740
|
+
headers,
|
|
741
|
+
signal: req.signal,
|
|
742
|
+
})
|
|
743
|
+
loads.set(
|
|
744
|
+
target,
|
|
745
|
+
new LoadState(
|
|
746
|
+
auth.ring,
|
|
747
|
+
state.down,
|
|
748
|
+
body,
|
|
749
|
+
path,
|
|
750
|
+
loadSeq,
|
|
751
|
+
grant,
|
|
752
|
+
match?.routeId ?? "",
|
|
753
|
+
retainPage,
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
return target
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
onRequest: (req) => {
|
|
761
|
+
const pathname = pathnameOf(req.url)
|
|
762
|
+
if (req.method === "POST" && pathname === loadPath) return admitLoad(req)
|
|
763
|
+
if (hubNamespace !== undefined && req.method === "GET" && pathname === socketPath) {
|
|
764
|
+
return forwardSocket(hubNamespace, req)
|
|
765
|
+
}
|
|
766
|
+
return undefined
|
|
767
|
+
},
|
|
768
|
+
seedOf: (req, routeId) => {
|
|
769
|
+
const c = core
|
|
770
|
+
// A render never waits on the bus: with no core yet, or none that could vouch, it carries none.
|
|
771
|
+
if (hubNamespace !== undefined || c === undefined || closed || c.hub.down) return undefined
|
|
772
|
+
const url = new URL(req.url)
|
|
773
|
+
const path = canonicalPath(url.pathname + url.search, req.url)
|
|
774
|
+
if (path === undefined) return undefined
|
|
775
|
+
const body: LoadBody = {
|
|
776
|
+
path,
|
|
777
|
+
sid: SEED_SID,
|
|
778
|
+
frame: PAGE_FRAME,
|
|
779
|
+
mode: "load",
|
|
780
|
+
retain: NO_OWNERS,
|
|
781
|
+
grants: NO_OWNERS,
|
|
782
|
+
fill: NO_OWNERS,
|
|
783
|
+
}
|
|
784
|
+
return new LoadState(c.ring, false, body, path, seen, undefined, routeId, false)
|
|
785
|
+
},
|
|
786
|
+
loadOf: (req) => {
|
|
787
|
+
// An app hook after the endpoint may hand on a replacement; the load is its original's.
|
|
788
|
+
let current: Request | undefined = req
|
|
789
|
+
for (let hops = 0; current !== undefined && hops < 16; hops++) {
|
|
790
|
+
const load = loads.get(current)
|
|
791
|
+
if (load !== undefined) return load
|
|
792
|
+
current = replacedRequestOf(current)
|
|
793
|
+
}
|
|
794
|
+
return undefined
|
|
795
|
+
},
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
class LoadState implements LiveLoad {
|
|
800
|
+
retainPage: boolean
|
|
801
|
+
private subject: string | null = null
|
|
802
|
+
private admitted = false
|
|
803
|
+
private released = false
|
|
804
|
+
private declared = 0
|
|
805
|
+
/** Keys past the frame budget: counted for the log, never stored. */
|
|
806
|
+
private dropped = 0
|
|
807
|
+
private readonly collectors = new Map<string, { keys: Set<string>; sealed: boolean }>()
|
|
808
|
+
|
|
809
|
+
constructor(
|
|
810
|
+
private readonly ring: Keyring,
|
|
811
|
+
/** No grant is issued: no socket would accept one, and revocations are unknown. */
|
|
812
|
+
private readonly down: boolean,
|
|
813
|
+
private readonly body: LoadBody,
|
|
814
|
+
private readonly path: string,
|
|
815
|
+
private readonly seq: number,
|
|
816
|
+
private readonly grant: OpenedGrant | undefined,
|
|
817
|
+
readonly routeId: string,
|
|
818
|
+
retainPage: boolean,
|
|
819
|
+
) {
|
|
820
|
+
this.retainPage = retainPage
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
admit(): Response | undefined {
|
|
824
|
+
if (inflight >= maxLoads) return busy()
|
|
825
|
+
inflight++
|
|
826
|
+
this.admitted = true
|
|
827
|
+
return undefined
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
release(): void {
|
|
831
|
+
if (!this.admitted || this.released) return
|
|
832
|
+
this.released = true
|
|
833
|
+
inflight--
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
settle(response: Response): Response {
|
|
837
|
+
if (
|
|
838
|
+
response.body !== null &&
|
|
839
|
+
mediaType(response.headers.get("content-type") ?? "") === "application/x-ndjson"
|
|
840
|
+
) {
|
|
841
|
+
// Deferred values are still loading while the body streams: the slot is held until it ends.
|
|
842
|
+
return new Response(
|
|
843
|
+
releasing(response.body, () => this.release()),
|
|
844
|
+
response,
|
|
845
|
+
)
|
|
846
|
+
}
|
|
847
|
+
this.release()
|
|
848
|
+
return response
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async bind(ctx: LoaderContext): Promise<boolean> {
|
|
852
|
+
const subject = await subjectOf(ctx)
|
|
853
|
+
if (subject !== null && typeof subject !== "string") {
|
|
854
|
+
throw new TypeError("[nifra/web] live: `subject` must return a string or null")
|
|
855
|
+
}
|
|
856
|
+
this.subject = subject
|
|
857
|
+
if (this.grant === undefined || this.grant.claims.sub === subject) return true
|
|
858
|
+
this.retainPage = false
|
|
859
|
+
return false
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
collector(owner: string): (...keys: string[]) => void {
|
|
863
|
+
let entry = this.collectors.get(owner)
|
|
864
|
+
if (entry === undefined) {
|
|
865
|
+
entry = { keys: new Set(), sealed: false }
|
|
866
|
+
this.collectors.set(owner, entry)
|
|
867
|
+
}
|
|
868
|
+
const collected = entry
|
|
869
|
+
return (...keys) => {
|
|
870
|
+
if (collected.sealed) {
|
|
871
|
+
throw new TypeError(
|
|
872
|
+
`[nifra/web] ctx.live() was called after the loader of "${owner}" settled; declare keys before the loader returns`,
|
|
873
|
+
)
|
|
874
|
+
}
|
|
875
|
+
for (const key of keys) {
|
|
876
|
+
if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
|
|
877
|
+
throw new TypeError(
|
|
878
|
+
"[nifra/web] ctx.live() keys are a letter followed by up to 127 of A-Z a-z 0-9 . _ : / -",
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
if (collected.keys.has(key)) continue
|
|
882
|
+
if (this.declared >= MAX_KEYS_PER_FRAME) {
|
|
883
|
+
if (!production) {
|
|
884
|
+
throw new RangeError(
|
|
885
|
+
`[nifra/web] a live frame holds at most ${MAX_KEYS_PER_FRAME} keys; "${owner}" declared one more`,
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
this.dropped++
|
|
889
|
+
continue
|
|
890
|
+
}
|
|
891
|
+
collected.keys.add(key)
|
|
892
|
+
this.declared++
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
seal(owner: string): void {
|
|
898
|
+
const entry = this.collectors.get(owner)
|
|
899
|
+
if (entry !== undefined) entry.sealed = true
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** A renewal that kept owners counts one more; a load that ran every loader starts over. */
|
|
903
|
+
private renewals(kept: boolean): number {
|
|
904
|
+
const previous = this.grant?.claims.r ?? 0
|
|
905
|
+
if (!kept) return 0
|
|
906
|
+
return this.body.mode === "renew" ? previous + 1 : previous
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async finish(input: LiveFinish): Promise<LiveEnvelope> {
|
|
910
|
+
for (const entry of this.collectors.values()) entry.sealed = true
|
|
911
|
+
const ring = this.ring
|
|
912
|
+
const subjects = await ring.subjectTags(this.subject)
|
|
913
|
+
const pageRetained = input.retained.some((owner) => owner.startsWith("P:"))
|
|
914
|
+
const bare: LiveEnvelope = pageRetained ? { subjects, page: "retained" } : { subjects }
|
|
915
|
+
// No socket would accept a grant now, and revocations are unknown: the client polls.
|
|
916
|
+
if (this.down || (core?.hub.down ?? false)) return bare
|
|
917
|
+
const owners: Record<string, GrantOwner> = {}
|
|
918
|
+
const carried = new Set<string>()
|
|
919
|
+
for (const owner of input.retained) {
|
|
920
|
+
const held =
|
|
921
|
+
this.grant !== undefined && Object.hasOwn(this.grant.claims.owners, owner)
|
|
922
|
+
? this.grant.claims.owners[owner]
|
|
923
|
+
: undefined
|
|
924
|
+
// Kept data with no grant behind it cannot be vouched for.
|
|
925
|
+
if (held === undefined) return bare
|
|
926
|
+
owners[owner] = { k: held.k, w: held.w }
|
|
927
|
+
for (const key of held.k) carried.add(key)
|
|
928
|
+
}
|
|
929
|
+
if (
|
|
930
|
+
carried.size > 0 &&
|
|
931
|
+
authorize !== undefined &&
|
|
932
|
+
!(await authorize([...carried], input.ctx))
|
|
933
|
+
) {
|
|
934
|
+
return bare
|
|
935
|
+
}
|
|
936
|
+
let count = this.dropped
|
|
937
|
+
for (const owner of input.fresh) {
|
|
938
|
+
const keys = [...(this.collectors.get(owner)?.keys ?? [])]
|
|
939
|
+
owners[owner] = { k: keys, w: this.seq }
|
|
940
|
+
}
|
|
941
|
+
for (const owner of Object.values(owners)) count += owner.k.length
|
|
942
|
+
if (count > MAX_KEYS_PER_FRAME) {
|
|
943
|
+
if (!production) {
|
|
944
|
+
throw new RangeError(
|
|
945
|
+
`[nifra/web] a live frame holds at most ${MAX_KEYS_PER_FRAME} keys; route "${this.routeId}" declared ${count}`,
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
console.error(
|
|
949
|
+
`[nifra/web] live: route "${this.routeId}" declared ${count} keys, over the ${MAX_KEYS_PER_FRAME}-key frame budget; no grant issued, the client polls`,
|
|
950
|
+
)
|
|
951
|
+
return bare
|
|
952
|
+
}
|
|
953
|
+
const grant = await ring.seal(
|
|
954
|
+
{
|
|
955
|
+
sid: this.body.sid,
|
|
956
|
+
sub: this.subject,
|
|
957
|
+
path: this.path,
|
|
958
|
+
frame: this.body.frame,
|
|
959
|
+
owners,
|
|
960
|
+
s: this.seq,
|
|
961
|
+
r: this.renewals(input.retained.length > 0),
|
|
962
|
+
},
|
|
963
|
+
Date.now(),
|
|
964
|
+
)
|
|
965
|
+
if (grant === undefined) {
|
|
966
|
+
console.error(
|
|
967
|
+
`[nifra/web] live: no grant issued for route "${this.routeId}" (over ${MAX_GRANT_CHARS} characters, or the subkey's seal limit); the client polls`,
|
|
968
|
+
)
|
|
969
|
+
return bare
|
|
970
|
+
}
|
|
971
|
+
// Each key to the owners holding it, in owner order.
|
|
972
|
+
const owning = new Map<string, string[]>()
|
|
973
|
+
for (const [name, owner] of Object.entries(owners)) {
|
|
974
|
+
for (const key of owner.k) {
|
|
975
|
+
const list = owning.get(key)
|
|
976
|
+
if (list === undefined) owning.set(key, [name])
|
|
977
|
+
else if (list.at(-1) !== name) list.push(name)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const keys = [...owning.keys()]
|
|
981
|
+
const salt = queryKeys ? querySalt() : undefined
|
|
982
|
+
const tagOf = await tags
|
|
983
|
+
const [wire, digested] = await Promise.all([
|
|
984
|
+
Promise.all(keys.map((key) => tagOf(key, 0))),
|
|
985
|
+
salt === undefined
|
|
986
|
+
? undefined
|
|
987
|
+
: queryDigester(salt).then((digest) => Promise.all(keys.map(digest))),
|
|
988
|
+
])
|
|
989
|
+
const tagMap: Record<string, string[]> = {}
|
|
990
|
+
const digests: Record<string, string> = {}
|
|
991
|
+
keys.forEach((key, at) => {
|
|
992
|
+
const tag = wire[at] ?? ""
|
|
993
|
+
tagMap[tag] = owning.get(key) ?? []
|
|
994
|
+
const digest = digested?.[at]
|
|
995
|
+
if (digest !== undefined) digests[tag] = digest
|
|
996
|
+
})
|
|
997
|
+
const envelope: LiveEnvelope = { ...bare, grant, tags: tagMap, ttl }
|
|
998
|
+
if (salt !== undefined) {
|
|
999
|
+
return {
|
|
1000
|
+
...envelope,
|
|
1001
|
+
query: production ? { salt, keys: digests } : { salt, keys: digests, dev: true },
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return production ? envelope : { ...envelope, query: { dev: true } }
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const checkKey = (key: unknown, method: string): string => {
|
|
1009
|
+
if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
|
|
1010
|
+
throw new TypeError(`[nifra/web] live.${method} takes keys in the ctx.live() charset`)
|
|
1011
|
+
}
|
|
1012
|
+
return key
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const clientEntry: { path?: string; loadPath?: string } = {}
|
|
1016
|
+
if (socketPath !== DEFAULT_SOCKET_PATH) clientEntry.path = socketPath
|
|
1017
|
+
if (loadPath !== DEFAULT_LOAD_PATH) clientEntry.loadPath = loadPath
|
|
1018
|
+
|
|
1019
|
+
return {
|
|
1020
|
+
clientEntry: Object.freeze(clientEntry),
|
|
1021
|
+
ready: authority.then(() => undefined),
|
|
1022
|
+
async publish(...keys) {
|
|
1023
|
+
for (const key of keys) checkKey(key, "publish")
|
|
1024
|
+
if (keys.length === 0) return
|
|
1025
|
+
await visible?.()
|
|
1026
|
+
await append({ type: "change", keys: [...new Set(keys)] })
|
|
1027
|
+
},
|
|
1028
|
+
async revoke(subject) {
|
|
1029
|
+
if (typeof subject !== "string")
|
|
1030
|
+
throw new TypeError("[nifra/web] live.revoke takes a subject")
|
|
1031
|
+
await append({ type: "revoke", sub: subject, key: null })
|
|
1032
|
+
},
|
|
1033
|
+
async revokeAll() {
|
|
1034
|
+
await append({ type: "revoke", sub: null, key: null })
|
|
1035
|
+
},
|
|
1036
|
+
async revokeKey(key, subject) {
|
|
1037
|
+
checkKey(key, "revokeKey")
|
|
1038
|
+
if (subject !== undefined && typeof subject !== "string") {
|
|
1039
|
+
throw new TypeError("[nifra/web] live.revokeKey takes an optional subject string")
|
|
1040
|
+
}
|
|
1041
|
+
await append({ type: "revoke", sub: subject ?? null, key })
|
|
1042
|
+
},
|
|
1043
|
+
status() {
|
|
1044
|
+
const hub = core?.hub
|
|
1045
|
+
return {
|
|
1046
|
+
ready: hubNamespace === undefined ? hub !== undefined : keyringReady,
|
|
1047
|
+
down: closed || downSince !== undefined || hub?.down === true,
|
|
1048
|
+
downSince: closed ? (downSince ?? closedAt) : downSince,
|
|
1049
|
+
sockets: hub?.size ?? 0,
|
|
1050
|
+
loads: inflight,
|
|
1051
|
+
events,
|
|
1052
|
+
notified: hub?.notified ?? 0,
|
|
1053
|
+
lagMs,
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
close() {
|
|
1057
|
+
closing ??= (async () => {
|
|
1058
|
+
closed = true
|
|
1059
|
+
closedAt = Date.now()
|
|
1060
|
+
subscription?.close()
|
|
1061
|
+
const c = core
|
|
1062
|
+
if (c !== undefined) {
|
|
1063
|
+
c.hub.down = true
|
|
1064
|
+
c.hub.closeAll(CLOSE_GOING_AWAY, "closed")
|
|
1065
|
+
}
|
|
1066
|
+
// A publish waiting to read its event back never will now.
|
|
1067
|
+
for (const waiter of waiters.splice(0)) waiter.resolve()
|
|
1068
|
+
})()
|
|
1069
|
+
return closing
|
|
1070
|
+
},
|
|
1071
|
+
[LIVE_ATTACH]: attach,
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const messageOf = (error: unknown): string | undefined =>
|
|
1076
|
+
error instanceof Error ? error.message : error === undefined ? undefined : String(error)
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* `app.publish`, holding the app weakly: a dev server builds a new app on every edit, and a discarded
|
|
1080
|
+
* one must not stay reachable, nor keep being published to, through the runtime. Built out here so
|
|
1081
|
+
* the closure's scope holds nothing else of the app.
|
|
1082
|
+
*/
|
|
1083
|
+
function weakPublisher(
|
|
1084
|
+
app: { publish(topic: string, frame: string): unknown },
|
|
1085
|
+
publishers: Set<(topic: string, frame: string) => void>,
|
|
1086
|
+
): (topic: string, frame: string) => void {
|
|
1087
|
+
const held = new WeakRef(app)
|
|
1088
|
+
const publisher = (topic: string, frame: string): void => {
|
|
1089
|
+
const attached = held.deref()
|
|
1090
|
+
if (attached === undefined) publishers.delete(publisher)
|
|
1091
|
+
else attached.publish(topic, frame)
|
|
1092
|
+
}
|
|
1093
|
+
return publisher
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function endpointPath(path: string, name: string): string {
|
|
1097
|
+
if (
|
|
1098
|
+
typeof path !== "string" ||
|
|
1099
|
+
!path.startsWith("/") ||
|
|
1100
|
+
path.startsWith("//") ||
|
|
1101
|
+
/[?#\s]/.test(path)
|
|
1102
|
+
) {
|
|
1103
|
+
throw new TypeError(`[nifra/web] live: \`${name}\` must be an absolute path with no query`)
|
|
1104
|
+
}
|
|
1105
|
+
return path
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const noSecret = (secret: LiveOptions["secret"]): boolean =>
|
|
1109
|
+
secret === undefined || (Array.isArray(secret) && secret.length === 0) || secret === ""
|
|
1110
|
+
|
|
1111
|
+
function secretsOf(secret: LiveOptions["secret"]): readonly unknown[] {
|
|
1112
|
+
if (secret !== undefined && !noSecret(secret))
|
|
1113
|
+
return typeof secret === "string" ? [secret] : secret
|
|
1114
|
+
if (isProduction()) {
|
|
1115
|
+
throw new TypeError(
|
|
1116
|
+
"[nifra/web] live: `secret` is required in production. Generate one with: openssl rand -base64 32",
|
|
1117
|
+
)
|
|
1118
|
+
}
|
|
1119
|
+
return [toBase64Url(crypto.getRandomValues(new Uint8Array(32)))]
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* An environment this cannot read counts as production, so a secret is required: Deno throws on
|
|
1124
|
+
* `process.env` without `--allow-env`, and a runtime with no `process` says nothing either way.
|
|
1125
|
+
*/
|
|
1126
|
+
function isProduction(): boolean {
|
|
1127
|
+
try {
|
|
1128
|
+
return typeof process === "undefined" || process.env?.NODE_ENV === "production"
|
|
1129
|
+
} catch {
|
|
1130
|
+
return true
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const mediaType = (value: string): string => {
|
|
1135
|
+
const end = value.indexOf(";")
|
|
1136
|
+
return (end === -1 ? value : value.slice(0, end)).trim().toLowerCase()
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function pathnameOf(url: string): string {
|
|
1140
|
+
const start = url.indexOf("/", url.indexOf("//") + 2)
|
|
1141
|
+
if (start === -1) return "/"
|
|
1142
|
+
const query = url.indexOf("?", start)
|
|
1143
|
+
return query === -1 ? url.slice(start) : url.slice(start, query)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function refusal(status: number, error: string): Response {
|
|
1147
|
+
return Response.json({ error }, { status, headers: { "cache-control": NO_STORE } })
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function busy(): Response {
|
|
1151
|
+
return Response.json(
|
|
1152
|
+
{ error: "live_load_busy" },
|
|
1153
|
+
{
|
|
1154
|
+
status: 503,
|
|
1155
|
+
headers: {
|
|
1156
|
+
"cache-control": NO_STORE,
|
|
1157
|
+
"retry-after": String(1 + Math.floor(Math.random() * 3)),
|
|
1158
|
+
},
|
|
1159
|
+
},
|
|
1160
|
+
)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** The hole a `B:<hole>:<routeId>` owner of route `routeId` names. */
|
|
1164
|
+
function holeOf(owner: string, routeId: string): string | undefined {
|
|
1165
|
+
const suffix = `:${routeId}`
|
|
1166
|
+
if (!owner.startsWith("B:") || !owner.endsWith(suffix)) return undefined
|
|
1167
|
+
const hole = owner.slice(2, owner.length - suffix.length)
|
|
1168
|
+
return hole === "" || hole.includes(":") || hole.includes(",") ? undefined : hole
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function parseBody(text: string): LoadBody | undefined {
|
|
1172
|
+
let value: unknown
|
|
1173
|
+
try {
|
|
1174
|
+
value = JSON.parse(text)
|
|
1175
|
+
} catch {
|
|
1176
|
+
return undefined
|
|
1177
|
+
}
|
|
1178
|
+
if (!isRecord(value)) return undefined
|
|
1179
|
+
const { path, sid, frame, mode, retain = [], grants = [], fill = [] } = value
|
|
1180
|
+
if (typeof path !== "string" || typeof sid !== "string" || !SID_PATTERN.test(sid))
|
|
1181
|
+
return undefined
|
|
1182
|
+
// Only the page frame is served; boundary frames arrive with the client's frame support.
|
|
1183
|
+
if (frame !== PAGE_FRAME || (mode !== "load" && mode !== "renew")) return undefined
|
|
1184
|
+
if (!isStringList(retain, MAX_RETAIN, MAX_OWNER_CHARS)) return undefined
|
|
1185
|
+
if (!isStringList(grants, MAX_GRANTS, MAX_GRANT_CHARS)) return undefined
|
|
1186
|
+
if (!isStringList(fill, MAX_HOSTS, MAX_HOST_CHARS) || !fill.every((name) => HOST.test(name)))
|
|
1187
|
+
return undefined
|
|
1188
|
+
return { path, sid, frame, mode, retain, grants, fill }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
1192
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
1193
|
+
|
|
1194
|
+
const isStringList = (value: unknown, max: number, chars: number): value is readonly string[] =>
|
|
1195
|
+
Array.isArray(value) &&
|
|
1196
|
+
value.length <= max &&
|
|
1197
|
+
value.every((item) => typeof item === "string" && item.length <= chars)
|
|
1198
|
+
|
|
1199
|
+
/** `path` when it is already the canonical same-origin `pathname + search`; anything else is refused. */
|
|
1200
|
+
function canonicalPath(path: string, base: string): string | undefined {
|
|
1201
|
+
if (path.length > MAX_PATH_CHARS || !path.startsWith("/") || path.startsWith("//"))
|
|
1202
|
+
return undefined
|
|
1203
|
+
// An absolute path against a request URL always parses: the parser normalises, it never throws.
|
|
1204
|
+
const url = new URL(path, base)
|
|
1205
|
+
const canonical = url.pathname + url.search
|
|
1206
|
+
return url.origin === new URL(base).origin && canonical === path ? canonical : undefined
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The headers the router's own data request for the target would carry: the POST's, without the
|
|
1211
|
+
* hop-by-hop, body and `origin` headers a same-origin GET does not send, and without any `x-nifra-*`
|
|
1212
|
+
* the client sent. `sec-fetch-*` pass through: a browser's fetch sends the same values for both.
|
|
1213
|
+
*/
|
|
1214
|
+
function internalHeaders(post: Headers): Headers {
|
|
1215
|
+
const headers = new Headers(post)
|
|
1216
|
+
const nominated = headers.get("connection")
|
|
1217
|
+
if (nominated !== null) {
|
|
1218
|
+
for (const name of nominated.split(",")) {
|
|
1219
|
+
const trimmed = name.trim()
|
|
1220
|
+
if (TOKEN.test(trimmed)) headers.delete(trimmed)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
for (const name of HOP_BY_HOP) headers.delete(name)
|
|
1224
|
+
for (const name of BODY_HEADERS) headers.delete(name)
|
|
1225
|
+
headers.delete("origin")
|
|
1226
|
+
const own: string[] = []
|
|
1227
|
+
headers.forEach((_value, name) => {
|
|
1228
|
+
if (name.startsWith("x-nifra-")) own.push(name)
|
|
1229
|
+
})
|
|
1230
|
+
for (const name of own) headers.delete(name)
|
|
1231
|
+
headers.set(DATA_HEADER, "1")
|
|
1232
|
+
return headers
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function releasing(body: ReadableStream<Uint8Array>, done: () => void): ReadableStream<Uint8Array> {
|
|
1236
|
+
const reader = body.getReader()
|
|
1237
|
+
return new ReadableStream<Uint8Array>({
|
|
1238
|
+
async pull(controller) {
|
|
1239
|
+
// Released on the last chunk and on a read that throws: only an enqueue keeps the slot.
|
|
1240
|
+
let finished = true
|
|
1241
|
+
try {
|
|
1242
|
+
const chunk = await reader.read()
|
|
1243
|
+
if (chunk.done) controller.close()
|
|
1244
|
+
else {
|
|
1245
|
+
finished = false
|
|
1246
|
+
controller.enqueue(chunk.value)
|
|
1247
|
+
}
|
|
1248
|
+
} finally {
|
|
1249
|
+
if (finished) done()
|
|
1250
|
+
}
|
|
1251
|
+
},
|
|
1252
|
+
cancel(reason) {
|
|
1253
|
+
done()
|
|
1254
|
+
return reader.cancel(reason)
|
|
1255
|
+
},
|
|
1256
|
+
})
|
|
1257
|
+
}
|