@nifrajs/web 5.5.1 → 5.6.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 +154 -0
- 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 +9 -0
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +140 -12
- 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 +7 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- 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 +65 -8
- 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 +179 -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 +96 -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 +14 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +415 -31
- package/dist/internal/page-execution.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 +32 -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 +1407 -0
- package/dist/internal/ppr/server.js.map +1 -0
- package/dist/internal/ppr/shell-store.d.ts +38 -0
- package/dist/internal/ppr/shell-store.d.ts.map +1 -0
- package/dist/internal/ppr/shell-store.js +90 -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/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 +28 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +83 -18
- 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/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 +25 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +78 -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 +1069 -0
- package/dist/keep-alive.js.map +1 -0
- package/dist/live/bus.d.ts +56 -0
- package/dist/live/bus.d.ts.map +1 -0
- package/dist/live/bus.js +32 -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 +181 -0
- package/dist/live/hub.d.ts.map +1 -0
- package/dist/live/hub.js +539 -0
- package/dist/live/hub.js.map +1 -0
- package/dist/live/keyring.d.ts +87 -0
- package/dist/live/keyring.d.ts.map +1 -0
- package/dist/live/keyring.js +260 -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 +840 -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 +48 -0
- package/dist/live-redis.d.ts.map +1 -0
- package/dist/live-redis.js +201 -0
- package/dist/live-redis.js.map +1 -0
- package/dist/live.d.ts +94 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +869 -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 +30 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +5 -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 +374 -99
- 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/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 +86 -28
- 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/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 +19 -1
- package/src/client.ts +147 -10
- 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 +19 -0
- package/src/internal/cdn/cloudflare.ts +2 -1
- package/src/internal/certified-components.ts +1 -1
- package/src/internal/codegen.ts +87 -8
- package/src/internal/dev-websocket.ts +230 -0
- package/src/internal/hole-runtime.ts +286 -0
- package/src/internal/identity-epoch.ts +235 -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 +106 -0
- package/src/internal/live-queries.ts +60 -0
- package/src/internal/page-events.ts +27 -0
- package/src/internal/page-execution.ts +579 -40
- 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 +36 -0
- package/src/internal/ppr/server.ts +1764 -0
- package/src/internal/ppr/shell-store.ts +139 -0
- package/src/internal/prerendered-pages.ts +7 -4
- 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 +149 -22
- 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/store-signature.ts +149 -0
- package/src/internal/vite-plugin-compat.ts +2 -2
- package/src/internal/web-app.ts +121 -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 +1315 -0
- package/src/live/bus.ts +77 -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 +615 -0
- package/src/live/keyring.ts +379 -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 +973 -0
- package/src/live-hub.ts +658 -0
- package/src/live-redis.ts +259 -0
- package/src/live.ts +1096 -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 +35 -0
- package/src/route-types.ts +3 -1
- package/src/router.ts +494 -101
- package/src/server-components/build.ts +9 -3
- package/src/server-components/client.ts +89 -5
- 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 +112 -30
- package/src/server-components/state.ts +66 -44
- package/src/server-components/types.ts +16 -0
- 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/hub.ts
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live socket endpoint and its fan-out. A socket subscribes to the tags of the keys its installed
|
|
3
|
+
* grants hold; each change goes out as one identical `changed` frame per tag topic, so Bun can send it
|
|
4
|
+
* with one native publish. Frames carry tags only: never data, key text, subjects or seqs.
|
|
5
|
+
*
|
|
6
|
+
* Installing a watch and applying a bus event each run as one synchronous step (subscribe, then scan
|
|
7
|
+
* history, then `ready`; record in history, then publish), so every event is either reported in
|
|
8
|
+
* `ready.stale` or delivered after the subscription, and `ready` precedes every later frame.
|
|
9
|
+
*/
|
|
10
|
+
import { isSameOriginRequest, type NifraWebSocket, type WebSocketData } from "@nifrajs/core/server"
|
|
11
|
+
import type { LiveRevocation } from "./bus.ts"
|
|
12
|
+
import type { History } from "./history.ts"
|
|
13
|
+
import { type Keyring, MAX_GRANT_CHARS, type OpenedGrant } from "./keyring.ts"
|
|
14
|
+
import type { Revocations } from "./revocations.ts"
|
|
15
|
+
|
|
16
|
+
export const CLOSE_BAD_GRANT = 4401
|
|
17
|
+
export const CLOSE_REVOKED = 4403
|
|
18
|
+
export const CLOSE_SCOPE = 4409
|
|
19
|
+
export const CLOSE_CAPACITY = 1013
|
|
20
|
+
export const CLOSE_PROTOCOL = 1008
|
|
21
|
+
/** Nothing to watch: a tab with no tag to hear about reconnects only once it has one. */
|
|
22
|
+
export const CLOSE_UNWATCHED = 1000
|
|
23
|
+
|
|
24
|
+
export const MAX_FRAME_BYTES = 32_768
|
|
25
|
+
export const MAX_WATCH_GRANTS = 3
|
|
26
|
+
export const MAX_WATCHES_PER_MINUTE = 20
|
|
27
|
+
/** Every inbound frame, heartbeats included: a client heartbeats twice a minute. */
|
|
28
|
+
export const MAX_FRAMES_PER_MINUTE = 60
|
|
29
|
+
export const MAX_OUTBOUND_BYTES = 65_536
|
|
30
|
+
export const MAX_TOPICS = 200_000
|
|
31
|
+
export const IDLE_MS = 150_000
|
|
32
|
+
/**
|
|
33
|
+
* How long a socket may hold no tag: from its open, or from the watch or revocation that left it
|
|
34
|
+
* none. A tab watches right after its first live load, so only a socket nobody listens on runs out.
|
|
35
|
+
*/
|
|
36
|
+
export const FIRST_WATCH_MS = 60_000
|
|
37
|
+
export const SWEEP_MS = 10_000
|
|
38
|
+
export const COALESCE_MS = 25
|
|
39
|
+
/** How long after closing many sockets at once their reconnects are told to spread their reloads. */
|
|
40
|
+
export const RECONNECT_WINDOW_MS = 30_000
|
|
41
|
+
const TAG_CACHE_SIZE = 16_384
|
|
42
|
+
const SPREAD_STEPS = [250, 500, 1000, 2000, 5000]
|
|
43
|
+
const HEARTBEAT = "p"
|
|
44
|
+
|
|
45
|
+
export interface HubOptions {
|
|
46
|
+
readonly appId: string
|
|
47
|
+
readonly trusted: ReadonlySet<string>
|
|
48
|
+
readonly maxSockets: number
|
|
49
|
+
readonly maxRssBytes: number | undefined
|
|
50
|
+
readonly reloadRate: number
|
|
51
|
+
readonly instances: number
|
|
52
|
+
readonly spreadFloor: number
|
|
53
|
+
readonly hash: (key: string) => number
|
|
54
|
+
/** Distinct tags this process holds subscriptions for. Default {@link MAX_TOPICS}. */
|
|
55
|
+
readonly maxTopics?: number
|
|
56
|
+
/** Where key tags come from; the runtime shares one cache between its loads and this hub. */
|
|
57
|
+
readonly tags?: TagOf
|
|
58
|
+
/**
|
|
59
|
+
* Socket-tag subscriptions this process holds in all; past it a watch is refused with 1013. For a
|
|
60
|
+
* host whose memory is capped below what its sockets could ask for. Default: no budget.
|
|
61
|
+
*/
|
|
62
|
+
readonly maxMemberships?: number
|
|
63
|
+
/**
|
|
64
|
+
* Where a hub that outlives its memory keeps socket state (a hibernating Durable Object): told of
|
|
65
|
+
* every installed watch and every socket that leaves. Its sweep runs on the host's alarm instead
|
|
66
|
+
* of a timer, which would keep the host awake.
|
|
67
|
+
*/
|
|
68
|
+
readonly store?: HubStore
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What a socket's membership is, kept so a hub can {@link LiveHub.restore} it after its memory is gone. */
|
|
72
|
+
export interface StoredSocket {
|
|
73
|
+
readonly sub: string | null
|
|
74
|
+
readonly exp: number
|
|
75
|
+
readonly tags: readonly string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface HubStore {
|
|
79
|
+
saved(ws: NifraWebSocket<LiveSocket>, socket: StoredSocket): void
|
|
80
|
+
removed(ws: NifraWebSocket<LiveSocket>): void
|
|
81
|
+
/** The last time the host answered this socket's heartbeat without waking the hub. */
|
|
82
|
+
lastBeat(ws: NifraWebSocket<LiveSocket>): number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Per-connection state, held in `ws.data`. */
|
|
86
|
+
export interface LiveSocket {
|
|
87
|
+
sid: string
|
|
88
|
+
/** The subject of the first accepted grant; `undefined` until then. */
|
|
89
|
+
sub: string | null | undefined
|
|
90
|
+
/** The highest `gen` received; a lower or equal one is ignored. */
|
|
91
|
+
received: number
|
|
92
|
+
tags: Set<string>
|
|
93
|
+
/** Earliest installed grant expiry, ms. */
|
|
94
|
+
exp: number
|
|
95
|
+
/** Since when it has held no tag; `undefined` while it holds one. See {@link FIRST_WATCH_MS}. */
|
|
96
|
+
unwatchedSince: number | undefined
|
|
97
|
+
window: number
|
|
98
|
+
frames: number
|
|
99
|
+
watches: number
|
|
100
|
+
lastSeen: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface Watch {
|
|
104
|
+
readonly gen: number
|
|
105
|
+
readonly grants: readonly string[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface Prepared {
|
|
109
|
+
readonly grant: OpenedGrant
|
|
110
|
+
readonly owners: readonly { readonly id: string; readonly w: number; readonly hashes: number[] }[]
|
|
111
|
+
readonly tags: readonly string[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Pending {
|
|
115
|
+
tags: readonly string[]
|
|
116
|
+
again: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class LiveHub {
|
|
120
|
+
/** Bus unreachable: no watch is accepted and no socket kept. */
|
|
121
|
+
down = false
|
|
122
|
+
private readonly sockets = new Set<NifraWebSocket<LiveSocket>>()
|
|
123
|
+
/** Local subscribers per tag: the intern table, and the input to `spread`. */
|
|
124
|
+
private readonly members = new Map<string, number>()
|
|
125
|
+
/** Socket-tag subscriptions held, the sum of `members`. */
|
|
126
|
+
private memberships = 0
|
|
127
|
+
private readonly tagOf: TagOf
|
|
128
|
+
private readonly coalescing = new Map<string, Pending>()
|
|
129
|
+
/** The last time many sockets were closed at once, and how many. */
|
|
130
|
+
private burstAt = Number.NEGATIVE_INFINITY
|
|
131
|
+
private burstSize = 0
|
|
132
|
+
private coalesceTimer: ReturnType<typeof setTimeout> | undefined
|
|
133
|
+
private sweepTimer: ReturnType<typeof setInterval> | undefined
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
private readonly ring: Keyring,
|
|
137
|
+
private readonly history: History,
|
|
138
|
+
private readonly revocations: Revocations,
|
|
139
|
+
private readonly options: HubOptions,
|
|
140
|
+
/** Where `changed` frames go: each attached app's `publish`. */
|
|
141
|
+
private readonly publishers: ReadonlySet<(topic: string, frame: string) => void>,
|
|
142
|
+
) {
|
|
143
|
+
this.tagOf = options.tags ?? tagCache(ring)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get size(): number {
|
|
147
|
+
return this.sockets.size
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The tag of `key` under each listed secret, newest first. */
|
|
151
|
+
tagsOf(key: string): Promise<string[]> {
|
|
152
|
+
const tags: Promise<string>[] = []
|
|
153
|
+
for (let secret = 0; secret < this.ring.secretCount; secret++)
|
|
154
|
+
tags.push(this.tagOf(key, secret))
|
|
155
|
+
return Promise.all(tags)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The handshake: a missing, `null` or untrusted Origin is refused. */
|
|
159
|
+
upgrade(req: Request): LiveSocket | Response {
|
|
160
|
+
const origin = req.headers.get("origin")
|
|
161
|
+
if (
|
|
162
|
+
origin === null ||
|
|
163
|
+
!(this.options.trusted.has(origin) || isSameOriginRequest(origin, req))
|
|
164
|
+
) {
|
|
165
|
+
return Response.json({ error: "forbidden_origin" }, { status: 403 })
|
|
166
|
+
}
|
|
167
|
+
const now = Date.now()
|
|
168
|
+
return {
|
|
169
|
+
sid: randomSid(),
|
|
170
|
+
sub: undefined,
|
|
171
|
+
received: -1,
|
|
172
|
+
tags: new Set<string>(),
|
|
173
|
+
exp: Number.POSITIVE_INFINITY,
|
|
174
|
+
unwatchedSince: now,
|
|
175
|
+
window: now,
|
|
176
|
+
frames: 0,
|
|
177
|
+
watches: 0,
|
|
178
|
+
lastSeen: now,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
open(ws: NifraWebSocket<LiveSocket>): void {
|
|
183
|
+
if (this.down || this.sockets.size >= this.options.maxSockets || this.overRss()) {
|
|
184
|
+
ws.close(CLOSE_CAPACITY, "at capacity")
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
this.sockets.add(ws)
|
|
188
|
+
this.startSweep()
|
|
189
|
+
const spread = Date.now() - this.burstAt < RECONNECT_WINDOW_MS ? this.spread(this.burstSize) : 0
|
|
190
|
+
this.send(
|
|
191
|
+
ws,
|
|
192
|
+
JSON.stringify(
|
|
193
|
+
spread > 0
|
|
194
|
+
? { t: "welcome", sid: ws.data.sid, spread }
|
|
195
|
+
: { t: "welcome", sid: ws.data.sid },
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The runtime has dropped the closed socket's topics; only the counts are left to settle. */
|
|
201
|
+
close(ws: NifraWebSocket<LiveSocket>): void {
|
|
202
|
+
this.forget(ws, false)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A change, already in history: publish one frame per tag, at most once per key per window. */
|
|
206
|
+
change(keys: readonly string[], tags: readonly (readonly string[])[]): void {
|
|
207
|
+
for (let i = 0; i < keys.length; i++) {
|
|
208
|
+
const key = keys[i] ?? ""
|
|
209
|
+
const keyTags = tags[i] ?? []
|
|
210
|
+
// Nobody here watches it: nothing to send now or at the end of a window.
|
|
211
|
+
if (!keyTags.some((tag) => this.members.has(tag))) continue
|
|
212
|
+
const pending = this.coalescing.get(key)
|
|
213
|
+
if (pending !== undefined) {
|
|
214
|
+
pending.again = true
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
this.coalescing.set(key, { tags: keyTags, again: false })
|
|
218
|
+
this.publishTags(keyTags)
|
|
219
|
+
}
|
|
220
|
+
if (this.coalescing.size > 0 && this.coalesceTimer === undefined) {
|
|
221
|
+
this.coalesceTimer = unref(setTimeout(() => this.flush(), COALESCE_MS))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A revocation, already stored. `tags` are the key's tags for a `revokeKey`. */
|
|
226
|
+
revoke(event: LiveRevocation, tags: readonly string[]): void {
|
|
227
|
+
if (event.key === null) {
|
|
228
|
+
for (const ws of [...this.sockets]) {
|
|
229
|
+
if (event.sub === null || ws.data.sub === event.sub) this.drop(ws, CLOSE_REVOKED, "revoked")
|
|
230
|
+
}
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
// One terminal `changed` to every subscriber, then the revoked ones leave the topics: no later
|
|
234
|
+
// frame for this key reaches them.
|
|
235
|
+
this.publishTags(tags)
|
|
236
|
+
for (const ws of this.sockets) {
|
|
237
|
+
if (event.sub !== null && ws.data.sub !== event.sub) continue
|
|
238
|
+
let left = false
|
|
239
|
+
for (const tag of tags) {
|
|
240
|
+
if (!ws.data.tags.has(tag)) continue
|
|
241
|
+
this.leave(ws, tag)
|
|
242
|
+
left = true
|
|
243
|
+
}
|
|
244
|
+
const state = ws.data
|
|
245
|
+
if (left && state.tags.size === 0) state.unwatchedSince ??= Date.now()
|
|
246
|
+
if (left)
|
|
247
|
+
this.options.store?.saved(ws, {
|
|
248
|
+
sub: state.sub ?? null,
|
|
249
|
+
exp: state.exp,
|
|
250
|
+
tags: [...state.tags],
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Close every socket: history or revocations before now are unknown, or the bus is gone. */
|
|
256
|
+
closeAll(code: number, reason: string): void {
|
|
257
|
+
const sockets = [...this.sockets]
|
|
258
|
+
this.burst(sockets.length)
|
|
259
|
+
for (const ws of sockets) this.drop(ws, code, reason)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `count` sockets just closed together and will reconnect together. Each must reload under its new
|
|
264
|
+
* socket id, so for a while `welcome` tells them to spread those reloads as a change would.
|
|
265
|
+
*/
|
|
266
|
+
burst(count: number): void {
|
|
267
|
+
const now = Date.now()
|
|
268
|
+
const recent = now - this.burstAt < RECONNECT_WINDOW_MS
|
|
269
|
+
this.burstSize = recent ? Math.max(this.burstSize, count) : count
|
|
270
|
+
this.burstAt = now
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Resolves once a watch is installed or refused. */
|
|
274
|
+
message(ws: NifraWebSocket<LiveSocket>, data: WebSocketData): Promise<void> | undefined {
|
|
275
|
+
// Refused at open or already dropped: a frame still in flight gets no say.
|
|
276
|
+
if (!this.sockets.has(ws)) return undefined
|
|
277
|
+
const state = ws.data
|
|
278
|
+
const now = Date.now()
|
|
279
|
+
state.lastSeen = now
|
|
280
|
+
if (now - state.window >= 60_000) {
|
|
281
|
+
state.window = now
|
|
282
|
+
state.frames = 0
|
|
283
|
+
state.watches = 0
|
|
284
|
+
}
|
|
285
|
+
if (++state.frames > MAX_FRAMES_PER_MINUTE) {
|
|
286
|
+
this.drop(ws, CLOSE_PROTOCOL, "rate")
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
if (data === HEARTBEAT) {
|
|
290
|
+
this.send(ws, HEARTBEAT)
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
const watch = typeof data === "string" ? parseWatch(data) : undefined
|
|
294
|
+
if (watch === undefined) {
|
|
295
|
+
this.drop(ws, CLOSE_PROTOCOL, "protocol")
|
|
296
|
+
return undefined
|
|
297
|
+
}
|
|
298
|
+
if (++state.watches > MAX_WATCHES_PER_MINUTE) {
|
|
299
|
+
this.drop(ws, CLOSE_PROTOCOL, "rate")
|
|
300
|
+
return undefined
|
|
301
|
+
}
|
|
302
|
+
if (watch.gen <= state.received) return undefined
|
|
303
|
+
state.received = watch.gen
|
|
304
|
+
return this.install(ws, watch).catch(() => this.drop(ws, 1011, "error"))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async install(ws: NifraWebSocket<LiveSocket>, watch: Watch): Promise<void> {
|
|
308
|
+
const state = ws.data
|
|
309
|
+
const now = Date.now()
|
|
310
|
+
const prepared: Prepared[] = []
|
|
311
|
+
// One grant per path: the page and each open boundary show different URLs.
|
|
312
|
+
const paths = new Set<string>()
|
|
313
|
+
for (const token of watch.grants) {
|
|
314
|
+
const grant = await this.ring.open(token, now)
|
|
315
|
+
if (grant === undefined || grant.claims.sid !== state.sid) {
|
|
316
|
+
return this.drop(ws, CLOSE_BAD_GRANT, "grant")
|
|
317
|
+
}
|
|
318
|
+
if (paths.has(grant.claims.path)) return this.drop(ws, CLOSE_PROTOCOL, "protocol")
|
|
319
|
+
paths.add(grant.claims.path)
|
|
320
|
+
const owners: { id: string; w: number; hashes: number[] }[] = []
|
|
321
|
+
const tags: string[] = []
|
|
322
|
+
for (const [id, owner] of Object.entries(grant.claims.owners)) {
|
|
323
|
+
owners.push({ id, w: owner.w, hashes: owner.k.map(this.options.hash) })
|
|
324
|
+
tags.push(...(await Promise.all(owner.k.map((key) => this.tagOf(key, grant.secret)))))
|
|
325
|
+
}
|
|
326
|
+
prepared.push({ grant, owners, tags })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// One synchronous step from here to `ready`.
|
|
330
|
+
if (!this.sockets.has(ws) || ws.readyState !== 1 || state.received !== watch.gen) return
|
|
331
|
+
if (this.down) return this.drop(ws, CLOSE_CAPACITY, "unavailable")
|
|
332
|
+
let sub = state.sub
|
|
333
|
+
for (const { grant } of prepared) {
|
|
334
|
+
const claims = grant.claims
|
|
335
|
+
if (this.revocations.grantRevoked(claims)) return this.drop(ws, CLOSE_REVOKED, "revoked")
|
|
336
|
+
for (const owner of Object.values(claims.owners)) {
|
|
337
|
+
for (const key of owner.k) {
|
|
338
|
+
if (this.revocations.keyRevoked(key, claims)) {
|
|
339
|
+
return this.drop(ws, CLOSE_BAD_GRANT, "grant")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (sub !== undefined && sub !== claims.sub) return this.drop(ws, CLOSE_SCOPE, "scope")
|
|
344
|
+
sub = claims.sub
|
|
345
|
+
}
|
|
346
|
+
// At most 3 grants of 64 keys each (`parseWatch`, the keyring): 192 memberships per socket.
|
|
347
|
+
const next = new Set<string>()
|
|
348
|
+
for (const { tags } of prepared) for (const tag of tags) next.add(tag)
|
|
349
|
+
let added = 0
|
|
350
|
+
for (const tag of next) if (!this.members.has(tag)) added++
|
|
351
|
+
const budget = this.options.maxMemberships ?? Number.POSITIVE_INFINITY
|
|
352
|
+
if (
|
|
353
|
+
this.members.size + added > (this.options.maxTopics ?? MAX_TOPICS) ||
|
|
354
|
+
this.memberships - state.tags.size + next.size > budget
|
|
355
|
+
) {
|
|
356
|
+
return this.drop(ws, CLOSE_CAPACITY, "at capacity")
|
|
357
|
+
}
|
|
358
|
+
state.sub = sub
|
|
359
|
+
for (const tag of state.tags) if (!next.has(tag)) this.leave(ws, tag)
|
|
360
|
+
for (const tag of next) {
|
|
361
|
+
if (state.tags.has(tag)) continue
|
|
362
|
+
this.join(ws, tag)
|
|
363
|
+
}
|
|
364
|
+
// An empty watch does not restart the clock: only holding a tag stops it.
|
|
365
|
+
state.unwatchedSince = next.size === 0 ? (state.unwatchedSince ?? now) : undefined
|
|
366
|
+
// Per grant, in watch order: two frames can hold an owner of the same id.
|
|
367
|
+
const stale: string[][] = []
|
|
368
|
+
let exp = Number.POSITIVE_INFINITY
|
|
369
|
+
for (const { grant, owners } of prepared) {
|
|
370
|
+
exp = Math.min(exp, grant.claims.exp * 1000)
|
|
371
|
+
const changed: string[] = []
|
|
372
|
+
for (const owner of owners) {
|
|
373
|
+
if (
|
|
374
|
+
!this.history.known(owner.w) ||
|
|
375
|
+
owner.hashes.some((hash) => this.history.changedSince(hash, owner.w))
|
|
376
|
+
)
|
|
377
|
+
changed.push(owner.id)
|
|
378
|
+
}
|
|
379
|
+
stale.push(changed)
|
|
380
|
+
}
|
|
381
|
+
state.exp = exp
|
|
382
|
+
this.options.store?.saved(ws, { sub: state.sub ?? null, exp, tags: [...state.tags] })
|
|
383
|
+
this.send(ws, JSON.stringify({ t: "ready", gen: watch.gen, stale }))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A socket whose hub lost its memory but not its connection: its membership comes back as stored.
|
|
388
|
+
* Every revocation since was applied to what is stored, so nothing here is checked again.
|
|
389
|
+
*/
|
|
390
|
+
restore(ws: NifraWebSocket<LiveSocket>, stored: StoredSocket | undefined): void {
|
|
391
|
+
this.sockets.add(ws)
|
|
392
|
+
if (stored === undefined) return
|
|
393
|
+
const state = ws.data
|
|
394
|
+
state.sub = stored.sub
|
|
395
|
+
state.exp = stored.exp
|
|
396
|
+
for (const tag of stored.tags) if (!state.tags.has(tag)) this.join(ws, tag)
|
|
397
|
+
if (state.tags.size > 0) state.unwatchedSince = undefined
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private flush(): void {
|
|
401
|
+
this.coalesceTimer = undefined
|
|
402
|
+
for (const [key, pending] of this.coalescing) {
|
|
403
|
+
if (!pending.again) {
|
|
404
|
+
this.coalescing.delete(key)
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
pending.again = false
|
|
408
|
+
this.publishTags(pending.tags)
|
|
409
|
+
}
|
|
410
|
+
if (this.coalescing.size > 0) {
|
|
411
|
+
this.coalesceTimer = unref(setTimeout(() => this.flush(), COALESCE_MS))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private publishTags(tags: readonly string[]): void {
|
|
416
|
+
for (const tag of tags) {
|
|
417
|
+
const subscribers = this.members.get(tag)
|
|
418
|
+
if (subscribers === undefined) continue
|
|
419
|
+
const frame = JSON.stringify({ t: "changed", tag, spread: this.spread(subscribers) })
|
|
420
|
+
for (const publish of this.publishers) publish(this.topic(tag), frame)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* How long clients spread their reloads, from this process's subscriber count, rounded up to a few
|
|
426
|
+
* steps so the frame shows no more than its order of magnitude.
|
|
427
|
+
*/
|
|
428
|
+
private spread(subscribers: number): number {
|
|
429
|
+
const { instances, reloadRate, spreadFloor } = this.options
|
|
430
|
+
const raw = Math.max(spreadFloor, ((subscribers * instances) / reloadRate) * 1000)
|
|
431
|
+
if (raw <= 100) return 0
|
|
432
|
+
return SPREAD_STEPS.find((step) => step >= raw) ?? 5000
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private join(ws: NifraWebSocket<LiveSocket>, tag: string): void {
|
|
436
|
+
ws.data.tags.add(tag)
|
|
437
|
+
this.members.set(tag, (this.members.get(tag) ?? 0) + 1)
|
|
438
|
+
this.memberships++
|
|
439
|
+
ws.subscribe(this.topic(tag))
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private leave(ws: NifraWebSocket<LiveSocket>, tag: string, open = true): void {
|
|
443
|
+
ws.data.tags.delete(tag)
|
|
444
|
+
if (open) ws.unsubscribe(this.topic(tag))
|
|
445
|
+
this.memberships--
|
|
446
|
+
const count = (this.members.get(tag) ?? 1) - 1
|
|
447
|
+
if (count === 0) this.members.delete(tag)
|
|
448
|
+
else this.members.set(tag, count)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Leave every topic before closing: nothing published after this reaches the socket. */
|
|
452
|
+
private drop(ws: NifraWebSocket<LiveSocket>, code: number, reason: string): void {
|
|
453
|
+
this.forget(ws)
|
|
454
|
+
ws.close(code, reason)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private forget(ws: NifraWebSocket<LiveSocket>, open = true): void {
|
|
458
|
+
if (!this.sockets.delete(ws)) return
|
|
459
|
+
for (const tag of [...ws.data.tags]) this.leave(ws, tag, open)
|
|
460
|
+
this.options.store?.removed(ws)
|
|
461
|
+
if (this.sockets.size === 0) this.stopSweep()
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private send(ws: NifraWebSocket<LiveSocket>, frame: string): void {
|
|
465
|
+
ws.send(frame)
|
|
466
|
+
if (bufferedOf(ws) > MAX_OUTBOUND_BYTES) this.drop(ws, CLOSE_CAPACITY, "backpressure")
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private topic(tag: string): string {
|
|
470
|
+
return topicOf(this.options.appId, tag)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Read on every open, never cached: a burst of opens must not all pass on one stale reading. */
|
|
474
|
+
private overRss(): boolean {
|
|
475
|
+
const cap = this.options.maxRssBytes
|
|
476
|
+
return cap !== undefined && rss() > cap
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private startSweep(): void {
|
|
480
|
+
if (this.options.store !== undefined) return
|
|
481
|
+
this.sweepTimer ??= unref(setInterval(() => this.sweep(), SWEEP_MS))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private stopSweep(): void {
|
|
485
|
+
if (this.sweepTimer === undefined) return
|
|
486
|
+
clearInterval(this.sweepTimer)
|
|
487
|
+
this.sweepTimer = undefined
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Drop the socket when its earliest grant has expired. A host whose sweep can run late (an alarm)
|
|
492
|
+
* asks before every frame it sends.
|
|
493
|
+
*/
|
|
494
|
+
expired(ws: NifraWebSocket<LiveSocket>, now: number): boolean {
|
|
495
|
+
if (ws.data.exp > now) return false
|
|
496
|
+
this.drop(ws, CLOSE_BAD_GRANT, "expired")
|
|
497
|
+
return true
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Expired grants, silent peers, sockets with nothing to watch and slow readers. Returns the earliest
|
|
502
|
+
* deadline left: a grant expiry, the silence limit, or the end of {@link FIRST_WATCH_MS}; infinite
|
|
503
|
+
* with no socket.
|
|
504
|
+
*/
|
|
505
|
+
sweep(): number {
|
|
506
|
+
const now = Date.now()
|
|
507
|
+
const store = this.options.store
|
|
508
|
+
let next = Number.POSITIVE_INFINITY
|
|
509
|
+
for (const ws of [...this.sockets]) {
|
|
510
|
+
const state = ws.data
|
|
511
|
+
const seen =
|
|
512
|
+
store === undefined ? state.lastSeen : Math.max(state.lastSeen, store.lastBeat(ws))
|
|
513
|
+
const unwatched = state.unwatchedSince
|
|
514
|
+
if (state.exp <= now) this.drop(ws, CLOSE_BAD_GRANT, "expired")
|
|
515
|
+
else if (now - seen > IDLE_MS) this.drop(ws, CLOSE_PROTOCOL, "idle")
|
|
516
|
+
else if (unwatched !== undefined && now - unwatched >= FIRST_WATCH_MS) {
|
|
517
|
+
this.drop(ws, CLOSE_UNWATCHED, "unwatched")
|
|
518
|
+
} else if (bufferedOf(ws) > MAX_OUTBOUND_BYTES) this.drop(ws, CLOSE_CAPACITY, "backpressure")
|
|
519
|
+
else {
|
|
520
|
+
const due = Number.isFinite(state.exp) ? state.exp : seen + IDLE_MS
|
|
521
|
+
next = Math.min(next, due, unwatched === undefined ? due : unwatched + FIRST_WATCH_MS)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return next
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The pub/sub topic of one tag. */
|
|
529
|
+
export const topicOf = (appId: string, tag: string): string => `nifra:live:${appId}:${tag}`
|
|
530
|
+
|
|
531
|
+
/** The wire tag of a key under one listed secret. */
|
|
532
|
+
export type TagOf = (key: string, secret: number) => Promise<string>
|
|
533
|
+
|
|
534
|
+
/** {@link Keyring.tag}, kept: a key's tag changes only with the secrets. Oldest entry out first. */
|
|
535
|
+
export function tagCache(ring: Keyring, size = TAG_CACHE_SIZE): TagOf {
|
|
536
|
+
const cache = new Map<string, Promise<string>>()
|
|
537
|
+
return (key, secret) => {
|
|
538
|
+
const name = `${secret}:${key}`
|
|
539
|
+
const cached = cache.get(name)
|
|
540
|
+
if (cached !== undefined) return cached
|
|
541
|
+
if (cache.size >= size) {
|
|
542
|
+
const oldest = cache.keys().next().value
|
|
543
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
544
|
+
}
|
|
545
|
+
const tag = ring.tag(key, secret)
|
|
546
|
+
cache.set(name, tag)
|
|
547
|
+
return tag
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parseWatch(text: string): Watch | undefined {
|
|
552
|
+
if (text.length > MAX_FRAME_BYTES || utf8Length(text) > MAX_FRAME_BYTES) return undefined
|
|
553
|
+
let value: unknown
|
|
554
|
+
try {
|
|
555
|
+
value = JSON.parse(text)
|
|
556
|
+
} catch {
|
|
557
|
+
return undefined
|
|
558
|
+
}
|
|
559
|
+
if (!isRecord(value)) return undefined
|
|
560
|
+
const { t, gen, grants } = value
|
|
561
|
+
if (t !== "watch" || typeof gen !== "number" || !Number.isSafeInteger(gen) || gen < 0)
|
|
562
|
+
return undefined
|
|
563
|
+
if (!Array.isArray(grants) || grants.length > MAX_WATCH_GRANTS) return undefined
|
|
564
|
+
const list: string[] = []
|
|
565
|
+
for (const grant of grants) {
|
|
566
|
+
if (typeof grant !== "string" || grant.length > MAX_GRANT_CHARS) return undefined
|
|
567
|
+
list.push(grant)
|
|
568
|
+
}
|
|
569
|
+
return { gen, grants: list }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
573
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
574
|
+
|
|
575
|
+
function utf8Length(text: string): number {
|
|
576
|
+
let bytes = 0
|
|
577
|
+
for (let i = 0; i < text.length; i++) {
|
|
578
|
+
const code = text.charCodeAt(i)
|
|
579
|
+
// A surrogate pair is 4 bytes, counted as 2 + 2.
|
|
580
|
+
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code >= 0xd800 && code < 0xe000 ? 2 : 3
|
|
581
|
+
}
|
|
582
|
+
return bytes
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function randomSid(): string {
|
|
586
|
+
let sid = ""
|
|
587
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(16)))
|
|
588
|
+
sid += byte.toString(16).padStart(2, "0")
|
|
589
|
+
return sid
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Bytes queued for a socket: Bun's `getBufferedAmount()`, or a standard socket's `bufferedAmount`. */
|
|
593
|
+
function bufferedOf(ws: NifraWebSocket<LiveSocket>): number {
|
|
594
|
+
const raw = ws.raw
|
|
595
|
+
if (typeof raw !== "object" || raw === null) return 0
|
|
596
|
+
if ("getBufferedAmount" in raw && typeof raw.getBufferedAmount === "function") {
|
|
597
|
+
const amount: unknown = raw.getBufferedAmount()
|
|
598
|
+
return typeof amount === "number" ? amount : 0
|
|
599
|
+
}
|
|
600
|
+
if ("bufferedAmount" in raw && typeof raw.bufferedAmount === "number") return raw.bufferedAmount
|
|
601
|
+
return 0
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function rss(): number {
|
|
605
|
+
const usage = typeof process === "undefined" ? undefined : process.memoryUsage
|
|
606
|
+
return typeof usage?.rss === "function" ? usage.rss() : 0
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function unref<T>(timer: T): T {
|
|
610
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) {
|
|
611
|
+
const release = timer.unref
|
|
612
|
+
if (typeof release === "function") release.call(timer)
|
|
613
|
+
}
|
|
614
|
+
return timer
|
|
615
|
+
}
|