@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
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live refresh in the browser. The client entry of an app with `live` builds its router with this
|
|
3
|
+
* module's `fetchData`, so every route load is a live load that returns a grant beside its data, and
|
|
4
|
+
* attaches the router once it is mounted. One WebSocket per tab watches the grants of what is on
|
|
5
|
+
* screen - the page, and the target an intercept boundary holds - and a change to a key either read
|
|
6
|
+
* reloads it, keeping every owner whose keys did not change.
|
|
7
|
+
*
|
|
8
|
+
* A frame whose load returned no grant, and every frame while the socket is down, is polled instead.
|
|
9
|
+
*
|
|
10
|
+
* Query hooks that name live keys refetch on the same changes: each load carries a salted digest of
|
|
11
|
+
* every key it grants, and the client matches the keys the page's queries name against them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BoundaryState, BoundaryStates } from "./boundary.ts"
|
|
15
|
+
import {
|
|
16
|
+
advanceLiveTick,
|
|
17
|
+
clearQueryClients,
|
|
18
|
+
type LiveQuery,
|
|
19
|
+
liveQueries,
|
|
20
|
+
registeredAt,
|
|
21
|
+
watchLiveQueries,
|
|
22
|
+
} from "./internal/live-queries.ts"
|
|
23
|
+
import { queryDigester } from "./internal/query-digest.ts"
|
|
24
|
+
import { appUrl } from "./internal/route-path.ts"
|
|
25
|
+
import {
|
|
26
|
+
type ClientRouter,
|
|
27
|
+
DATA_HEADER,
|
|
28
|
+
type FetchRouteData,
|
|
29
|
+
HOLE_FILL_HEADER,
|
|
30
|
+
isRouteDataEnvelope,
|
|
31
|
+
NAV_FROM_HEADER,
|
|
32
|
+
RETAIN_HEADER,
|
|
33
|
+
type RouteDataEnvelope,
|
|
34
|
+
routeDataOf,
|
|
35
|
+
} from "./router.ts"
|
|
36
|
+
|
|
37
|
+
export interface LiveClientOptions {
|
|
38
|
+
/** The live socket, as `live({ path })` serves it. Default `/_nifra/live`. */
|
|
39
|
+
readonly path?: string
|
|
40
|
+
/** The live load endpoint, as `live({ loadPath })` serves it. Default `/_nifra/live/load`. */
|
|
41
|
+
readonly loadPath?: string
|
|
42
|
+
/**
|
|
43
|
+
* Sends every live load. Give one that adds an `Authorization` header when the app authenticates
|
|
44
|
+
* that way; cookies are sent by default.
|
|
45
|
+
*/
|
|
46
|
+
readonly fetch?: (input: string, init: RequestInit) => Promise<Response>
|
|
47
|
+
/** Route id to its layout ids, outermost first. The generated client entry passes its own table. */
|
|
48
|
+
readonly layoutIdsOf?: Readonly<Record<string, readonly string[]>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LiveClient {
|
|
52
|
+
/** Pass as `createClientRouter({ fetchData })`: every route load goes through the live load endpoint. */
|
|
53
|
+
readonly fetchData: FetchRouteData
|
|
54
|
+
/** Keep what `router` shows fresh. Returns a function that stops and closes the socket. */
|
|
55
|
+
attach(router: ClientRouter): () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const HEARTBEAT = "p"
|
|
59
|
+
const HEARTBEAT_MS = 30_000
|
|
60
|
+
const RENEW_BEFORE_MS = 60_000
|
|
61
|
+
const RENEW_JITTER_MS = 30_000
|
|
62
|
+
const POLL_MS = 30_000
|
|
63
|
+
const MAX_POLL_MS = 300_000
|
|
64
|
+
const FOCUS_RELOAD_MS = 5_000
|
|
65
|
+
const RETRY_MS = 1_000
|
|
66
|
+
const MAX_RECONNECT_MS = 30_000
|
|
67
|
+
/** Failed connects in a row before frames poll instead of waiting for the socket. */
|
|
68
|
+
const CONNECTS_BEFORE_POLLING = 3
|
|
69
|
+
const MAX_SPREAD_MS = 5_000
|
|
70
|
+
const PAGE = "page"
|
|
71
|
+
const SID = /^[0-9a-f]{32}$/
|
|
72
|
+
/** The socket id of a seed: the grant the server rendered the page with, bound to no socket yet. */
|
|
73
|
+
const SEED = ""
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a live load handed the router, carried in its state beside the data it loaded. A load whose
|
|
77
|
+
* holes were still loading gets its grant after them: until then `trailing` is set and the fields the
|
|
78
|
+
* envelope fills are empty.
|
|
79
|
+
*/
|
|
80
|
+
interface Loaded {
|
|
81
|
+
readonly routeId: string
|
|
82
|
+
/** The socket id the grant is bound to. */
|
|
83
|
+
readonly sid: string
|
|
84
|
+
grant: string | undefined
|
|
85
|
+
/** Wire tag to the owners in this load that watch its key. */
|
|
86
|
+
tags: ReadonlyMap<string, readonly string[]>
|
|
87
|
+
/** Client clock, in ms. */
|
|
88
|
+
expiresAt: number
|
|
89
|
+
ttlMs: number
|
|
90
|
+
/** The live tick its response arrived at. */
|
|
91
|
+
readonly tick: number
|
|
92
|
+
query: QueryKeys | undefined
|
|
93
|
+
trailing: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a load tells query hooks about the keys it grants. */
|
|
97
|
+
interface QueryKeys {
|
|
98
|
+
/** Absent when the app has `queryKeys` off. */
|
|
99
|
+
readonly salt: string | undefined
|
|
100
|
+
/** Digest of a granted key under `salt` to its wire tag. */
|
|
101
|
+
readonly tags: ReadonlyMap<string, string>
|
|
102
|
+
readonly dev: boolean
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A live load the client itself asked the router for, read back by `fetchData` in the same call. */
|
|
106
|
+
interface Intent {
|
|
107
|
+
readonly path: string
|
|
108
|
+
readonly mode: "load" | "renew"
|
|
109
|
+
readonly held: Loaded | undefined
|
|
110
|
+
readonly retain: readonly string[]
|
|
111
|
+
/** What is on screen now: the copies a retained owner keeps. */
|
|
112
|
+
readonly data: unknown
|
|
113
|
+
readonly layoutData: readonly unknown[] | undefined
|
|
114
|
+
readonly boundaries: BoundaryStates | undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A load on screen: the page, or the target an intercept boundary holds. */
|
|
118
|
+
interface Frame {
|
|
119
|
+
/** `"page"`, or the boundary's name. */
|
|
120
|
+
readonly slot: string
|
|
121
|
+
path: string
|
|
122
|
+
readonly routeId: string
|
|
123
|
+
readonly loaded: Loaded | undefined
|
|
124
|
+
readonly shownAt: number
|
|
125
|
+
/** Owners known to have changed since their data loaded. */
|
|
126
|
+
dirty: Set<string>
|
|
127
|
+
/** Every owner reloads: there is no grant to keep any of them by. */
|
|
128
|
+
whole: boolean
|
|
129
|
+
/** The frame holds a seed and a socket is open: one load keeping every owner trades it for a grant. */
|
|
130
|
+
exchange: boolean
|
|
131
|
+
/** The watch generation that installed this frame's grant, once `ready` confirmed it. */
|
|
132
|
+
installed: boolean
|
|
133
|
+
/** Set while a watch carrying this frame's grant waits for its `ready`. */
|
|
134
|
+
pending: number | undefined
|
|
135
|
+
loading: boolean
|
|
136
|
+
failures: number
|
|
137
|
+
retryAt: number
|
|
138
|
+
renewAt: number
|
|
139
|
+
pollAt: number
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function liveClient(options: LiveClientOptions = {}): LiveClient {
|
|
143
|
+
const socketPath = options.path ?? "/_nifra/live"
|
|
144
|
+
const loadPath = options.loadPath ?? "/_nifra/live/load"
|
|
145
|
+
const send = options.fetch ?? ((input: string, init: RequestInit) => fetch(input, init))
|
|
146
|
+
const layoutIdsOf = options.layoutIdsOf ?? {}
|
|
147
|
+
// Loads made with no socket are bound to an id no socket has, so their grants are never watched.
|
|
148
|
+
const localSid = randomSid()
|
|
149
|
+
let router: ClientRouter | undefined
|
|
150
|
+
let sid: string | undefined
|
|
151
|
+
// The last load's viewer tags: one per secret the server lists.
|
|
152
|
+
let subjects: readonly string[] | undefined
|
|
153
|
+
let switched = false
|
|
154
|
+
let intent: Intent | undefined
|
|
155
|
+
// The handles this client put in router state: anything else there is not a live load.
|
|
156
|
+
const issued = new WeakMap<object, Loaded>()
|
|
157
|
+
const loadedOf = (value: unknown): Loaded | undefined =>
|
|
158
|
+
typeof value === "object" && value !== null ? issued.get(value) : undefined
|
|
159
|
+
let frames: Frame[] = []
|
|
160
|
+
let stopped = false
|
|
161
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
162
|
+
// A frame with no usable grant gets one load for one under each socket; after that it polls.
|
|
163
|
+
const tried = new Set<string>()
|
|
164
|
+
const pollFailures = new Map<string, number>()
|
|
165
|
+
let ws: WebSocket | undefined
|
|
166
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
167
|
+
let reconnect: ReturnType<typeof setTimeout> | undefined
|
|
168
|
+
let connectFailures = 0
|
|
169
|
+
// The spread the last `welcome` asked for: the reloads a new socket id needs go out over it.
|
|
170
|
+
let welcomeSpread = 0
|
|
171
|
+
let gen = 0
|
|
172
|
+
let sent:
|
|
173
|
+
| { readonly gen: number; readonly frames: readonly Frame[]; readonly grants: string }
|
|
174
|
+
| undefined
|
|
175
|
+
// Set by an account switch: the next socket's `welcome` reloads everything under its id.
|
|
176
|
+
let clearOnWelcome = false
|
|
177
|
+
// Query hooks: wire tag to the queries whose keys it carries, from the last match.
|
|
178
|
+
let matched = new Map<string, Set<LiveQuery>>()
|
|
179
|
+
// Tag to the live tick of the load from which an installed watch has held it without a break; a
|
|
180
|
+
// query that asked for its data before that tick may have missed a change.
|
|
181
|
+
const heldSince = new Map<string, number>()
|
|
182
|
+
let matching = 0
|
|
183
|
+
let matchScheduled = false
|
|
184
|
+
let matchedLoads: readonly (Loaded | undefined)[] = []
|
|
185
|
+
let changedWhileMatching: Set<string> | undefined
|
|
186
|
+
// Salt to its digester and the digests it made: a salt lives as long as its load is on screen.
|
|
187
|
+
const digesters = new Map<
|
|
188
|
+
string,
|
|
189
|
+
{
|
|
190
|
+
readonly sign: Promise<(key: string) => Promise<string>>
|
|
191
|
+
readonly made: Map<string, Promise<string>>
|
|
192
|
+
}
|
|
193
|
+
>()
|
|
194
|
+
const warned = new Set<string>()
|
|
195
|
+
let unwatchQueries: (() => void) | undefined
|
|
196
|
+
|
|
197
|
+
/** The owners a load of `routeId` may keep: its layouts, its page and the holes `shown` holds. */
|
|
198
|
+
const ownersOf = (routeId: string, shown: BoundaryStates | undefined): string[] => {
|
|
199
|
+
const owners = [...(layoutIdsOf[routeId] ?? []).map((id) => `L:${id}`), `P:${routeId}`]
|
|
200
|
+
for (const state of Object.values(shown ?? {}))
|
|
201
|
+
if (state.mode === "dynamic" || state.mode === "shared")
|
|
202
|
+
owners.push(`B:${state.name}:${routeId}`)
|
|
203
|
+
return owners
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A load's envelope written into its handle; a new viewer clears what the last one cached. */
|
|
207
|
+
const settleLoaded = (loaded: Loaded, live: ServerEnvelope | undefined): void => {
|
|
208
|
+
loaded.trailing = false
|
|
209
|
+
if (live === undefined) return
|
|
210
|
+
const last = subjects
|
|
211
|
+
if (last !== undefined && !live.subjects.some((tag) => last.includes(tag))) {
|
|
212
|
+
switched = true
|
|
213
|
+
// Nothing a query loaded for the last viewer may show to this one.
|
|
214
|
+
clearQueryClients()
|
|
215
|
+
}
|
|
216
|
+
subjects = live.subjects
|
|
217
|
+
loaded.grant = live.grant
|
|
218
|
+
loaded.tags = new Map(Object.entries(live.tags ?? {}))
|
|
219
|
+
loaded.expiresAt =
|
|
220
|
+
live.ttl === undefined ? Number.POSITIVE_INFINITY : Date.now() + live.ttl * 1000
|
|
221
|
+
loaded.ttlMs = live.ttl === undefined ? Number.POSITIVE_INFINITY : live.ttl * 1000
|
|
222
|
+
loaded.query = live.query
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A plain data GET: the router's own transport, for a navigation the live endpoint turned away. */
|
|
226
|
+
const plainLoad: FetchRouteData = async (path, _match, signal, navigation, _purpose, hosts) => {
|
|
227
|
+
const headers = new Headers({ [DATA_HEADER]: "1" })
|
|
228
|
+
if (hosts !== undefined && hosts.length > 0) headers.set(HOLE_FILL_HEADER, hosts.join(","))
|
|
229
|
+
if (navigation !== undefined && navigation.retain.length > 0) {
|
|
230
|
+
headers.set(RETAIN_HEADER, navigation.retain.join(","))
|
|
231
|
+
headers.set(NAV_FROM_HEADER, navigation.from)
|
|
232
|
+
}
|
|
233
|
+
return routeDataOf(await send(appUrl(path), { headers, signal: signal ?? null }), path, signal)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const fetchData: FetchRouteData = async (path, match, signal, navigation, purpose, hosts) => {
|
|
237
|
+
// Read before anything awaits: the client's own reload calls the router, which calls here at once.
|
|
238
|
+
const own = intent?.path === path ? intent : undefined
|
|
239
|
+
if (own !== undefined) intent = undefined
|
|
240
|
+
const bound = sid ?? localSid
|
|
241
|
+
let held = own?.held
|
|
242
|
+
const retain: string[] = own === undefined ? [] : [...own.retain]
|
|
243
|
+
if (own === undefined && navigation !== undefined && navigation.retain.length > 0) {
|
|
244
|
+
// A navigation offering its layouts presents the page it leaves: the server keeps those a
|
|
245
|
+
// router navigation would, and none whose keys changed.
|
|
246
|
+
const page = frames.find((frame) => frame.slot === PAGE)
|
|
247
|
+
held = page?.loaded
|
|
248
|
+
const from = layoutIdsOf[router?.snapshot().routeId ?? ""] ?? []
|
|
249
|
+
for (const index of navigation.retain) {
|
|
250
|
+
const id = from[index]
|
|
251
|
+
if (id !== undefined && page?.dirty.has(`L:${id}`) === false) retain.push(`L:${id}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const grant =
|
|
255
|
+
held?.grant !== undefined && (held.sid === bound || held.sid === SEED)
|
|
256
|
+
? held.grant
|
|
257
|
+
: undefined
|
|
258
|
+
const res = await send(appUrl(loadPath), {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "content-type": "application/json" },
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
path,
|
|
263
|
+
sid: bound,
|
|
264
|
+
frame: PAGE,
|
|
265
|
+
mode: own?.mode ?? "load",
|
|
266
|
+
retain: grant === undefined ? [] : retain,
|
|
267
|
+
grants: grant === undefined ? [] : [grant],
|
|
268
|
+
fill: hosts !== undefined && hosts.length > 0 ? hosts : undefined,
|
|
269
|
+
}),
|
|
270
|
+
signal: signal ?? null,
|
|
271
|
+
})
|
|
272
|
+
if (res.status === 503) {
|
|
273
|
+
await res.body?.cancel()
|
|
274
|
+
// Admission bounds reloads; a navigation the user asked for is not held behind it.
|
|
275
|
+
if (own === undefined) return plainLoad(path, match, signal, navigation, purpose, hosts)
|
|
276
|
+
throw new Error(`[nifra/web] live load busy: ${path}`)
|
|
277
|
+
}
|
|
278
|
+
const payload = await routeDataOf(res, path, signal)
|
|
279
|
+
if (!isRouteDataEnvelope(payload)) return payload
|
|
280
|
+
const kept = own === undefined || grant === undefined ? payload : keptBy(payload, own)
|
|
281
|
+
const loaded: Loaded = {
|
|
282
|
+
routeId: match.routeId,
|
|
283
|
+
sid: bound,
|
|
284
|
+
grant: undefined,
|
|
285
|
+
tags: new Map(),
|
|
286
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
287
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
288
|
+
tick: advanceLiveTick(),
|
|
289
|
+
query: undefined,
|
|
290
|
+
trailing: false,
|
|
291
|
+
}
|
|
292
|
+
const trailing = deferredOf(payload.live)
|
|
293
|
+
if (trailing !== undefined) {
|
|
294
|
+
// The grant follows the holes: the frame waits for it, then watches or polls as any other.
|
|
295
|
+
loaded.trailing = true
|
|
296
|
+
issued.set(loaded, loaded)
|
|
297
|
+
trailing.then(
|
|
298
|
+
(value) => granted(loaded, envelopeOf(value)),
|
|
299
|
+
() => granted(loaded, undefined),
|
|
300
|
+
)
|
|
301
|
+
return { ...kept, live: loaded }
|
|
302
|
+
}
|
|
303
|
+
const live = envelopeOf(payload.live)
|
|
304
|
+
if (live === undefined) return { ...kept, live: undefined }
|
|
305
|
+
settleLoaded(loaded, live)
|
|
306
|
+
issued.set(loaded, loaded)
|
|
307
|
+
// The page loader did not run: show the copy already on screen.
|
|
308
|
+
if (own !== undefined && grant !== undefined && live.page === "retained")
|
|
309
|
+
return { ...kept, data: own.data, live: loaded }
|
|
310
|
+
return { ...kept, live: loaded }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** What the server kept of what was offered, and is still current: the copies already on screen. */
|
|
314
|
+
const keptBy = (payload: RouteDataEnvelope, own: Intent): RouteDataEnvelope => {
|
|
315
|
+
const retained = new Set(payload.retained ?? [])
|
|
316
|
+
const shown = own.layoutData
|
|
317
|
+
let boundaries = payload.boundaries
|
|
318
|
+
for (const [name, state] of Object.entries(payload.boundaries ?? {})) {
|
|
319
|
+
const held = own.boundaries?.[name]
|
|
320
|
+
if (state.kept !== true || held === undefined) continue
|
|
321
|
+
boundaries = { ...boundaries, [name]: held satisfies BoundaryState }
|
|
322
|
+
}
|
|
323
|
+
const layoutData = payload.layoutData?.map((value, index) =>
|
|
324
|
+
retained.has(index) && shown !== undefined && index < shown.length ? shown[index] : value,
|
|
325
|
+
)
|
|
326
|
+
return {
|
|
327
|
+
...payload,
|
|
328
|
+
...(layoutData === undefined ? {} : { layoutData }),
|
|
329
|
+
...(boundaries === undefined ? {} : { boundaries }),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The server-rendered page's seed, in the router's initial state: held as the page's load, it is
|
|
335
|
+
* traded for a socket's grant once one opens. Anything else there is ignored.
|
|
336
|
+
*/
|
|
337
|
+
const adoptSeed = (state: { readonly routeId: string; readonly live?: unknown }): void => {
|
|
338
|
+
const value = state.live
|
|
339
|
+
if (typeof value !== "object" || value === null || issued.has(value)) return
|
|
340
|
+
const live = envelopeOf(value)
|
|
341
|
+
if (live?.grant === undefined) return
|
|
342
|
+
const loaded: Loaded = {
|
|
343
|
+
routeId: state.routeId,
|
|
344
|
+
sid: SEED,
|
|
345
|
+
grant: undefined,
|
|
346
|
+
tags: new Map(),
|
|
347
|
+
expiresAt: Number.POSITIVE_INFINITY,
|
|
348
|
+
ttlMs: Number.POSITIVE_INFINITY,
|
|
349
|
+
tick: advanceLiveTick(),
|
|
350
|
+
query: undefined,
|
|
351
|
+
trailing: false,
|
|
352
|
+
}
|
|
353
|
+
settleLoaded(loaded, live)
|
|
354
|
+
issued.set(value, loaded)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A trailing grant arrived, or will not: the frame holding `loaded` watches or polls from now. */
|
|
358
|
+
const granted = (loaded: Loaded, live: ServerEnvelope | undefined): void => {
|
|
359
|
+
settleLoaded(loaded, live)
|
|
360
|
+
for (const frame of frames) if (frame.loaded === loaded) frame.renewAt = renewAtOf(loaded)
|
|
361
|
+
reconcile()
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- On screen -----------------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
const watchable = (frame: Frame): boolean =>
|
|
367
|
+
frame.loaded?.grant !== undefined && frame.loaded.sid === sid
|
|
368
|
+
/** A socket serves a frame holding a tag, or one still to get a grant; a page with neither needs none. */
|
|
369
|
+
const wantsSocket = (): boolean =>
|
|
370
|
+
frames.length === 0 ||
|
|
371
|
+
frames.some((frame) => frame.loaded?.grant === undefined || frame.loaded.tags.size > 0)
|
|
372
|
+
const hidden = (): boolean => globalThis.document?.visibilityState === "hidden"
|
|
373
|
+
const polling = (): boolean => sid === undefined && connectFailures >= CONNECTS_BEFORE_POLLING
|
|
374
|
+
|
|
375
|
+
const frameFor = (
|
|
376
|
+
slot: string,
|
|
377
|
+
path: string,
|
|
378
|
+
routeId: string,
|
|
379
|
+
loaded: Loaded | undefined,
|
|
380
|
+
now: number,
|
|
381
|
+
): Frame => {
|
|
382
|
+
const same = frames.find(
|
|
383
|
+
(frame) =>
|
|
384
|
+
frame.slot === slot &&
|
|
385
|
+
frame.loaded === loaded &&
|
|
386
|
+
(loaded !== undefined || frame.path === path),
|
|
387
|
+
)
|
|
388
|
+
if (same !== undefined) {
|
|
389
|
+
// A client-only search change keeps the load and moves its URL.
|
|
390
|
+
same.path = path
|
|
391
|
+
return same
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
slot,
|
|
395
|
+
path,
|
|
396
|
+
routeId: loaded?.routeId ?? routeId,
|
|
397
|
+
loaded,
|
|
398
|
+
shownAt: now,
|
|
399
|
+
dirty: new Set(),
|
|
400
|
+
whole: false,
|
|
401
|
+
exchange: false,
|
|
402
|
+
installed: false,
|
|
403
|
+
pending: undefined,
|
|
404
|
+
loading: false,
|
|
405
|
+
failures: 0,
|
|
406
|
+
retryAt: 0,
|
|
407
|
+
renewAt: loaded === undefined ? Number.POSITIVE_INFINITY : renewAtOf(loaded),
|
|
408
|
+
pollAt: now + jittered(POLL_MS * 2 ** Math.min(pollFailures.get(path) ?? 0, 4), MAX_POLL_MS),
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const reconcile = (): void => {
|
|
413
|
+
const current = router
|
|
414
|
+
if (stopped || current === undefined) return
|
|
415
|
+
const state = current.snapshot()
|
|
416
|
+
const now = Date.now()
|
|
417
|
+
const next = [
|
|
418
|
+
frameFor(
|
|
419
|
+
PAGE,
|
|
420
|
+
state.intercepted?.pagePath ?? state.path,
|
|
421
|
+
state.routeId,
|
|
422
|
+
loadedOf(state.live),
|
|
423
|
+
now,
|
|
424
|
+
),
|
|
425
|
+
]
|
|
426
|
+
const intercepted = state.intercepted
|
|
427
|
+
const held = intercepted === undefined ? undefined : state.boundaries?.[intercepted.boundary]
|
|
428
|
+
if (intercepted !== undefined && held?.status === "ready") {
|
|
429
|
+
next.push(
|
|
430
|
+
frameFor(
|
|
431
|
+
intercepted.boundary,
|
|
432
|
+
state.path,
|
|
433
|
+
current.match(state.path)?.routeId ?? "",
|
|
434
|
+
loadedOf(held.live),
|
|
435
|
+
now,
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
frames = next
|
|
440
|
+
// Nothing to watch let the last socket go; a frame that now holds a tag opens the next.
|
|
441
|
+
if (ws === undefined && reconnect === undefined && wantsSocket()) connect()
|
|
442
|
+
for (const frame of frames) {
|
|
443
|
+
// No grant to watch by yet: load one once the socket can take it. A trailing one is on its way.
|
|
444
|
+
if (
|
|
445
|
+
!watchable(frame) &&
|
|
446
|
+
frame.loaded?.trailing !== true &&
|
|
447
|
+
sid !== undefined &&
|
|
448
|
+
!tried.has(`${sid}\n${frame.path}`)
|
|
449
|
+
) {
|
|
450
|
+
tried.add(`${sid}\n${frame.path}`)
|
|
451
|
+
// A seed needs no loader to run: the server keeps every owner and binds a grant to this socket.
|
|
452
|
+
if (frame.loaded?.sid === SEED) frame.exchange = true
|
|
453
|
+
else frame.whole = true
|
|
454
|
+
// A new socket is a new chance: no backoff carries over, only the spread it was given.
|
|
455
|
+
frame.failures = 0
|
|
456
|
+
frame.retryAt = welcomeSpread > 0 ? now + Math.random() * welcomeSpread : 0
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
welcomeSpread = 0
|
|
460
|
+
if (switched && !state.pending) {
|
|
461
|
+
// Another viewer: nothing kept, cached or watched for the last one survives.
|
|
462
|
+
switched = false
|
|
463
|
+
restart()
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
const loads = frames.map((frame) => frame.loaded)
|
|
467
|
+
if (loads.length !== matchedLoads.length || loads.some((l, i) => l !== matchedLoads[i])) {
|
|
468
|
+
matchedLoads = loads
|
|
469
|
+
scheduleMatch()
|
|
470
|
+
}
|
|
471
|
+
sendWatch()
|
|
472
|
+
run()
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A kept page shown again reloads itself, and that load brings the grant to watch it by.
|
|
476
|
+
const busy = (current: ClientRouter): boolean => {
|
|
477
|
+
const state = current.snapshot()
|
|
478
|
+
return state.pending || state.revalidating === true
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const run = (): void => {
|
|
482
|
+
const current = router
|
|
483
|
+
if (stopped || current === undefined || hidden() || busy(current)) return
|
|
484
|
+
const now = Date.now()
|
|
485
|
+
for (const frame of frames) {
|
|
486
|
+
if (frame.loading || wakeOf(frame) > now) continue
|
|
487
|
+
if (frame.whole || frame.exchange || frame.dirty.size > 0) reload(frame, "load")
|
|
488
|
+
else if (watchable(frame)) reload(frame, "renew")
|
|
489
|
+
else reload(frame, "load", true)
|
|
490
|
+
// One load at a time: the router runs it, and its next state runs this again.
|
|
491
|
+
if (busy(current)) break
|
|
492
|
+
}
|
|
493
|
+
arm()
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* When `run` loads this frame next; infinite when only an event can make it due. One rule for both
|
|
498
|
+
* `run` and `arm`: a deadline `run` would skip must never be armed, or the timer spins until it can.
|
|
499
|
+
*/
|
|
500
|
+
const wakeOf = (frame: Frame): number => {
|
|
501
|
+
let due: number
|
|
502
|
+
if (frame.whole || frame.exchange || frame.dirty.size > 0) due = 0
|
|
503
|
+
// Its grant trails the holes still loading; it arrives with or without one, and runs this again.
|
|
504
|
+
else if (frame.loaded?.trailing === true) due = Number.POSITIVE_INFINITY
|
|
505
|
+
// A renewal waits for the pending watch's `ready`, which runs this again.
|
|
506
|
+
else if (watchable(frame))
|
|
507
|
+
due = frame.pending === undefined ? frame.renewAt : Number.POSITIVE_INFINITY
|
|
508
|
+
else if (polling() || frame.loaded?.grant === undefined) due = frame.pollAt
|
|
509
|
+
else due = Number.POSITIVE_INFINITY
|
|
510
|
+
return Math.max(frame.retryAt, due)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Wake for the earliest frame due. */
|
|
514
|
+
const arm = (): void => {
|
|
515
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
516
|
+
timer = undefined
|
|
517
|
+
if (stopped) return
|
|
518
|
+
let at = Number.POSITIVE_INFINITY
|
|
519
|
+
for (const frame of frames) if (!frame.loading) at = Math.min(at, wakeOf(frame))
|
|
520
|
+
if (at === Number.POSITIVE_INFINITY) return
|
|
521
|
+
timer = setTimeout(run, Math.max(0, at - Date.now()))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** `polled`: no change notice can reach this frame, so its queries refetch with it. */
|
|
525
|
+
const reload = (frame: Frame, mode: "load" | "renew", polled = false): void => {
|
|
526
|
+
const current = router
|
|
527
|
+
if (current === undefined) return
|
|
528
|
+
const state = current.snapshot()
|
|
529
|
+
const page = frame.slot === PAGE
|
|
530
|
+
const whole = frame.whole
|
|
531
|
+
const exchange = frame.exchange
|
|
532
|
+
// A poll is for fresh data: no change notice reaches the frame, so no owner can be kept.
|
|
533
|
+
const held = whole || polled ? undefined : frame.loaded
|
|
534
|
+
intent = {
|
|
535
|
+
path: frame.path,
|
|
536
|
+
mode: held === undefined ? "load" : mode,
|
|
537
|
+
held,
|
|
538
|
+
retain:
|
|
539
|
+
held === undefined || mode === "renew"
|
|
540
|
+
? []
|
|
541
|
+
: ownersOf(frame.routeId, page ? state.boundaries : undefined).filter(
|
|
542
|
+
(owner) => !frame.dirty.has(owner),
|
|
543
|
+
),
|
|
544
|
+
data: page ? state.data : state.boundaries?.[frame.slot]?.data,
|
|
545
|
+
layoutData: page ? state.layoutData : undefined,
|
|
546
|
+
boundaries: page ? state.boundaries : undefined,
|
|
547
|
+
}
|
|
548
|
+
frame.loading = true
|
|
549
|
+
frame.whole = false
|
|
550
|
+
frame.exchange = false
|
|
551
|
+
let running: Promise<void>
|
|
552
|
+
try {
|
|
553
|
+
running = current.invalidate([frame.path])
|
|
554
|
+
} finally {
|
|
555
|
+
intent = undefined
|
|
556
|
+
}
|
|
557
|
+
const settle = (ok: boolean): void => {
|
|
558
|
+
frame.loading = false
|
|
559
|
+
// Applied, the load is a new frame; still on screen, nothing was applied.
|
|
560
|
+
if (ok && !frames.includes(frame)) {
|
|
561
|
+
pollFailures.delete(frame.path)
|
|
562
|
+
if (polled) for (const query of liveQueries()) query.refresh()
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
if (!ok && !watchable(frame))
|
|
566
|
+
pollFailures.set(frame.path, (pollFailures.get(frame.path) ?? 0) + 1)
|
|
567
|
+
// Still no grant to keep an owner by, or a seed it could not trade: the retry loads it whole.
|
|
568
|
+
if (whole || exchange) frame.whole = true
|
|
569
|
+
frame.failures++
|
|
570
|
+
// Backs off to five minutes, never further: a frame that gives up shows stale data for good.
|
|
571
|
+
// A change, a visibility change or a new socket tries again at once.
|
|
572
|
+
frame.retryAt =
|
|
573
|
+
Date.now() + jittered(RETRY_MS * 2 ** Math.min(frame.failures - 1, 9), MAX_POLL_MS)
|
|
574
|
+
if (!watchable(frame))
|
|
575
|
+
frame.pollAt =
|
|
576
|
+
Date.now() + jittered(POLL_MS * 2 ** Math.min(frame.failures, 4), MAX_POLL_MS)
|
|
577
|
+
run()
|
|
578
|
+
}
|
|
579
|
+
running.then(
|
|
580
|
+
() => settle(true),
|
|
581
|
+
() => settle(false),
|
|
582
|
+
)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- The socket ----------------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
const sendWatch = (): void => {
|
|
588
|
+
const socket = ws
|
|
589
|
+
if (socket === undefined || sid === undefined || socket.readyState !== 1) return
|
|
590
|
+
const watched = frames.filter(watchable)
|
|
591
|
+
const grants = watched.map((frame) => frame.loaded?.grant ?? "")
|
|
592
|
+
const key = grants.join("\n")
|
|
593
|
+
if (sent === undefined && grants.length === 0) return
|
|
594
|
+
if (sent?.grants === key) {
|
|
595
|
+
sent = { gen: sent.gen, frames: watched, grants: key }
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
gen++
|
|
599
|
+
sent = { gen, frames: watched, grants: key }
|
|
600
|
+
for (const frame of watched) if (!frame.installed) frame.pending = gen
|
|
601
|
+
socket.send(JSON.stringify({ t: "watch", gen, grants }))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const onReady = (value: Record<string, unknown>): void => {
|
|
605
|
+
if (sent === undefined || value.gen !== sent.gen || !Array.isArray(value.stale)) return
|
|
606
|
+
const stale: unknown[] = value.stale
|
|
607
|
+
sent.frames.forEach((frame, index) => {
|
|
608
|
+
const changed = stale[index]
|
|
609
|
+
frame.pending = undefined
|
|
610
|
+
frame.installed = true
|
|
611
|
+
// Every change since each owner's data loaded is in this list; nothing before `ready` counts.
|
|
612
|
+
frame.dirty = new Set(
|
|
613
|
+
Array.isArray(changed) ? changed.filter((owner) => typeof owner === "string") : [],
|
|
614
|
+
)
|
|
615
|
+
// A query may have read before one of those changes: refetch every query on a tag they own.
|
|
616
|
+
for (const [tag, owners] of frame.loaded?.tags ?? []) {
|
|
617
|
+
if (!owners.some((owner) => frame.dirty.has(owner))) continue
|
|
618
|
+
changedWhileMatching?.add(tag)
|
|
619
|
+
refreshTag(tag)
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
const held = new Set<string>()
|
|
623
|
+
for (const frame of frames) {
|
|
624
|
+
if (!frame.installed || frame.loaded === undefined) continue
|
|
625
|
+
for (const tag of frame.loaded.tags.keys()) {
|
|
626
|
+
held.add(tag)
|
|
627
|
+
if (!heldSince.has(tag)) heldSince.set(tag, frame.loaded.tick)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
for (const tag of heldSince.keys()) if (!held.has(tag)) heldSince.delete(tag)
|
|
631
|
+
cover()
|
|
632
|
+
run()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const onChanged = (value: Record<string, unknown>): void => {
|
|
636
|
+
const { tag, spread } = value
|
|
637
|
+
if (typeof tag !== "string") return
|
|
638
|
+
const delay =
|
|
639
|
+
typeof spread === "number" && spread > 0 ? Math.random() * Math.min(spread, MAX_SPREAD_MS) : 0
|
|
640
|
+
for (const frame of frames) {
|
|
641
|
+
const owners = frame.loaded?.tags.get(tag)
|
|
642
|
+
// A tag this frame does not hold is another load's; a pending frame's `ready` settles it.
|
|
643
|
+
if (owners === undefined || frame.pending !== undefined || !watchable(frame)) continue
|
|
644
|
+
const mark = (): void => {
|
|
645
|
+
if (!frames.includes(frame) || frame.pending !== undefined) return
|
|
646
|
+
for (const owner of owners) frame.dirty.add(owner)
|
|
647
|
+
frame.failures = 0
|
|
648
|
+
frame.retryAt = 0
|
|
649
|
+
run()
|
|
650
|
+
}
|
|
651
|
+
if (delay === 0) mark()
|
|
652
|
+
else setTimeout(mark, delay)
|
|
653
|
+
}
|
|
654
|
+
changedWhileMatching?.add(tag)
|
|
655
|
+
if (!matched.has(tag)) return
|
|
656
|
+
if (delay === 0) refreshTag(tag)
|
|
657
|
+
else setTimeout(() => refreshTag(tag), delay)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// --- Query hooks ---------------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
const refreshTag = (tag: string): void => {
|
|
663
|
+
for (const query of matched.get(tag) ?? []) query.refresh()
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Refetch each query that asked for its data before its tag's watch, or its own registration, began. */
|
|
667
|
+
const cover = (): void => {
|
|
668
|
+
for (const [tag, queries] of matched) {
|
|
669
|
+
const held = heldSince.get(tag)
|
|
670
|
+
if (held === undefined) continue
|
|
671
|
+
for (const query of queries) {
|
|
672
|
+
const since = Math.max(held, registeredAt(query) ?? Number.POSITIVE_INFINITY)
|
|
673
|
+
if (query.tick() < since) query.refresh()
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const scheduleMatch = (): void => {
|
|
679
|
+
if (stopped) return
|
|
680
|
+
// From here until the match lands, a change may concern a query it has not matched yet.
|
|
681
|
+
changedWhileMatching ??= new Set()
|
|
682
|
+
if (matchScheduled) return
|
|
683
|
+
matchScheduled = true
|
|
684
|
+
queueMicrotask(() => {
|
|
685
|
+
matchScheduled = false
|
|
686
|
+
matchQueries().catch(() => {})
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const digestOf = (salt: string, key: string): Promise<string> => {
|
|
691
|
+
let digester = digesters.get(salt)
|
|
692
|
+
if (digester === undefined) {
|
|
693
|
+
digester = { sign: queryDigester(salt), made: new Map() }
|
|
694
|
+
digesters.set(salt, digester)
|
|
695
|
+
}
|
|
696
|
+
let made = digester.made.get(key)
|
|
697
|
+
if (made === undefined) {
|
|
698
|
+
const { sign } = digester
|
|
699
|
+
made = sign.then((digest) => digest(key))
|
|
700
|
+
digester.made.set(key, made)
|
|
701
|
+
}
|
|
702
|
+
return made
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const matchQueries = async (): Promise<void> => {
|
|
706
|
+
const runId = ++matching
|
|
707
|
+
const sources = frames.flatMap((frame) =>
|
|
708
|
+
frame.loaded?.query === undefined ? [] : [frame.loaded.query],
|
|
709
|
+
)
|
|
710
|
+
const registered = [...liveQueries()]
|
|
711
|
+
const next = new Map<string, Set<LiveQuery>>()
|
|
712
|
+
const found = new Set<string>()
|
|
713
|
+
for (const source of sources) {
|
|
714
|
+
const { salt } = source
|
|
715
|
+
if (salt === undefined) continue
|
|
716
|
+
await Promise.all(
|
|
717
|
+
registered.flatMap((query) =>
|
|
718
|
+
query.keys().map(async (key) => {
|
|
719
|
+
const tag = source.tags.get(await digestOf(salt, key))
|
|
720
|
+
if (tag === undefined) return
|
|
721
|
+
found.add(key)
|
|
722
|
+
let queries = next.get(tag)
|
|
723
|
+
if (queries === undefined) {
|
|
724
|
+
queries = new Set()
|
|
725
|
+
next.set(tag, queries)
|
|
726
|
+
}
|
|
727
|
+
queries.add(query)
|
|
728
|
+
}),
|
|
729
|
+
),
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
if (runId !== matching || stopped) return
|
|
733
|
+
const salts = new Set(sources.map((source) => source.salt))
|
|
734
|
+
for (const salt of digesters.keys()) if (!salts.has(salt)) digesters.delete(salt)
|
|
735
|
+
const changed = changedWhileMatching ?? new Set<string>()
|
|
736
|
+
changedWhileMatching = undefined
|
|
737
|
+
const previous = matched
|
|
738
|
+
matched = next
|
|
739
|
+
// A change that arrived while this ran found the old match: refetch what only this one has.
|
|
740
|
+
for (const tag of changed) {
|
|
741
|
+
for (const query of next.get(tag) ?? []) if (!previous.get(tag)?.has(query)) query.refresh()
|
|
742
|
+
}
|
|
743
|
+
cover()
|
|
744
|
+
if (registered.length > 0 && sources.length === frames.length)
|
|
745
|
+
warnUnmatched(sources, registered, found)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Development only: a query key nothing on the page grants never refetches. */
|
|
749
|
+
const warnUnmatched = (
|
|
750
|
+
sources: readonly QueryKeys[],
|
|
751
|
+
registered: readonly LiveQuery[],
|
|
752
|
+
found: ReadonlySet<string>,
|
|
753
|
+
): void => {
|
|
754
|
+
if (!sources.some((source) => source.dev)) return
|
|
755
|
+
if (sources.every((source) => source.salt === undefined)) {
|
|
756
|
+
if (warned.has("")) return
|
|
757
|
+
warned.add("")
|
|
758
|
+
console.warn(
|
|
759
|
+
"[nifra/web] a query names live keys, but the app's live() has queryKeys off: no change refetches it",
|
|
760
|
+
)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
for (const query of registered) {
|
|
764
|
+
for (const key of query.keys()) {
|
|
765
|
+
if (found.has(key) || warned.has(key)) continue
|
|
766
|
+
warned.add(key)
|
|
767
|
+
console.warn(
|
|
768
|
+
`[nifra/web] query live key "${key}" is not granted by any loader on this page: a change to it never refetches the query`,
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const connect = (): void => {
|
|
775
|
+
reconnect = undefined
|
|
776
|
+
if (stopped) return
|
|
777
|
+
const url = new URL(appUrl(socketPath), globalThis.location?.href ?? "http://localhost/")
|
|
778
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
779
|
+
let socket: WebSocket
|
|
780
|
+
try {
|
|
781
|
+
socket = new WebSocket(url.href)
|
|
782
|
+
} catch {
|
|
783
|
+
closed(1006, false)
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
ws = socket
|
|
787
|
+
let welcomed = false
|
|
788
|
+
socket.onmessage = (event: MessageEvent) => {
|
|
789
|
+
if (ws !== socket || typeof event.data !== "string" || event.data === HEARTBEAT) return
|
|
790
|
+
let value: unknown
|
|
791
|
+
try {
|
|
792
|
+
value = JSON.parse(event.data)
|
|
793
|
+
} catch {
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
if (!isRecord(value)) return
|
|
797
|
+
if (value.t === "welcome" && typeof value.sid === "string" && SID.test(value.sid)) {
|
|
798
|
+
welcomed = true
|
|
799
|
+
connectFailures = 0
|
|
800
|
+
sid = value.sid
|
|
801
|
+
const spread = value.spread
|
|
802
|
+
welcomeSpread =
|
|
803
|
+
typeof spread === "number" && spread > 0 ? Math.min(spread, MAX_SPREAD_MS) : 0
|
|
804
|
+
heartbeat = setInterval(() => {
|
|
805
|
+
if (socket.readyState === 1) socket.send(HEARTBEAT)
|
|
806
|
+
}, HEARTBEAT_MS)
|
|
807
|
+
if (clearOnWelcome) {
|
|
808
|
+
clearOnWelcome = false
|
|
809
|
+
router?.invalidate().catch(() => {})
|
|
810
|
+
}
|
|
811
|
+
reconcile()
|
|
812
|
+
} else if (value.t === "ready") onReady(value)
|
|
813
|
+
else if (value.t === "changed") onChanged(value)
|
|
814
|
+
}
|
|
815
|
+
socket.onclose = (event: CloseEvent) => {
|
|
816
|
+
if (ws !== socket) return
|
|
817
|
+
closed(event.code, welcomed)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const closed = (code: number, welcomed: boolean): void => {
|
|
822
|
+
ws = undefined
|
|
823
|
+
sid = undefined
|
|
824
|
+
sent = undefined
|
|
825
|
+
// No watch survives the socket: changes until the next `ready` are not known.
|
|
826
|
+
heldSince.clear()
|
|
827
|
+
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
828
|
+
heartbeat = undefined
|
|
829
|
+
for (const frame of frames) {
|
|
830
|
+
frame.installed = false
|
|
831
|
+
frame.pending = undefined
|
|
832
|
+
}
|
|
833
|
+
if (stopped) return
|
|
834
|
+
if (!welcomed) connectFailures++
|
|
835
|
+
// A full server asks every client to wait; a protocol refusal is not retried at once.
|
|
836
|
+
const base = code === 1013 || code === 1008 ? 5_000 : RETRY_MS
|
|
837
|
+
if (wantsSocket()) {
|
|
838
|
+
reconnect = setTimeout(
|
|
839
|
+
connect,
|
|
840
|
+
jittered(base * 2 ** Math.min(connectFailures, 5), MAX_RECONNECT_MS),
|
|
841
|
+
)
|
|
842
|
+
}
|
|
843
|
+
run()
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** A new viewer: a new socket, every cache cleared, and what is on screen loaded under it. */
|
|
847
|
+
const restart = (): void => {
|
|
848
|
+
tried.clear()
|
|
849
|
+
const socket = ws
|
|
850
|
+
if (socket === undefined) {
|
|
851
|
+
router?.invalidate().catch(() => {})
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
ws = undefined
|
|
855
|
+
socket.close(1000)
|
|
856
|
+
closed(1000, true)
|
|
857
|
+
if (reconnect !== undefined) clearTimeout(reconnect)
|
|
858
|
+
clearOnWelcome = true
|
|
859
|
+
connect()
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const onVisible = (): void => {
|
|
863
|
+
if (hidden()) return
|
|
864
|
+
for (const frame of frames) {
|
|
865
|
+
frame.failures = 0
|
|
866
|
+
frame.retryAt = 0
|
|
867
|
+
}
|
|
868
|
+
run()
|
|
869
|
+
}
|
|
870
|
+
const onFocus = (): void => {
|
|
871
|
+
const now = Date.now()
|
|
872
|
+
for (const frame of frames) {
|
|
873
|
+
if (!watchable(frame) && now - frame.shownAt > FOCUS_RELOAD_MS) frame.pollAt = now
|
|
874
|
+
}
|
|
875
|
+
run()
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
fetchData,
|
|
880
|
+
attach(target) {
|
|
881
|
+
router = target
|
|
882
|
+
stopped = false
|
|
883
|
+
adoptSeed(target.snapshot())
|
|
884
|
+
const unsubscribe = target.subscribe(reconcile)
|
|
885
|
+
unwatchQueries = watchLiveQueries(scheduleMatch)
|
|
886
|
+
globalThis.document?.addEventListener("visibilitychange", onVisible)
|
|
887
|
+
globalThis.addEventListener?.("focus", onFocus)
|
|
888
|
+
connect()
|
|
889
|
+
reconcile()
|
|
890
|
+
return () => {
|
|
891
|
+
stopped = true
|
|
892
|
+
unsubscribe()
|
|
893
|
+
unwatchQueries?.()
|
|
894
|
+
unwatchQueries = undefined
|
|
895
|
+
globalThis.document?.removeEventListener("visibilitychange", onVisible)
|
|
896
|
+
globalThis.removeEventListener?.("focus", onFocus)
|
|
897
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
898
|
+
if (reconnect !== undefined) clearTimeout(reconnect)
|
|
899
|
+
const socket = ws
|
|
900
|
+
ws = undefined
|
|
901
|
+
socket?.close(1000)
|
|
902
|
+
closed(1000, true)
|
|
903
|
+
router = undefined
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
interface ServerEnvelope {
|
|
910
|
+
readonly subjects: readonly string[]
|
|
911
|
+
readonly grant?: string
|
|
912
|
+
readonly tags?: Readonly<Record<string, readonly string[]>>
|
|
913
|
+
readonly page?: "retained"
|
|
914
|
+
readonly ttl?: number
|
|
915
|
+
readonly query?: QueryKeys
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const MAX_SALT_CHARS = 64
|
|
919
|
+
const MAX_DIGEST_CHARS = 64
|
|
920
|
+
/** One per listed secret, at most three. */
|
|
921
|
+
const MAX_SUBJECTS = 3
|
|
922
|
+
const MAX_SUBJECT_CHARS = 64
|
|
923
|
+
|
|
924
|
+
/** The envelope's `query` member, or `undefined` for anything else in its place. */
|
|
925
|
+
function queryKeysOf(value: unknown): QueryKeys | undefined {
|
|
926
|
+
if (!isRecord(value)) return undefined
|
|
927
|
+
const { salt, keys, dev } = value
|
|
928
|
+
if (dev !== undefined && dev !== true) return undefined
|
|
929
|
+
if (salt !== undefined && (typeof salt !== "string" || salt.length > MAX_SALT_CHARS))
|
|
930
|
+
return undefined
|
|
931
|
+
const tags = new Map<string, string>()
|
|
932
|
+
if (keys !== undefined) {
|
|
933
|
+
if (!isRecord(keys)) return undefined
|
|
934
|
+
for (const [tag, digest] of Object.entries(keys)) {
|
|
935
|
+
if (typeof digest !== "string" || digest.length > MAX_DIGEST_CHARS) return undefined
|
|
936
|
+
tags.set(digest, tag)
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return { salt, tags, dev: dev === true }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** The server's live envelope, or `undefined` for anything else in its place. */
|
|
943
|
+
function envelopeOf(value: unknown): ServerEnvelope | undefined {
|
|
944
|
+
if (!isRecord(value)) return undefined
|
|
945
|
+
const { subjects, grant, tags, page, ttl, query } = value
|
|
946
|
+
if (
|
|
947
|
+
!Array.isArray(subjects) ||
|
|
948
|
+
subjects.length === 0 ||
|
|
949
|
+
subjects.length > MAX_SUBJECTS ||
|
|
950
|
+
!subjects.every((tag) => typeof tag === "string" && tag.length <= MAX_SUBJECT_CHARS)
|
|
951
|
+
) {
|
|
952
|
+
return undefined
|
|
953
|
+
}
|
|
954
|
+
const queryKeys = query === undefined ? undefined : queryKeysOf(query)
|
|
955
|
+
if (query !== undefined && queryKeys === undefined) return undefined
|
|
956
|
+
if (grant !== undefined && typeof grant !== "string") return undefined
|
|
957
|
+
if (ttl !== undefined && (typeof ttl !== "number" || !(ttl > 0))) return undefined
|
|
958
|
+
if (page !== undefined && page !== "retained") return undefined
|
|
959
|
+
const owned: Record<string, readonly string[]> = {}
|
|
960
|
+
if (tags !== undefined) {
|
|
961
|
+
if (!isRecord(tags)) return undefined
|
|
962
|
+
for (const [tag, owners] of Object.entries(tags)) {
|
|
963
|
+
if (!Array.isArray(owners)) return undefined
|
|
964
|
+
const names: string[] = []
|
|
965
|
+
for (const owner of owners) {
|
|
966
|
+
if (typeof owner !== "string") return undefined
|
|
967
|
+
names.push(owner)
|
|
968
|
+
}
|
|
969
|
+
owned[tag] = names
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
const envelope: {
|
|
973
|
+
subjects: readonly string[]
|
|
974
|
+
tags: Record<string, readonly string[]>
|
|
975
|
+
grant?: string
|
|
976
|
+
page?: "retained"
|
|
977
|
+
ttl?: number
|
|
978
|
+
query?: QueryKeys
|
|
979
|
+
} = { subjects, tags: owned }
|
|
980
|
+
if (typeof grant === "string") envelope.grant = grant
|
|
981
|
+
if (page === "retained") envelope.page = page
|
|
982
|
+
if (typeof ttl === "number") envelope.ttl = ttl
|
|
983
|
+
if (queryKeys !== undefined) envelope.query = queryKeys
|
|
984
|
+
return envelope
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
988
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
989
|
+
|
|
990
|
+
/** A minute before `loaded` expires, less up to 30 s of jitter; a short grant renews a third early. */
|
|
991
|
+
const renewAtOf = (loaded: Loaded): number =>
|
|
992
|
+
loaded.expiresAt -
|
|
993
|
+
Math.min(RENEW_BEFORE_MS, loaded.ttlMs / 3) -
|
|
994
|
+
Math.random() * Math.min(RENEW_JITTER_MS, loaded.ttlMs / 6)
|
|
995
|
+
|
|
996
|
+
/** The promise of a value the NDJSON stream settles later, as the data reader marks one. */
|
|
997
|
+
const deferredOf = (value: unknown): Promise<unknown> | undefined =>
|
|
998
|
+
isRecord(value) && value.__nifra_deferred === true && value.promise instanceof Promise
|
|
999
|
+
? value.promise
|
|
1000
|
+
: undefined
|
|
1001
|
+
|
|
1002
|
+
/** `ms` spread over +/-20%, capped. */
|
|
1003
|
+
const jittered = (ms: number, max: number): number =>
|
|
1004
|
+
Math.min(max, ms * (0.8 + Math.random() * 0.4))
|
|
1005
|
+
|
|
1006
|
+
function randomSid(): string {
|
|
1007
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
1008
|
+
let out = ""
|
|
1009
|
+
for (const byte of bytes) out += byte.toString(16).padStart(2, "0")
|
|
1010
|
+
return out
|
|
1011
|
+
}
|