@nifrajs/web 5.5.0 → 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 +162 -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
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
import { markRepeatedPrefix } from "@nifrajs/core/response-observer"
|
|
2
|
+
import type {
|
|
3
|
+
BoundaryError,
|
|
4
|
+
BoundaryRequestCtx,
|
|
5
|
+
BoundaryState,
|
|
6
|
+
BoundaryStates,
|
|
7
|
+
SharedHoleCtx,
|
|
8
|
+
} from "../../boundary.ts"
|
|
9
|
+
import { type CspPolicy, cspHeaderWithSources, prepareCspPolicy } from "../../csp.ts"
|
|
10
|
+
import { defer } from "../../deferred.ts"
|
|
11
|
+
import type { RenderAdapter } from "../../render-seam.ts"
|
|
12
|
+
import { HOLE_ERROR_HTML } from "../hole-runtime.ts"
|
|
13
|
+
import { isControlFlow, isStatusSignal, notFound } from "../render-document.ts"
|
|
14
|
+
import { HoleAdmission, type HoleRun, type HoleSlot, watchAbort } from "./admission.ts"
|
|
15
|
+
import type { ShellCheckRecord } from "./compile-shell.ts"
|
|
16
|
+
import {
|
|
17
|
+
type HoleRegistration,
|
|
18
|
+
type PprRequest,
|
|
19
|
+
type PprServer,
|
|
20
|
+
PprShellBusy,
|
|
21
|
+
PprShellCold,
|
|
22
|
+
PprShellUnchecked,
|
|
23
|
+
type SharedHole,
|
|
24
|
+
serverHoleComponent,
|
|
25
|
+
variationQuery,
|
|
26
|
+
} from "./contract.ts"
|
|
27
|
+
import {
|
|
28
|
+
assertHoleName,
|
|
29
|
+
encodeHoleFrame,
|
|
30
|
+
frameBodyOf,
|
|
31
|
+
holeFallbackInserts,
|
|
32
|
+
requestScriptAround,
|
|
33
|
+
validateHoleHtml,
|
|
34
|
+
} from "./frames.ts"
|
|
35
|
+
import { type ResolvedPprOptions, totalDeadlineMs } from "./options.ts"
|
|
36
|
+
import { PPR_BOOT, PPR_RUNTIME, pprScriptHashes } from "./runtime.ts"
|
|
37
|
+
import type { SharedShellStore, StoredEntry } from "./shell-store.ts"
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
type DynamicHole,
|
|
41
|
+
type HoleRegistration,
|
|
42
|
+
type HoleStatesMode,
|
|
43
|
+
PPR_CANCEL_PATH,
|
|
44
|
+
PPR_SERVER,
|
|
45
|
+
type PprRequest,
|
|
46
|
+
type PprServer,
|
|
47
|
+
type PprServerStats,
|
|
48
|
+
PprShellBusy,
|
|
49
|
+
PprShellCold,
|
|
50
|
+
PprShellUnavailable,
|
|
51
|
+
PprShellUnchecked,
|
|
52
|
+
type SharedHole,
|
|
53
|
+
variationQuery,
|
|
54
|
+
} from "./contract.ts"
|
|
55
|
+
|
|
56
|
+
/** The redacted state a blocking render shows for a hole that did not load, as `boundary.ts` does. */
|
|
57
|
+
const HOLE_ERROR_STATE: BoundaryError = { name: "Error", message: "Boundary failed" }
|
|
58
|
+
|
|
59
|
+
const DOCUMENT_END = "</body></html>"
|
|
60
|
+
|
|
61
|
+
const MODULE_SCRIPT_OPEN = '<script type="module" src="'
|
|
62
|
+
const MODULE_SCRIPT_CLOSE = '"></script>'
|
|
63
|
+
const RUNTIME_SCRIPT = `<script>${PPR_RUNTIME}</script>`
|
|
64
|
+
const BOOT_SCRIPT = `<script>${PPR_BOOT}</script>`
|
|
65
|
+
|
|
66
|
+
const ENCODER = new TextEncoder()
|
|
67
|
+
|
|
68
|
+
/** A built shell, shared by every visitor whose request maps to its key. */
|
|
69
|
+
const NO_BYTES = new Uint8Array(0)
|
|
70
|
+
|
|
71
|
+
interface ShellEntry {
|
|
72
|
+
readonly routeId: string
|
|
73
|
+
/**
|
|
74
|
+
* The document up to (not including) `</body></html>` and the request script, encoded once with
|
|
75
|
+
* room for the token at {@link tokenAt}: each response copies it and writes its token in.
|
|
76
|
+
*/
|
|
77
|
+
readonly first: Uint8Array
|
|
78
|
+
readonly tokenAt: number
|
|
79
|
+
/** The shell loader's output, for a soft navigation's data request. */
|
|
80
|
+
readonly data: unknown
|
|
81
|
+
readonly bytes: number
|
|
82
|
+
readonly builtAt: number
|
|
83
|
+
readonly tags: readonly string[]
|
|
84
|
+
/** The shell loader's `notFound()`, rethrown for every request until the entry expires. */
|
|
85
|
+
readonly notFound?: unknown
|
|
86
|
+
/** Background rebuilds that failed in a row; the next waits `2 ** failures` seconds. */
|
|
87
|
+
failures: number
|
|
88
|
+
retryAt: number
|
|
89
|
+
/** When this process last saw it in the shared store; unset for an entry kept only here. */
|
|
90
|
+
checkedAt: number | undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A shared hole's frame, built once for every visitor whose request maps to its key. */
|
|
94
|
+
interface HoleEntry {
|
|
95
|
+
readonly routeId: string
|
|
96
|
+
/** The finished frame, encoded once. */
|
|
97
|
+
readonly frame: Uint8Array
|
|
98
|
+
/** The loader's output after its `output` projection, for a data request or a blocking render. */
|
|
99
|
+
readonly data: unknown
|
|
100
|
+
readonly bytes: number
|
|
101
|
+
readonly builtAt: number
|
|
102
|
+
readonly tags: readonly string[]
|
|
103
|
+
/** The live keys its loader declared, watched for every visitor it serves. */
|
|
104
|
+
readonly keys: readonly string[]
|
|
105
|
+
failures: number
|
|
106
|
+
retryAt: number
|
|
107
|
+
checkedAt: number | undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type Entry = ShellEntry | HoleEntry
|
|
111
|
+
|
|
112
|
+
export interface PprServerConfig {
|
|
113
|
+
readonly options: ResolvedPprOptions
|
|
114
|
+
readonly adapter: RenderAdapter
|
|
115
|
+
readonly csp?: CspPolicy
|
|
116
|
+
/** Part of every shell key, so a shell built by one deployment is never served by another. */
|
|
117
|
+
readonly deployment: string
|
|
118
|
+
/** The client entry `shellChecks` was recorded for; `deployment` when not given. */
|
|
119
|
+
readonly clientEntry?: string
|
|
120
|
+
readonly cancelUrl: string
|
|
121
|
+
/**
|
|
122
|
+
* The routes whose shell import check passed, for this deployment. A route not listed, or a record
|
|
123
|
+
* made for another deployment, gets no shared shell (fail closed). `"unchecked-development"` waives
|
|
124
|
+
* the check and is refused in production.
|
|
125
|
+
*/
|
|
126
|
+
readonly shellChecks: ShellCheckRecord | "unchecked-development"
|
|
127
|
+
/** Milliseconds, for shell freshness. Tests pass their own clock. */
|
|
128
|
+
readonly now?: () => number
|
|
129
|
+
/** Shells shared with other processes, behind this process's own; signed entries only. */
|
|
130
|
+
readonly shared?: SharedShellStore | undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One hole of a response, started: exactly one of `hit`, `entry` and `run` is set. */
|
|
134
|
+
type HoleStart =
|
|
135
|
+
| {
|
|
136
|
+
readonly hole: HoleRegistration
|
|
137
|
+
/** A shared hole's entry held here when the hole started: its frame goes out with the shell. */
|
|
138
|
+
readonly hit: HoleEntry
|
|
139
|
+
readonly entry?: undefined
|
|
140
|
+
readonly run?: undefined
|
|
141
|
+
}
|
|
142
|
+
| {
|
|
143
|
+
readonly hole: HoleRegistration
|
|
144
|
+
/** A shared hole's entry, being read or built; aborted on the visitor's deadline. */
|
|
145
|
+
readonly entry: Promise<HoleRun<HoleEntry>>
|
|
146
|
+
readonly hit?: undefined
|
|
147
|
+
readonly run?: undefined
|
|
148
|
+
}
|
|
149
|
+
| {
|
|
150
|
+
readonly hole: HoleRegistration
|
|
151
|
+
/** The hole's loader, run for this visitor. */
|
|
152
|
+
readonly run: Promise<HoleRun<unknown>>
|
|
153
|
+
readonly hit?: undefined
|
|
154
|
+
readonly entry?: undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One streamed response's own queue of frames its client has not read yet. */
|
|
158
|
+
interface StreamState {
|
|
159
|
+
readonly queue: Uint8Array[]
|
|
160
|
+
queued: number
|
|
161
|
+
overflowed: boolean
|
|
162
|
+
ending: boolean
|
|
163
|
+
dead: boolean
|
|
164
|
+
/** The reader is waiting on a read the queue could not fill: the next frame goes straight to it. */
|
|
165
|
+
wanted: boolean
|
|
166
|
+
kill(reason: unknown): void
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pendingStates = (holes: readonly HoleRegistration[]): BoundaryStates => {
|
|
170
|
+
const states: Record<string, BoundaryState & { readonly ppr: true }> = {}
|
|
171
|
+
for (const hole of holes)
|
|
172
|
+
states[hole.name] = { name: hole.name, mode: hole.mode, status: "pending", ppr: true }
|
|
173
|
+
return states
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The pathname, not the matched params, keys the shell: two spellings of one path (percent-encoding)
|
|
177
|
+
// match the same params but render different `path`s. The per-route cap bounds the spellings.
|
|
178
|
+
const shellKey = (deployment: string, request: PprRequest): string =>
|
|
179
|
+
`ppr:v1|${deployment}|${request.routeId}|${request.locale ?? ""}|${request.path}|${variationQuery(request.variations)}`
|
|
180
|
+
|
|
181
|
+
const isProduction = (): boolean =>
|
|
182
|
+
typeof process !== "undefined" && process.env?.NODE_ENV === "production"
|
|
183
|
+
|
|
184
|
+
const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
|
|
185
|
+
let length = 0
|
|
186
|
+
for (const chunk of chunks) length += chunk.length
|
|
187
|
+
const joined = new Uint8Array(length)
|
|
188
|
+
let at = 0
|
|
189
|
+
for (const chunk of chunks) {
|
|
190
|
+
joined.set(chunk, at)
|
|
191
|
+
at += chunk.length
|
|
192
|
+
}
|
|
193
|
+
return joined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
197
|
+
/** A token is 16 random bytes in unpadded base64url: always this many ASCII characters. */
|
|
198
|
+
const TOKEN_LENGTH = 22
|
|
199
|
+
const tokenBytes = new Uint8Array(16)
|
|
200
|
+
/** Where a shell's bytes hold each response's token. */
|
|
201
|
+
const TOKEN_ROOM = "A".repeat(TOKEN_LENGTH)
|
|
202
|
+
|
|
203
|
+
const randomToken = (): string => {
|
|
204
|
+
crypto.getRandomValues(tokenBytes)
|
|
205
|
+
let token = ""
|
|
206
|
+
for (let at = 0; at < 15; at += 3) {
|
|
207
|
+
const n =
|
|
208
|
+
((tokenBytes[at] ?? 0) << 16) | ((tokenBytes[at + 1] ?? 0) << 8) | (tokenBytes[at + 2] ?? 0)
|
|
209
|
+
token +=
|
|
210
|
+
BASE64URL.charAt(n >> 18) +
|
|
211
|
+
BASE64URL.charAt((n >> 12) & 63) +
|
|
212
|
+
BASE64URL.charAt((n >> 6) & 63) +
|
|
213
|
+
BASE64URL.charAt(n & 63)
|
|
214
|
+
}
|
|
215
|
+
const last = tokenBytes[15] ?? 0
|
|
216
|
+
return token + BASE64URL.charAt(last >> 2) + BASE64URL.charAt((last & 3) << 4)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const frozenParts = new WeakMap<
|
|
220
|
+
Omit<BoundaryRequestCtx, "signal" | "live">,
|
|
221
|
+
Pick<BoundaryRequestCtx, "params" | "search">
|
|
222
|
+
>()
|
|
223
|
+
|
|
224
|
+
/** `ctx.live` outside a live load: keys are declared for nobody. */
|
|
225
|
+
const NO_LIVE = (): void => {}
|
|
226
|
+
|
|
227
|
+
/** The server component a dynamic hole shows, if it shows one. */
|
|
228
|
+
const componentOf = (hole: HoleRegistration): object | undefined =>
|
|
229
|
+
hole.mode === "dynamic" ? serverHoleComponent(hole.render) : undefined
|
|
230
|
+
|
|
231
|
+
/** A frozen context per invocation, so no hole changes what a sibling sees. The frozen params and
|
|
232
|
+
* search are copied once per request and shared. */
|
|
233
|
+
const holeCtx = (
|
|
234
|
+
context: Omit<BoundaryRequestCtx, "signal" | "live">,
|
|
235
|
+
signal: AbortSignal,
|
|
236
|
+
live: (...keys: string[]) => void,
|
|
237
|
+
): BoundaryRequestCtx => {
|
|
238
|
+
let parts = frozenParts.get(context)
|
|
239
|
+
if (parts === undefined) {
|
|
240
|
+
parts = {
|
|
241
|
+
params: Object.freeze({ ...context.params }),
|
|
242
|
+
search: Object.freeze({ ...context.search }),
|
|
243
|
+
}
|
|
244
|
+
frozenParts.set(context, parts)
|
|
245
|
+
}
|
|
246
|
+
return Object.freeze({
|
|
247
|
+
request: context.request,
|
|
248
|
+
params: parts.params,
|
|
249
|
+
api: context.api,
|
|
250
|
+
env: context.env,
|
|
251
|
+
draft: context.draft,
|
|
252
|
+
search: parts.search,
|
|
253
|
+
signal,
|
|
254
|
+
live,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What a response's holes stop for: the response's own aborts, or a request's signal on the data path. */
|
|
259
|
+
interface AbortSource {
|
|
260
|
+
readonly aborted: boolean
|
|
261
|
+
readonly reason: unknown
|
|
262
|
+
watch(onAbort: () => void): void
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const signalSource = (signal: AbortSignal): AbortSource => ({
|
|
266
|
+
get aborted() {
|
|
267
|
+
return signal.aborted
|
|
268
|
+
},
|
|
269
|
+
get reason() {
|
|
270
|
+
return signal.reason
|
|
271
|
+
},
|
|
272
|
+
watch(onAbort) {
|
|
273
|
+
watchAbort(signal, onAbort)
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
interface HoleDeadline {
|
|
278
|
+
readonly signal: AbortSignal
|
|
279
|
+
/** Calls `onAbort` once the signal aborts, until the returned function is called. */
|
|
280
|
+
watch(onAbort: () => void): () => void
|
|
281
|
+
/** One of the holes settled; the last one clears the timer. */
|
|
282
|
+
leave(): void
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface HoleDeadlines {
|
|
286
|
+
join(timeoutMs: number): HoleDeadline
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Holes that start together with one timeout share its signal and timer: a controller, listener and
|
|
291
|
+
* timer per hole cost more under load than the holes. Not `AbortSignal.timeout`: its timer stays
|
|
292
|
+
* pending after the holes settle. Only this code aborts the signal, so its watchers are called here
|
|
293
|
+
* and the signal gets no native listener.
|
|
294
|
+
*/
|
|
295
|
+
const holeDeadlines = (response: AbortSource): HoleDeadlines => {
|
|
296
|
+
const byTimeout = new Map<number, HoleDeadline & { joined: number }>()
|
|
297
|
+
return {
|
|
298
|
+
join(timeoutMs) {
|
|
299
|
+
let deadline = byTimeout.get(timeoutMs)
|
|
300
|
+
if (deadline === undefined) {
|
|
301
|
+
const own = new AbortController()
|
|
302
|
+
const watchers = new Set<() => void>()
|
|
303
|
+
const stop = (reason: unknown): void => {
|
|
304
|
+
if (own.signal.aborted) return
|
|
305
|
+
own.abort(reason)
|
|
306
|
+
for (const watcher of watchers) watcher()
|
|
307
|
+
}
|
|
308
|
+
if (response.aborted) own.abort(response.reason)
|
|
309
|
+
else response.watch(() => stop(response.reason))
|
|
310
|
+
const timer = setTimeout(
|
|
311
|
+
() => stop(new DOMException("The operation timed out.", "TimeoutError")),
|
|
312
|
+
timeoutMs,
|
|
313
|
+
)
|
|
314
|
+
const created = {
|
|
315
|
+
signal: own.signal,
|
|
316
|
+
joined: 0,
|
|
317
|
+
watch(onAbort: () => void): () => void {
|
|
318
|
+
watchers.add(onAbort)
|
|
319
|
+
return () => {
|
|
320
|
+
watchers.delete(onAbort)
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
leave() {
|
|
324
|
+
if (--created.joined === 0) clearTimeout(timer)
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
deadline = created
|
|
328
|
+
byTimeout.set(timeoutMs, created)
|
|
329
|
+
}
|
|
330
|
+
deadline.joined++
|
|
331
|
+
return deadline
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Error frames are a few hundred bytes whatever the hole, so no frame limit applies to them.
|
|
337
|
+
const errorFrame = (name: string): string =>
|
|
338
|
+
encodeHoleFrame(name, { error: true, html: HOLE_ERROR_HTML }, Number.POSITIVE_INFINITY)
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* WebKit paints a loading document only once it holds about this many characters of text, so a
|
|
342
|
+
* sparser shell shows nothing in Safari until its slowest hole ends (measured in WebKit 2026-10: a
|
|
343
|
+
* 171-character paragraph stayed blank, 228 painted at once). Fewer than this never paints early.
|
|
344
|
+
*/
|
|
345
|
+
const WEBKIT_PAINT_CHARS = 200
|
|
346
|
+
|
|
347
|
+
const SKIPPED_ELEMENTS: ReadonlySet<string> = new Set(["script", "style", "template", "noscript"])
|
|
348
|
+
|
|
349
|
+
/** Characters of text in `html` with whitespace collapsed, outside tags, comments and scripts.
|
|
350
|
+
* An `indexOf` scan, not a lazy regex, so an unclosed opener stays linear. */
|
|
351
|
+
const textLength = (html: string): number => {
|
|
352
|
+
let count = 0
|
|
353
|
+
let blank = true
|
|
354
|
+
let at = 0
|
|
355
|
+
while (at < html.length) {
|
|
356
|
+
const open = html.indexOf("<", at)
|
|
357
|
+
const end = open === -1 ? html.length : open
|
|
358
|
+
for (let i = at; i < end; i++) {
|
|
359
|
+
const code = html.charCodeAt(i)
|
|
360
|
+
const space = code === 32 || (code >= 9 && code <= 13)
|
|
361
|
+
if (!space || !blank) count++
|
|
362
|
+
blank = space
|
|
363
|
+
}
|
|
364
|
+
if (open === -1) break
|
|
365
|
+
if (html.startsWith("<!--", open)) {
|
|
366
|
+
const close = html.indexOf("-->", open + 4)
|
|
367
|
+
at = close === -1 ? html.length : close + 3
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
const close = html.indexOf(">", open)
|
|
371
|
+
if (close === -1) break
|
|
372
|
+
at = close + 1
|
|
373
|
+
const name = /^<([a-zA-Z]+)/.exec(html.slice(open, open + 12))?.[1]?.toLowerCase()
|
|
374
|
+
if (name !== undefined && SKIPPED_ELEMENTS.has(name)) {
|
|
375
|
+
const endTag = html.indexOf(`</${name}`, at)
|
|
376
|
+
at = endTag === -1 ? html.length : endTag
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return count
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const SHED: HoleRun<never> = Object.freeze({ ok: false, reason: "denied" })
|
|
383
|
+
|
|
384
|
+
// Keys a full store has seen fewer than `shellAfterRequests` times, forgotten together once this many
|
|
385
|
+
// wait, so a crawl of unique paths holds a bounded map.
|
|
386
|
+
const MAX_SIGHTINGS = 10_000
|
|
387
|
+
|
|
388
|
+
/** What an entry counts against the store's byte budget beside its key, document and data. */
|
|
389
|
+
const ENTRY_BYTES = 256
|
|
390
|
+
|
|
391
|
+
// Roughly how JSC and V8 lay out a loader's output: a header per object and per string, a slot per
|
|
392
|
+
// element or property, a byte per character (a two-byte string holds twice that).
|
|
393
|
+
const OBJECT_BYTES = 32
|
|
394
|
+
const STRING_BYTES = 24
|
|
395
|
+
const SLOT_BYTES = 8
|
|
396
|
+
// Deeper than any document nests, so only a cycle reaches it.
|
|
397
|
+
const MAX_DATA_DEPTH = 256
|
|
398
|
+
|
|
399
|
+
const isHolder = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
400
|
+
typeof value === "object" && value !== null
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* About how many heap bytes a shell loader's output holds. The count stops once it passes `room`, so
|
|
404
|
+
* an output too large for the store costs no more than the store's budget to measure.
|
|
405
|
+
*/
|
|
406
|
+
function heldBytes(value: unknown, room: number, depth = 0): number {
|
|
407
|
+
if (typeof value === "string") return STRING_BYTES + value.length
|
|
408
|
+
if (!isHolder(value)) return SLOT_BYTES
|
|
409
|
+
if (depth === MAX_DATA_DEPTH) return Number.POSITIVE_INFINITY
|
|
410
|
+
let bytes = OBJECT_BYTES
|
|
411
|
+
if (Array.isArray(value)) {
|
|
412
|
+
for (let i = 0; i < value.length && bytes <= room; i++)
|
|
413
|
+
bytes += SLOT_BYTES + heldBytes(value[i], room - bytes, depth + 1)
|
|
414
|
+
return bytes
|
|
415
|
+
}
|
|
416
|
+
for (const key in value) {
|
|
417
|
+
bytes += 2 * SLOT_BYTES + heldBytes(value[key], room - bytes, depth + 1)
|
|
418
|
+
if (bytes > room) break
|
|
419
|
+
}
|
|
420
|
+
return bytes
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const notFoundSignal = (): unknown => {
|
|
424
|
+
try {
|
|
425
|
+
return notFound()
|
|
426
|
+
} catch (signal) {
|
|
427
|
+
return signal
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const DECODER = new TextDecoder()
|
|
432
|
+
|
|
433
|
+
// How long a shared store keeps a shell that has no `revalidate`: a year, as good as forever.
|
|
434
|
+
const KEEP_UNREVALIDATED_SECONDS = 365 * 24 * 60 * 60
|
|
435
|
+
|
|
436
|
+
// Taken when this module is evaluated, before any request: a build run through it sees no visitor's
|
|
437
|
+
// async context, even in an app created while a request was being handled.
|
|
438
|
+
const detached: <T>(fn: () => T) => T =
|
|
439
|
+
globalThis.process?.getBuiltinModule?.("node:async_hooks")?.AsyncLocalStorage?.snapshot?.() ??
|
|
440
|
+
((fn) => fn())
|
|
441
|
+
|
|
442
|
+
/** A 404 entry answers as the shell loader's own `notFound()` did. */
|
|
443
|
+
const served = (entry: ShellEntry): ShellEntry | Promise<never> =>
|
|
444
|
+
entry.notFound === undefined ? entry : Promise.reject(entry.notFound)
|
|
445
|
+
|
|
446
|
+
interface BuildDeadline {
|
|
447
|
+
readonly signal: AbortSignal
|
|
448
|
+
/**
|
|
449
|
+
* `work`, or the deadline's reason once it passes first: a loader that ignores its signal leaves no
|
|
450
|
+
* request waiting on it, and its late result reaches no one.
|
|
451
|
+
*/
|
|
452
|
+
bound<T>(work: Promise<T>): Promise<T>
|
|
453
|
+
clear(): void
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A build's deadline. One timer aborts the signal and fails the bound work, so a build registers no
|
|
458
|
+
* listener. Not `AbortSignal.timeout`: under a run of misses its timers would stay pending long after
|
|
459
|
+
* the builds settle.
|
|
460
|
+
*/
|
|
461
|
+
const buildDeadline = (ms: number): BuildDeadline => {
|
|
462
|
+
const controller = new AbortController()
|
|
463
|
+
let expire: (reason: unknown) => void = () => {}
|
|
464
|
+
const timer = setTimeout(() => {
|
|
465
|
+
const reason = new DOMException("The operation timed out.", "TimeoutError")
|
|
466
|
+
controller.abort(reason)
|
|
467
|
+
expire(reason)
|
|
468
|
+
}, ms)
|
|
469
|
+
return {
|
|
470
|
+
signal: controller.signal,
|
|
471
|
+
bound: (work) =>
|
|
472
|
+
new Promise((resolve, reject) => {
|
|
473
|
+
expire = reject
|
|
474
|
+
work.then(resolve, reject)
|
|
475
|
+
}),
|
|
476
|
+
clear: () => clearTimeout(timer),
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `read`, or a miss once `ms` pass: a shared store that stalls is answered by a build, not a wait. */
|
|
481
|
+
const missAfter = <T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> => {
|
|
482
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
483
|
+
const late = new Promise<undefined>((resolve) => {
|
|
484
|
+
timer = setTimeout(resolve, ms, undefined)
|
|
485
|
+
})
|
|
486
|
+
return Promise.race([read, late]).finally(() => clearTimeout(timer))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function createPprServer(config: PprServerConfig): PprServer {
|
|
490
|
+
const { options, adapter, deployment, shellChecks } = config
|
|
491
|
+
// With the beacon off, the request script names no address, so the runtime sends none.
|
|
492
|
+
const cancelUrl = options.cancelBeacon ? config.cancelUrl : ""
|
|
493
|
+
const renderFragment = adapter.renderFragment
|
|
494
|
+
if (renderFragment === undefined) {
|
|
495
|
+
throw new TypeError("[nifra/web] ppr: the render adapter does not implement renderFragment")
|
|
496
|
+
}
|
|
497
|
+
if (shellChecks === "unchecked-development" && isProduction()) {
|
|
498
|
+
throw new TypeError("[nifra/web] ppr: production needs the build's shell check records")
|
|
499
|
+
}
|
|
500
|
+
const ownRecord = (): boolean =>
|
|
501
|
+
shellChecks !== "unchecked-development" &&
|
|
502
|
+
shellChecks.deployment === (config.clientEntry ?? deployment)
|
|
503
|
+
const checked = (routeId: string): boolean =>
|
|
504
|
+
shellChecks === "unchecked-development" ||
|
|
505
|
+
(ownRecord() && Object.hasOwn(shellChecks.routes, routeId))
|
|
506
|
+
/** Whether the build checked this shared hole's loader with its route's shell. */
|
|
507
|
+
const checkedShared = (routeId: string, name: string): boolean =>
|
|
508
|
+
shellChecks === "unchecked-development" ||
|
|
509
|
+
(ownRecord() &&
|
|
510
|
+
Object.hasOwn(shellChecks.routes, routeId) &&
|
|
511
|
+
shellChecks.sharedHoles?.[routeId]?.includes(name) === true)
|
|
512
|
+
const now = config.now ?? Date.now
|
|
513
|
+
const shared = config.shared
|
|
514
|
+
// One shared-store read per key at a time; concurrent misses wait on it.
|
|
515
|
+
const shellLookups = new Map<string, Promise<ShellEntry | undefined>>()
|
|
516
|
+
const holeLookups = new Map<string, Promise<HoleEntry | undefined>>()
|
|
517
|
+
const untaggableWarned = new Set<string>()
|
|
518
|
+
const uncheckedHoleWarned = new Set<string>()
|
|
519
|
+
// Shells and shared hole frames, under one byte budget and one entry cap per route.
|
|
520
|
+
const settled = new Map<string, Entry>()
|
|
521
|
+
const sightings = new Map<string, number>()
|
|
522
|
+
const routeCounts = new Map<string, number>()
|
|
523
|
+
let storedBytes = 0
|
|
524
|
+
const builds = new Map<string, Promise<ShellEntry>>()
|
|
525
|
+
const holeBuilds = new Map<string, Promise<HoleEntry>>()
|
|
526
|
+
// Shell and shared hole builds, which share the route's limit.
|
|
527
|
+
const buildsByRoute = new Map<string, number>()
|
|
528
|
+
// A build records the count when it starts and is not stored if one of its tags was invalidated
|
|
529
|
+
// since. Only running builds read the map, so it empties whenever none run.
|
|
530
|
+
let invalidations = 0
|
|
531
|
+
const invalidatedAt = new Map<string, number>()
|
|
532
|
+
const live = new Map<string, (reason: unknown) => void>()
|
|
533
|
+
const cspHeaders = new Map<string, Promise<string | undefined>>()
|
|
534
|
+
const thinWarned = new Set<string>()
|
|
535
|
+
const oversizeWarned = new Set<string>()
|
|
536
|
+
const emptyWarned = new Map<string, Set<string>>()
|
|
537
|
+
const fallbackHtml = new WeakMap<HoleRegistration, Promise<string>>()
|
|
538
|
+
// A route's request script, with room for the token, serialized once per set of hole names.
|
|
539
|
+
const requestScripts = new Map<
|
|
540
|
+
string,
|
|
541
|
+
{ readonly text: string; readonly bytes: number; readonly tokenAt: number }
|
|
542
|
+
>()
|
|
543
|
+
const requestScriptOf = (holes: readonly HoleRegistration[]) => {
|
|
544
|
+
const names = holes.map((hole) => hole.name)
|
|
545
|
+
const sharedNames = holes.filter((hole) => hole.mode === "shared").map((hole) => hole.name)
|
|
546
|
+
const key = `${names.join(",")}|${sharedNames.join(",")}`
|
|
547
|
+
let script = requestScripts.get(key)
|
|
548
|
+
if (script === undefined) {
|
|
549
|
+
const [before, after] = requestScriptAround(cancelUrl, names, sharedNames)
|
|
550
|
+
const text = before + TOKEN_ROOM + after
|
|
551
|
+
script = { text, bytes: ENCODER.encode(text).length, tokenAt: ENCODER.encode(before).length }
|
|
552
|
+
requestScripts.set(key, script)
|
|
553
|
+
}
|
|
554
|
+
return script
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A fallback comes from its registration alone and every shell holds it, so it renders once.
|
|
558
|
+
const fallbackOf = (hole: HoleRegistration): Promise<string> => {
|
|
559
|
+
let html = fallbackHtml.get(hole)
|
|
560
|
+
if (html === undefined) {
|
|
561
|
+
const fallback = hole.fallback
|
|
562
|
+
html =
|
|
563
|
+
fallback === undefined
|
|
564
|
+
? Promise.resolve("")
|
|
565
|
+
: Promise.resolve().then(() => renderFragment.call(adapter, () => fallback, {}))
|
|
566
|
+
fallbackHtml.set(hole, html)
|
|
567
|
+
html.catch(() => fallbackHtml.delete(hole))
|
|
568
|
+
}
|
|
569
|
+
return html
|
|
570
|
+
}
|
|
571
|
+
const admission = new HoleAdmission(
|
|
572
|
+
options,
|
|
573
|
+
isProduction()
|
|
574
|
+
? undefined
|
|
575
|
+
: (key, ms) =>
|
|
576
|
+
console.warn(
|
|
577
|
+
`[nifra/web] ppr: boundary "${key}" settled ${Math.round(ms)} ms after its abort; pass ctx.signal to the work its loader starts`,
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
const streams = new Set<StreamState>()
|
|
581
|
+
let unsentBytes = 0
|
|
582
|
+
let slowAborted = 0
|
|
583
|
+
|
|
584
|
+
const evict = (key: string): void => {
|
|
585
|
+
const entry = settled.get(key)
|
|
586
|
+
if (entry === undefined) return
|
|
587
|
+
settled.delete(key)
|
|
588
|
+
storedBytes -= entry.bytes
|
|
589
|
+
routeCounts.set(entry.routeId, (routeCounts.get(entry.routeId) ?? 1) - 1)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const store = (key: string, entry: Entry): void => {
|
|
593
|
+
evict(key)
|
|
594
|
+
settled.set(key, entry)
|
|
595
|
+
storedBytes += entry.bytes
|
|
596
|
+
routeCounts.set(entry.routeId, (routeCounts.get(entry.routeId) ?? 0) + 1)
|
|
597
|
+
// Map order is insertion order, refreshed on every hit, so iteration meets the least recent first.
|
|
598
|
+
// Over the byte budget anything older goes; over the route's entry cap, that route's oldest.
|
|
599
|
+
for (const [oldKey, old] of settled) {
|
|
600
|
+
const overBytes = storedBytes > options.shellStoreBytes
|
|
601
|
+
const overRoute = (routeCounts.get(entry.routeId) ?? 0) > options.shellEntriesPerRoute
|
|
602
|
+
if (!overBytes && !overRoute) break
|
|
603
|
+
if (oldKey !== key && (overBytes || old.routeId === entry.routeId)) evict(oldKey)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Whether a held entry is due a read of the shared store: another process may have purged it there,
|
|
609
|
+
* and reading it again is how that purge reaches this one.
|
|
610
|
+
*/
|
|
611
|
+
const recheckDue = (entry: Entry): boolean =>
|
|
612
|
+
entry.checkedAt !== undefined && now() - entry.checkedAt >= options.sharedRecheckMs
|
|
613
|
+
|
|
614
|
+
const finishShell = async (
|
|
615
|
+
key: string,
|
|
616
|
+
html: string,
|
|
617
|
+
request: PprRequest,
|
|
618
|
+
data: unknown,
|
|
619
|
+
): Promise<ShellEntry> => {
|
|
620
|
+
// A component that copied the nonce into the shell would hand it to every visitor.
|
|
621
|
+
if (html.includes(" nonce=")) {
|
|
622
|
+
throw new Error(`[nifra/web] ppr: route "${request.routeId}" rendered a nonce into its shell`)
|
|
623
|
+
}
|
|
624
|
+
if (!html.endsWith(DOCUMENT_END)) {
|
|
625
|
+
throw new Error(`[nifra/web] ppr: route "${request.routeId}" shell does not end its document`)
|
|
626
|
+
}
|
|
627
|
+
const headEnd = html.indexOf("</head>")
|
|
628
|
+
if (headEnd === -1)
|
|
629
|
+
throw new Error(`[nifra/web] ppr: route "${request.routeId}" shell has no head`)
|
|
630
|
+
const bodyEnd = html.length - DOCUMENT_END.length
|
|
631
|
+
// Every change to the rendered document, made in one pass that copies it once.
|
|
632
|
+
const edits: Array<{ readonly from: number; readonly to: number; readonly html: string }> = [
|
|
633
|
+
{ from: headEnd, to: headEnd, html: RUNTIME_SCRIPT },
|
|
634
|
+
]
|
|
635
|
+
// The client entry must not wait for the end of the document, which is the slowest hole.
|
|
636
|
+
for (
|
|
637
|
+
let at = html.indexOf(MODULE_SCRIPT_OPEN);
|
|
638
|
+
at !== -1 && at < bodyEnd;
|
|
639
|
+
at = html.indexOf(MODULE_SCRIPT_OPEN, at + 1)
|
|
640
|
+
) {
|
|
641
|
+
const srcEnd = html.indexOf('"', at + MODULE_SCRIPT_OPEN.length)
|
|
642
|
+
const end = srcEnd + MODULE_SCRIPT_CLOSE.length
|
|
643
|
+
if (srcEnd === -1 || !html.startsWith(MODULE_SCRIPT_CLOSE, srcEnd)) continue
|
|
644
|
+
if (at < headEnd && end > headEnd) continue
|
|
645
|
+
const src = html.slice(at + MODULE_SCRIPT_OPEN.length, srcEnd)
|
|
646
|
+
edits.push({
|
|
647
|
+
from: at,
|
|
648
|
+
to: end,
|
|
649
|
+
html: `<link rel="modulepreload" href="${src}">${BOOT_SCRIPT}`,
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
const fallbacks = new Map<string, string>()
|
|
653
|
+
for (const hole of request.holes) {
|
|
654
|
+
const fallback = await fallbackOf(hole)
|
|
655
|
+
fallbacks.set(hole.name, fallback)
|
|
656
|
+
if (fallback.trim() !== "" || isProduction()) continue
|
|
657
|
+
const warned = emptyWarned.get(request.routeId) ?? new Set<string>()
|
|
658
|
+
if (warned.has(hole.name)) continue
|
|
659
|
+
emptyWarned.set(request.routeId, warned.add(hole.name))
|
|
660
|
+
console.warn(
|
|
661
|
+
`[nifra/web] ppr: hole "${hole.name}" of "${request.routeId}" has an empty fallback, so a browser without JavaScript shows nothing in its place and the page moves when it fills`,
|
|
662
|
+
)
|
|
663
|
+
}
|
|
664
|
+
for (const insert of holeFallbackInserts(html, fallbacks))
|
|
665
|
+
edits.push({ from: insert.at, to: insert.at, html: insert.html })
|
|
666
|
+
edits.sort((a, b) => a.from - b.from)
|
|
667
|
+
let prefix = ""
|
|
668
|
+
let from = 0
|
|
669
|
+
for (const edit of edits) {
|
|
670
|
+
prefix += html.slice(from, edit.from) + edit.html
|
|
671
|
+
from = edit.to
|
|
672
|
+
}
|
|
673
|
+
prefix += html.slice(from, bodyEnd)
|
|
674
|
+
if (!isProduction() && !thinWarned.has(request.routeId)) {
|
|
675
|
+
const text = textLength(prefix.slice(Math.max(prefix.indexOf("<body"), 0)))
|
|
676
|
+
if (text < WEBKIT_PAINT_CHARS) {
|
|
677
|
+
thinWarned.add(request.routeId)
|
|
678
|
+
console.warn(
|
|
679
|
+
`[nifra/web] ppr: the shell of "${request.routeId}" has ${text} characters of text. Safari paints a loading document only once it holds about ${WEBKIT_PAINT_CHARS}, so it shows this page only when its slowest hole ends`,
|
|
680
|
+
)
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const script = requestScriptOf(request.holes)
|
|
684
|
+
// One encode: the request script's bytes are known, so the prefix's are the rest.
|
|
685
|
+
let first = ENCODER.encode(prefix + script.text)
|
|
686
|
+
const short = options.minShellBytes - (first.length - script.bytes)
|
|
687
|
+
if (short > 0) first = ENCODER.encode(`${prefix}<!--${" ".repeat(short)}-->${script.text}`)
|
|
688
|
+
const bytes = ENTRY_BYTES + key.length + first.length
|
|
689
|
+
return {
|
|
690
|
+
routeId: request.routeId,
|
|
691
|
+
first,
|
|
692
|
+
tokenAt: first.length - script.bytes + script.tokenAt,
|
|
693
|
+
data,
|
|
694
|
+
bytes: bytes + heldBytes(data, options.shellStoreBytes - bytes),
|
|
695
|
+
builtAt: now(),
|
|
696
|
+
tags: request.tags(),
|
|
697
|
+
failures: 0,
|
|
698
|
+
retryAt: 0,
|
|
699
|
+
checkedAt: undefined,
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The shared store's copy of an entry, unless its tags could never be dropped there. True when the
|
|
705
|
+
* write started, so the entry is one a purge on another process can reach.
|
|
706
|
+
*/
|
|
707
|
+
const share = (
|
|
708
|
+
key: string,
|
|
709
|
+
stored: StoredEntry,
|
|
710
|
+
keepSeconds: number,
|
|
711
|
+
request: PprRequest,
|
|
712
|
+
fence: Promise<number | undefined>,
|
|
713
|
+
): boolean => {
|
|
714
|
+
if (shared === undefined) return false
|
|
715
|
+
if (stored.tags.length > 0 && shared.invalidateTag === undefined) {
|
|
716
|
+
if (!untaggableWarned.has(stored.routeId)) {
|
|
717
|
+
untaggableWarned.add(stored.routeId)
|
|
718
|
+
console.warn(
|
|
719
|
+
`[nifra/web] ppr: route "${stored.routeId}" lists revalidateTags, which the shared store cannot drop, so its shells and shared holes stay in this process`,
|
|
720
|
+
)
|
|
721
|
+
}
|
|
722
|
+
return false
|
|
723
|
+
}
|
|
724
|
+
const write = fence
|
|
725
|
+
.then((at) => shared.set(key, stored, keepSeconds, at))
|
|
726
|
+
.catch((error: unknown) =>
|
|
727
|
+
console.error("[nifra/web] ppr: writing to the shared store failed", {
|
|
728
|
+
route: stored.routeId,
|
|
729
|
+
kind: error instanceof Error ? "error" : typeof error,
|
|
730
|
+
}),
|
|
731
|
+
)
|
|
732
|
+
request.waitUntil?.(write)
|
|
733
|
+
return true
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const shareShell = (
|
|
737
|
+
key: string,
|
|
738
|
+
entry: ShellEntry,
|
|
739
|
+
request: PprRequest,
|
|
740
|
+
fence: Promise<number | undefined>,
|
|
741
|
+
): boolean =>
|
|
742
|
+
share(
|
|
743
|
+
key,
|
|
744
|
+
{
|
|
745
|
+
routeId: entry.routeId,
|
|
746
|
+
html: DECODER.decode(entry.first),
|
|
747
|
+
tokenAt: entry.tokenAt,
|
|
748
|
+
data: entry.data,
|
|
749
|
+
builtAt: entry.builtAt,
|
|
750
|
+
tags: entry.tags,
|
|
751
|
+
notFound: entry.notFound !== undefined,
|
|
752
|
+
},
|
|
753
|
+
entry.notFound !== undefined
|
|
754
|
+
? options.notFoundTtlSeconds
|
|
755
|
+
: request.revalidate === undefined
|
|
756
|
+
? KEEP_UNREVALIDATED_SECONDS
|
|
757
|
+
: staleLimitMs(request.revalidate) / 1000,
|
|
758
|
+
request,
|
|
759
|
+
fence,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
/** A shell read back from the shared store, as this process keeps it. */
|
|
763
|
+
const entryOf = (key: string, stored: StoredEntry): ShellEntry => {
|
|
764
|
+
const first = stored.notFound ? NO_BYTES : ENCODER.encode(stored.html)
|
|
765
|
+
const bytes = ENTRY_BYTES + key.length + first.length
|
|
766
|
+
return {
|
|
767
|
+
routeId: stored.routeId,
|
|
768
|
+
first,
|
|
769
|
+
tokenAt: stored.tokenAt,
|
|
770
|
+
data: stored.data,
|
|
771
|
+
bytes: bytes + heldBytes(stored.data, options.shellStoreBytes - bytes),
|
|
772
|
+
builtAt: stored.builtAt,
|
|
773
|
+
tags: stored.tags,
|
|
774
|
+
notFound: stored.notFound ? notFoundSignal() : undefined,
|
|
775
|
+
failures: 0,
|
|
776
|
+
retryAt: 0,
|
|
777
|
+
checkedAt: now(),
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const leaveBuild = (routeId: string): void => {
|
|
782
|
+
const running = (buildsByRoute.get(routeId) ?? 1) - 1
|
|
783
|
+
if (running === 0) buildsByRoute.delete(routeId)
|
|
784
|
+
else buildsByRoute.set(routeId, running)
|
|
785
|
+
if (builds.size === 0 && holeBuilds.size === 0) invalidatedAt.clear()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const startBuild = (key: string, request: PprRequest): Promise<ShellEntry> => {
|
|
789
|
+
const routeId = request.routeId
|
|
790
|
+
buildsByRoute.set(routeId, (buildsByRoute.get(routeId) ?? 0) + 1)
|
|
791
|
+
const startedAt = invalidations
|
|
792
|
+
// Read before the build starts, so a purge the shared store sees during it keeps it out.
|
|
793
|
+
const fence: Promise<number | undefined> =
|
|
794
|
+
shared === undefined ? Promise.resolve(undefined) : shared.fence().catch(() => undefined)
|
|
795
|
+
const deadline = buildDeadline(options.maxHoleTimeoutMs)
|
|
796
|
+
const loading = detached(() =>
|
|
797
|
+
request.loadShell(deadline.signal).then(
|
|
798
|
+
async (data) =>
|
|
799
|
+
finishShell(
|
|
800
|
+
key,
|
|
801
|
+
await request.renderShellDocument(data, pendingStates(request.holes)),
|
|
802
|
+
request,
|
|
803
|
+
data,
|
|
804
|
+
),
|
|
805
|
+
(error: unknown): ShellEntry => {
|
|
806
|
+
// A missing record is cached briefly, so a flood of unknown ids runs no loader twice.
|
|
807
|
+
if (!isStatusSignal(error) || error.status !== 404) throw error
|
|
808
|
+
return {
|
|
809
|
+
routeId,
|
|
810
|
+
first: NO_BYTES,
|
|
811
|
+
tokenAt: 0,
|
|
812
|
+
data: undefined,
|
|
813
|
+
bytes: ENTRY_BYTES + key.length,
|
|
814
|
+
builtAt: now(),
|
|
815
|
+
tags: request.tags(),
|
|
816
|
+
notFound: error,
|
|
817
|
+
failures: 0,
|
|
818
|
+
retryAt: 0,
|
|
819
|
+
checkedAt: undefined,
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
),
|
|
823
|
+
)
|
|
824
|
+
const building = deadline.bound(loading)
|
|
825
|
+
builds.set(key, building)
|
|
826
|
+
// The route's build is held until the loader itself ends: one that ignores its signal past the
|
|
827
|
+
// deadline still counts, so it cannot leave room for unbounded replacements.
|
|
828
|
+
loading.then(
|
|
829
|
+
() => leaveBuild(routeId),
|
|
830
|
+
() => leaveBuild(routeId),
|
|
831
|
+
)
|
|
832
|
+
const done = (): void => {
|
|
833
|
+
deadline.clear()
|
|
834
|
+
if (builds.get(key) === building) builds.delete(key)
|
|
835
|
+
}
|
|
836
|
+
building.then((entry) => {
|
|
837
|
+
const fenced = entry.tags.some((tag) => (invalidatedAt.get(tag) ?? -1) > startedAt)
|
|
838
|
+
done()
|
|
839
|
+
if (fenced) return
|
|
840
|
+
if (shareShell(key, entry, request, fence)) entry.checkedAt = now()
|
|
841
|
+
if (entry.bytes <= options.shellStoreBytes) return store(key, entry)
|
|
842
|
+
// Kept, it would empty the store and still overrun it; a stale entry it replaces goes too.
|
|
843
|
+
evict(key)
|
|
844
|
+
if (!isProduction() && !oversizeWarned.has(routeId)) {
|
|
845
|
+
oversizeWarned.add(routeId)
|
|
846
|
+
console.warn(
|
|
847
|
+
`[nifra/web] ppr: a shell of "${routeId}" holds more than shellStoreBytes with its loader data, so it is built for every request`,
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
}, done)
|
|
851
|
+
return building
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Behind a stale shell: one rebuild per key, within the route's build limit, backing off on failure. */
|
|
855
|
+
const rebuild = (key: string, request: PprRequest, stale: ShellEntry): void => {
|
|
856
|
+
if (builds.has(key) || now() < stale.retryAt) return
|
|
857
|
+
if ((buildsByRoute.get(request.routeId) ?? 0) >= options.shellBuildConcurrency) return
|
|
858
|
+
const building = startBuild(key, request)
|
|
859
|
+
// No visitor waits on it, so a platform that stops work at the response's end must be told.
|
|
860
|
+
request.waitUntil?.(building.catch(() => undefined))
|
|
861
|
+
building.catch((error: unknown) => {
|
|
862
|
+
stale.failures++
|
|
863
|
+
stale.retryAt = now() + Math.min(2 ** stale.failures, request.revalidate ?? 1) * 1000
|
|
864
|
+
console.error("[nifra/web] ppr: shell rebuild failed; serving the stale shell", {
|
|
865
|
+
route: request.routeId,
|
|
866
|
+
kind: error instanceof Error ? "error" : typeof error,
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const staleLimitMs = (revalidate: number): number =>
|
|
872
|
+
(revalidate +
|
|
873
|
+
Math.min(revalidate * options.staleOnErrorFactor, options.maxStaleOnErrorSeconds)) *
|
|
874
|
+
1000
|
|
875
|
+
|
|
876
|
+
/** Whether a new shell of the route would likely push another out: an average entry overruns. */
|
|
877
|
+
const full = (routeId: string): boolean =>
|
|
878
|
+
(settled.size > 0 && storedBytes + storedBytes / settled.size > options.shellStoreBytes) ||
|
|
879
|
+
(routeCounts.get(routeId) ?? 0) >= options.shellEntriesPerRoute
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Counts a request for a key with no shell; true once the key may build one. Only a full store
|
|
883
|
+
* counts, so with room every key builds at once, and when full a path seen once cannot evict.
|
|
884
|
+
*/
|
|
885
|
+
const due = (key: string, routeId: string): boolean => {
|
|
886
|
+
if (options.shellAfterRequests === 1 || settled.has(key) || builds.has(key)) return true
|
|
887
|
+
if (!full(routeId)) return true
|
|
888
|
+
const seen = (sightings.get(key) ?? 0) + 1
|
|
889
|
+
if (seen >= options.shellAfterRequests) {
|
|
890
|
+
sightings.delete(key)
|
|
891
|
+
return true
|
|
892
|
+
}
|
|
893
|
+
if (sightings.size >= MAX_SIGHTINGS) sightings.clear()
|
|
894
|
+
sightings.set(key, seen)
|
|
895
|
+
return false
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Serves `entry` if it is still usable for `request`, rebuilding behind a stale one. */
|
|
899
|
+
const serveEntry = (
|
|
900
|
+
key: string,
|
|
901
|
+
entry: ShellEntry,
|
|
902
|
+
request: PprRequest,
|
|
903
|
+
): Promise<ShellEntry> | undefined => {
|
|
904
|
+
const age = now() - entry.builtAt
|
|
905
|
+
if (entry.notFound !== undefined) {
|
|
906
|
+
return age < options.notFoundTtlSeconds * 1000 ? Promise.reject(entry.notFound) : undefined
|
|
907
|
+
}
|
|
908
|
+
if (request.revalidate === undefined || age < request.revalidate * 1000) {
|
|
909
|
+
return Promise.resolve(entry)
|
|
910
|
+
}
|
|
911
|
+
if (age < staleLimitMs(request.revalidate)) {
|
|
912
|
+
rebuild(key, request, entry)
|
|
913
|
+
return Promise.resolve(entry)
|
|
914
|
+
}
|
|
915
|
+
return undefined
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const build = (key: string, request: PprRequest): Promise<ShellEntry> => {
|
|
919
|
+
const running = builds.get(key)
|
|
920
|
+
if (running !== undefined) return running.then(served)
|
|
921
|
+
if ((buildsByRoute.get(request.routeId) ?? 0) >= options.shellBuildConcurrency) {
|
|
922
|
+
return Promise.reject(new PprShellBusy(request.routeId))
|
|
923
|
+
}
|
|
924
|
+
for (const hole of request.holes) assertHoleName(hole.name)
|
|
925
|
+
return startBuild(key, request).then(served)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** The shared store's shell for `key`, kept here once read; a failed or missing read builds one. */
|
|
929
|
+
const fromShared = (
|
|
930
|
+
sharedStore: SharedShellStore,
|
|
931
|
+
key: string,
|
|
932
|
+
request: PprRequest,
|
|
933
|
+
): Promise<ShellEntry> => {
|
|
934
|
+
let lookup = shellLookups.get(key)
|
|
935
|
+
if (lookup === undefined) {
|
|
936
|
+
const read = missAfter(sharedStore.get(key), options.holeTimeoutMs).then(
|
|
937
|
+
(found) => (found === undefined ? undefined : entryOf(key, found)),
|
|
938
|
+
() => undefined,
|
|
939
|
+
)
|
|
940
|
+
lookup = read
|
|
941
|
+
shellLookups.set(key, read)
|
|
942
|
+
read.then(() => shellLookups.delete(key))
|
|
943
|
+
}
|
|
944
|
+
return lookup.then((entry) => {
|
|
945
|
+
if (entry !== undefined && entry.routeId === request.routeId) {
|
|
946
|
+
const usable = serveEntry(key, entry, request)
|
|
947
|
+
if (usable !== undefined) {
|
|
948
|
+
if (!settled.has(key) && entry.bytes <= options.shellStoreBytes) store(key, entry)
|
|
949
|
+
return usable
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return build(key, request)
|
|
953
|
+
})
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const shellFor = (
|
|
957
|
+
request: PprRequest,
|
|
958
|
+
key = shellKey(deployment, request),
|
|
959
|
+
): Promise<ShellEntry> => {
|
|
960
|
+
const entry = settled.get(key)
|
|
961
|
+
if (entry !== undefined && "first" in entry) {
|
|
962
|
+
settled.delete(key)
|
|
963
|
+
settled.set(key, entry)
|
|
964
|
+
const usable = recheckDue(entry) ? undefined : serveEntry(key, entry, request)
|
|
965
|
+
if (usable !== undefined) return usable
|
|
966
|
+
evict(key)
|
|
967
|
+
}
|
|
968
|
+
const running = builds.get(key)
|
|
969
|
+
if (running !== undefined) return running.then(served)
|
|
970
|
+
if (!checked(request.routeId)) return Promise.reject(new PprShellUnchecked(request.routeId))
|
|
971
|
+
return shared === undefined ? build(key, request) : fromShared(shared, key, request)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** What a shared hole's loader gets: the shell's context, under the build's own signal. */
|
|
975
|
+
const sharedHoleCtx = (
|
|
976
|
+
request: PprRequest,
|
|
977
|
+
signal: AbortSignal,
|
|
978
|
+
live: (...keys: string[]) => void,
|
|
979
|
+
): SharedHoleCtx => Object.freeze({ ...request.shellContext, signal, live })
|
|
980
|
+
|
|
981
|
+
/** A shared hole's frame and data, rendered once for every visitor. */
|
|
982
|
+
const finishHole = async (
|
|
983
|
+
key: string,
|
|
984
|
+
request: PprRequest,
|
|
985
|
+
hole: SharedHole,
|
|
986
|
+
data: unknown,
|
|
987
|
+
keys: readonly string[],
|
|
988
|
+
): Promise<HoleEntry> => {
|
|
989
|
+
// The visitor's status and headers went out with the shell, so a hole has none to give.
|
|
990
|
+
if (isControlFlow(data) || isStatusSignal(data))
|
|
991
|
+
throw new TypeError("[nifra/web] ppr: a shared hole returned a redirect or status")
|
|
992
|
+
// The id prefix is the hole's own, never a request's, so every visitor gets the same bytes.
|
|
993
|
+
const html = await renderFragment.call(
|
|
994
|
+
adapter,
|
|
995
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: defineBoundary binds render to its own load's output; the registration erases that generic, and the value came from that load.
|
|
996
|
+
() => hole.render(data as never),
|
|
997
|
+
{ idPrefix: `nifra-${hole.name}-`, hydrates: hole.interactive === true },
|
|
998
|
+
)
|
|
999
|
+
validateHoleHtml(html)
|
|
1000
|
+
// A component that copied the nonce into the frame would hand it to every visitor.
|
|
1001
|
+
if (html.includes(" nonce=")) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`[nifra/web] ppr: shared hole "${hole.name}" of "${request.routeId}" rendered a nonce`,
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
const frame = ENCODER.encode(
|
|
1007
|
+
encodeHoleFrame(
|
|
1008
|
+
hole.name,
|
|
1009
|
+
hole.interactive === true ? { html, props: data } : { html },
|
|
1010
|
+
options.frameBytes,
|
|
1011
|
+
),
|
|
1012
|
+
)
|
|
1013
|
+
const bytes = ENTRY_BYTES + key.length + frame.length
|
|
1014
|
+
return {
|
|
1015
|
+
routeId: request.routeId,
|
|
1016
|
+
frame,
|
|
1017
|
+
data,
|
|
1018
|
+
bytes: bytes + heldBytes(data, options.shellStoreBytes - bytes),
|
|
1019
|
+
builtAt: now(),
|
|
1020
|
+
tags: request.holeTags(hole),
|
|
1021
|
+
keys,
|
|
1022
|
+
failures: 0,
|
|
1023
|
+
retryAt: 0,
|
|
1024
|
+
checkedAt: undefined,
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Build one shared hole's entry, detached from the visitor, under its own deadline, and keep it.
|
|
1030
|
+
* With `slot`, the build is hole work under admission instead of one of the route's builds.
|
|
1031
|
+
*/
|
|
1032
|
+
const startHoleBuild = (
|
|
1033
|
+
key: string,
|
|
1034
|
+
request: PprRequest,
|
|
1035
|
+
hole: SharedHole,
|
|
1036
|
+
slot: HoleSlot | undefined,
|
|
1037
|
+
): Promise<HoleEntry> => {
|
|
1038
|
+
const routeId = request.routeId
|
|
1039
|
+
if (slot === undefined) buildsByRoute.set(routeId, (buildsByRoute.get(routeId) ?? 0) + 1)
|
|
1040
|
+
const startedAt = invalidations
|
|
1041
|
+
const fence: Promise<number | undefined> =
|
|
1042
|
+
shared === undefined ? Promise.resolve(undefined) : shared.fence().catch(() => undefined)
|
|
1043
|
+
const deadline = buildDeadline(options.maxHoleTimeoutMs)
|
|
1044
|
+
const signal = deadline.signal
|
|
1045
|
+
// Keys count only when declared before the loader settles, as in a visitor's live load.
|
|
1046
|
+
const keys = new Set<string>()
|
|
1047
|
+
let declaring = true
|
|
1048
|
+
const declare = (...declared: string[]): void => {
|
|
1049
|
+
if (declaring) for (const declaredKey of declared) keys.add(String(declaredKey))
|
|
1050
|
+
}
|
|
1051
|
+
const load = (): Promise<HoleEntry> =>
|
|
1052
|
+
detached(() =>
|
|
1053
|
+
Promise.resolve()
|
|
1054
|
+
.then(() => hole.load?.(sharedHoleCtx(request, signal, declare)))
|
|
1055
|
+
.finally(() => {
|
|
1056
|
+
declaring = false
|
|
1057
|
+
})
|
|
1058
|
+
.then((data) => finishHole(key, request, hole, data, [...keys])),
|
|
1059
|
+
)
|
|
1060
|
+
const loading =
|
|
1061
|
+
slot === undefined
|
|
1062
|
+
? load()
|
|
1063
|
+
: slot
|
|
1064
|
+
.run(`${routeId}|${hole.name}`, signal, load)
|
|
1065
|
+
.then((run) =>
|
|
1066
|
+
run.ok ? run.value : Promise.reject(run.error ?? new PprShellBusy(routeId)),
|
|
1067
|
+
)
|
|
1068
|
+
const building = deadline.bound(loading)
|
|
1069
|
+
holeBuilds.set(key, building)
|
|
1070
|
+
// As for a shell: the route's build is held until the loader itself ends.
|
|
1071
|
+
const leave = (): void => {
|
|
1072
|
+
if (slot === undefined) leaveBuild(routeId)
|
|
1073
|
+
else if (builds.size === 0 && holeBuilds.size === 0) invalidatedAt.clear()
|
|
1074
|
+
}
|
|
1075
|
+
loading.then(leave, leave)
|
|
1076
|
+
const done = (): void => {
|
|
1077
|
+
deadline.clear()
|
|
1078
|
+
if (holeBuilds.get(key) === building) holeBuilds.delete(key)
|
|
1079
|
+
}
|
|
1080
|
+
building.then(
|
|
1081
|
+
(entry) => {
|
|
1082
|
+
const fenced = entry.tags.some((tag) => (invalidatedAt.get(tag) ?? -1) > startedAt)
|
|
1083
|
+
done()
|
|
1084
|
+
if (fenced) return
|
|
1085
|
+
const written = share(
|
|
1086
|
+
key,
|
|
1087
|
+
{
|
|
1088
|
+
routeId,
|
|
1089
|
+
html: DECODER.decode(entry.frame),
|
|
1090
|
+
tokenAt: 0,
|
|
1091
|
+
data: entry.data,
|
|
1092
|
+
builtAt: entry.builtAt,
|
|
1093
|
+
tags: entry.tags,
|
|
1094
|
+
keys: entry.keys,
|
|
1095
|
+
notFound: false,
|
|
1096
|
+
},
|
|
1097
|
+
staleLimitMs(hole.revalidate) / 1000,
|
|
1098
|
+
request,
|
|
1099
|
+
fence,
|
|
1100
|
+
)
|
|
1101
|
+
if (written) entry.checkedAt = now()
|
|
1102
|
+
if (entry.bytes <= options.shellStoreBytes) store(key, entry)
|
|
1103
|
+
else evict(key)
|
|
1104
|
+
},
|
|
1105
|
+
(error: unknown) => {
|
|
1106
|
+
done()
|
|
1107
|
+
console.error("[nifra/web] ppr: shared hole build failed", {
|
|
1108
|
+
route: routeId,
|
|
1109
|
+
hole: hole.name,
|
|
1110
|
+
kind: error instanceof Error ? "error" : typeof error,
|
|
1111
|
+
})
|
|
1112
|
+
},
|
|
1113
|
+
)
|
|
1114
|
+
return building
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* A shared hole's build. Over the route's build limit it still runs, as hole work under admission:
|
|
1119
|
+
* it reads no request, so storing it is as safe as any build, and the next visitor hits it instead
|
|
1120
|
+
* of calling a slow upstream again.
|
|
1121
|
+
*/
|
|
1122
|
+
const buildHole = (key: string, request: PprRequest, hole: SharedHole): Promise<HoleEntry> => {
|
|
1123
|
+
const running = holeBuilds.get(key)
|
|
1124
|
+
if (running !== undefined) return running
|
|
1125
|
+
if ((buildsByRoute.get(request.routeId) ?? 0) < options.shellBuildConcurrency)
|
|
1126
|
+
return startHoleBuild(key, request, hole, undefined)
|
|
1127
|
+
const slot = admission.slot()
|
|
1128
|
+
if (slot === undefined) return Promise.reject(new PprShellBusy(request.routeId))
|
|
1129
|
+
const building = startHoleBuild(key, request, hole, slot)
|
|
1130
|
+
slot.seal()
|
|
1131
|
+
return building
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** `entry` if it is still usable, rebuilding behind a stale one within the route's build limit. */
|
|
1135
|
+
const usableHole = (
|
|
1136
|
+
key: string,
|
|
1137
|
+
entry: HoleEntry,
|
|
1138
|
+
request: PprRequest,
|
|
1139
|
+
hole: SharedHole,
|
|
1140
|
+
): HoleEntry | undefined => {
|
|
1141
|
+
const age = now() - entry.builtAt
|
|
1142
|
+
if (age < hole.revalidate * 1000) return entry
|
|
1143
|
+
if (age >= staleLimitMs(hole.revalidate)) return undefined
|
|
1144
|
+
const routeId = request.routeId
|
|
1145
|
+
if (
|
|
1146
|
+
!holeBuilds.has(key) &&
|
|
1147
|
+
now() >= entry.retryAt &&
|
|
1148
|
+
(buildsByRoute.get(routeId) ?? 0) < options.shellBuildConcurrency
|
|
1149
|
+
) {
|
|
1150
|
+
const building = startHoleBuild(key, request, hole, undefined)
|
|
1151
|
+
// No visitor waits on it, so a platform that stops work at the response's end must be told.
|
|
1152
|
+
request.waitUntil?.(building.catch(() => undefined))
|
|
1153
|
+
building.catch(() => {
|
|
1154
|
+
entry.failures++
|
|
1155
|
+
entry.retryAt = now() + Math.min(2 ** entry.failures, hole.revalidate) * 1000
|
|
1156
|
+
})
|
|
1157
|
+
}
|
|
1158
|
+
return entry
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** The shared hole's entry this process holds, if it is usable now: a hit costs no promise. */
|
|
1162
|
+
const heldHole = (key: string, request: PprRequest, hole: SharedHole): HoleEntry | undefined => {
|
|
1163
|
+
const entry = settled.get(key)
|
|
1164
|
+
if (entry === undefined || "first" in entry) return undefined
|
|
1165
|
+
settled.delete(key)
|
|
1166
|
+
settled.set(key, entry)
|
|
1167
|
+
const usable = recheckDue(entry) ? undefined : usableHole(key, entry, request, hole)
|
|
1168
|
+
if (usable === undefined) evict(key)
|
|
1169
|
+
return usable
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** The shared hole's entry: being built, in the shared store, or built now. */
|
|
1173
|
+
const holeEntry = (key: string, request: PprRequest, hole: SharedHole): Promise<HoleEntry> => {
|
|
1174
|
+
const running = holeBuilds.get(key)
|
|
1175
|
+
if (running !== undefined) return running
|
|
1176
|
+
if (shared === undefined) return buildHole(key, request, hole)
|
|
1177
|
+
let lookup = holeLookups.get(key)
|
|
1178
|
+
if (lookup === undefined) {
|
|
1179
|
+
const read = missAfter(shared.get(key), options.holeTimeoutMs).then(
|
|
1180
|
+
(found): HoleEntry | undefined => {
|
|
1181
|
+
if (found === undefined || found.notFound) return undefined
|
|
1182
|
+
const frame = ENCODER.encode(found.html)
|
|
1183
|
+
const bytes = ENTRY_BYTES + key.length + frame.length
|
|
1184
|
+
return {
|
|
1185
|
+
routeId: found.routeId,
|
|
1186
|
+
frame,
|
|
1187
|
+
data: found.data,
|
|
1188
|
+
bytes: bytes + heldBytes(found.data, options.shellStoreBytes - bytes),
|
|
1189
|
+
builtAt: found.builtAt,
|
|
1190
|
+
tags: found.tags,
|
|
1191
|
+
keys: found.keys ?? [],
|
|
1192
|
+
failures: 0,
|
|
1193
|
+
retryAt: 0,
|
|
1194
|
+
checkedAt: now(),
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
() => undefined,
|
|
1198
|
+
)
|
|
1199
|
+
lookup = read
|
|
1200
|
+
holeLookups.set(key, read)
|
|
1201
|
+
read.then(() => holeLookups.delete(key))
|
|
1202
|
+
}
|
|
1203
|
+
return lookup.then((entry) => {
|
|
1204
|
+
if (entry !== undefined && entry.routeId === request.routeId) {
|
|
1205
|
+
const usable = usableHole(key, entry, request, hole)
|
|
1206
|
+
if (usable !== undefined) {
|
|
1207
|
+
if (!settled.has(key) && entry.bytes <= options.shellStoreBytes) store(key, entry)
|
|
1208
|
+
return usable
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return buildHole(key, request, hole)
|
|
1212
|
+
})
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* The key of `hole`'s shared entry for `request`, or `undefined` when this request must not read
|
|
1217
|
+
* or fill one: a draft, a hole the build did not check, or a key a full store has seen too rarely.
|
|
1218
|
+
*/
|
|
1219
|
+
const sharedKey = (request: PprRequest, hole: SharedHole): string | undefined => {
|
|
1220
|
+
if (request.holeContext.draft) return undefined
|
|
1221
|
+
if (!checkedShared(request.routeId, hole.name)) {
|
|
1222
|
+
const warning = `${request.routeId}|${hole.name}`
|
|
1223
|
+
if (checked(request.routeId) && !isProduction() && !uncheckedHoleWarned.has(warning)) {
|
|
1224
|
+
uncheckedHoleWarned.add(warning)
|
|
1225
|
+
console.warn(
|
|
1226
|
+
`[nifra/web] ppr: shared hole "${hole.name}" of "${request.routeId}" has no passing build check, so it loads for every visitor. Write its boundaryLoaders entry inline, with revalidate`,
|
|
1227
|
+
)
|
|
1228
|
+
}
|
|
1229
|
+
return undefined
|
|
1230
|
+
}
|
|
1231
|
+
const key = `ppr-hole:v1|${deployment}|${request.routeId}|${hole.name}|${request.locale ?? ""}|${variationQuery(request.holeContext.params)}|${variationQuery(request.variations)}`
|
|
1232
|
+
return due(key, request.routeId) ? key : undefined
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Wait for a shared hole's entry until the hole's deadline; the build itself goes on. */
|
|
1236
|
+
const awaitHole = (
|
|
1237
|
+
key: string,
|
|
1238
|
+
request: PprRequest,
|
|
1239
|
+
hole: SharedHole,
|
|
1240
|
+
deadlines: HoleDeadlines,
|
|
1241
|
+
): Promise<HoleRun<HoleEntry>> => {
|
|
1242
|
+
const deadline = deadlines.join(hole.timeout ?? options.holeTimeoutMs)
|
|
1243
|
+
return new Promise((resolve) => {
|
|
1244
|
+
let unwatch = (): void => {}
|
|
1245
|
+
let answered = false
|
|
1246
|
+
const answer = (run: HoleRun<HoleEntry>): void => {
|
|
1247
|
+
if (answered) return
|
|
1248
|
+
answered = true
|
|
1249
|
+
unwatch()
|
|
1250
|
+
deadline.leave()
|
|
1251
|
+
resolve(run)
|
|
1252
|
+
}
|
|
1253
|
+
const aborted = (): void =>
|
|
1254
|
+
answer({ ok: false, reason: "aborted", error: deadline.signal.reason })
|
|
1255
|
+
if (deadline.signal.aborted) return aborted()
|
|
1256
|
+
unwatch = deadline.watch(aborted)
|
|
1257
|
+
holeEntry(key, request, hole).then(
|
|
1258
|
+
(value) => answer({ ok: true, value }),
|
|
1259
|
+
(error: unknown) => answer({ ok: false, reason: "failed", error }),
|
|
1260
|
+
)
|
|
1261
|
+
})
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// The log carries the failure's kind only: a thrown message can hold credentials or user data.
|
|
1265
|
+
const logFailure = (request: PprRequest, hole: HoleRegistration, error: unknown): void =>
|
|
1266
|
+
console.error("[nifra/web] ppr: hole failed", {
|
|
1267
|
+
route: request.routeId,
|
|
1268
|
+
hole: hole.name,
|
|
1269
|
+
kind: error instanceof Error ? "error" : typeof error,
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
/** Invoke one hole loader under admission, with its timeout's deadline under the response. */
|
|
1273
|
+
const runHole = (
|
|
1274
|
+
slot: HoleSlot | undefined,
|
|
1275
|
+
request: PprRequest,
|
|
1276
|
+
hole: HoleRegistration,
|
|
1277
|
+
deadlines: HoleDeadlines,
|
|
1278
|
+
): Promise<HoleRun<unknown>> => {
|
|
1279
|
+
if (hole.load === undefined) return Promise.resolve({ ok: true, value: undefined })
|
|
1280
|
+
if (slot === undefined) return Promise.resolve(SHED)
|
|
1281
|
+
const deadline = deadlines.join(hole.timeout ?? options.holeTimeoutMs)
|
|
1282
|
+
const signal = deadline.signal
|
|
1283
|
+
const live = request.live?.collector(hole.name) ?? NO_LIVE
|
|
1284
|
+
const component = componentOf(hole)
|
|
1285
|
+
// A shared hole that is not stored for this request still loads as it is built: no request.
|
|
1286
|
+
const invoke =
|
|
1287
|
+
hole.mode === "shared"
|
|
1288
|
+
? () => detached(() => hole.load?.(sharedHoleCtx(request, signal, live)))
|
|
1289
|
+
: component === undefined
|
|
1290
|
+
? () => hole.load?.(holeCtx(request.holeContext, signal, live))
|
|
1291
|
+
: // The component runs under the hole's own deadline and admission, after its loader.
|
|
1292
|
+
async () => {
|
|
1293
|
+
const input = await hole.load?.(holeCtx(request.holeContext, signal, live))
|
|
1294
|
+
if (isControlFlow(input) || isStatusSignal(input)) return input
|
|
1295
|
+
if (request.component === undefined) {
|
|
1296
|
+
throw new TypeError(
|
|
1297
|
+
`[nifra/web] ppr: hole "${hole.name}" shows a server component that the app's serverComponents does not list under holes for "${request.routeId}"`,
|
|
1298
|
+
)
|
|
1299
|
+
}
|
|
1300
|
+
return request.component(hole.name, component, input, signal)
|
|
1301
|
+
}
|
|
1302
|
+
return slot
|
|
1303
|
+
.run(`${request.routeId}|${hole.name}`, signal, invoke, deadline.watch)
|
|
1304
|
+
.then((run) => {
|
|
1305
|
+
deadline.leave()
|
|
1306
|
+
// The shell's status and headers are already sent, so a hole has no redirect or status to give.
|
|
1307
|
+
const settled: HoleRun<unknown> =
|
|
1308
|
+
run.ok && (isControlFlow(run.value) || isStatusSignal(run.value))
|
|
1309
|
+
? { ok: false, reason: "failed", error: new TypeError("returned a redirect or status") }
|
|
1310
|
+
: run
|
|
1311
|
+
if (!settled.ok && settled.reason === "failed") logFailure(request, hole, settled.error)
|
|
1312
|
+
return settled
|
|
1313
|
+
})
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Start every hole of one response. A shared hole's held entry is a hit at once; any loader run for
|
|
1318
|
+
* this visitor needs the response's slot, and with none free, each such hole gets its error.
|
|
1319
|
+
*/
|
|
1320
|
+
const startHoles = (
|
|
1321
|
+
request: PprRequest,
|
|
1322
|
+
response: AbortSource,
|
|
1323
|
+
holes: readonly HoleRegistration[] = request.holes,
|
|
1324
|
+
): HoleStart[] => {
|
|
1325
|
+
let slot: HoleSlot | undefined
|
|
1326
|
+
let slotTaken = false
|
|
1327
|
+
let deadlines: HoleDeadlines | undefined
|
|
1328
|
+
const starts = holes.map((hole): HoleStart => {
|
|
1329
|
+
if (hole.mode === "shared") {
|
|
1330
|
+
const key = sharedKey(request, hole)
|
|
1331
|
+
if (key !== undefined) {
|
|
1332
|
+
const hit = heldHole(key, request, hole)
|
|
1333
|
+
if (hit !== undefined) return { hole, hit }
|
|
1334
|
+
deadlines ??= holeDeadlines(response)
|
|
1335
|
+
return { hole, entry: awaitHole(key, request, hole, deadlines) }
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (!slotTaken) {
|
|
1339
|
+
slotTaken = true
|
|
1340
|
+
slot = admission.slot()
|
|
1341
|
+
}
|
|
1342
|
+
deadlines ??= holeDeadlines(response)
|
|
1343
|
+
return { hole, run: runHole(slot, request, hole, deadlines) }
|
|
1344
|
+
})
|
|
1345
|
+
slot?.seal()
|
|
1346
|
+
return starts
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const frameOf = async (
|
|
1350
|
+
request: PprRequest,
|
|
1351
|
+
hole: HoleRegistration,
|
|
1352
|
+
run: HoleRun<unknown>,
|
|
1353
|
+
): Promise<string> => {
|
|
1354
|
+
if (!run.ok) return errorFrame(hole.name)
|
|
1355
|
+
try {
|
|
1356
|
+
if (componentOf(hole) !== undefined) {
|
|
1357
|
+
const value: unknown = run.value
|
|
1358
|
+
const html: unknown =
|
|
1359
|
+
typeof value === "object" && value !== null ? Reflect.get(value, "html") : null
|
|
1360
|
+
if (typeof value !== "object" || value === null || typeof html !== "string") {
|
|
1361
|
+
throw new TypeError("a server component rendered no HTML")
|
|
1362
|
+
}
|
|
1363
|
+
validateHoleHtml(html)
|
|
1364
|
+
// The frame carries the HTML once; the record the islands mount from is the rest.
|
|
1365
|
+
const component = Object.fromEntries(
|
|
1366
|
+
Object.entries(value).filter(([key]) => key !== "html"),
|
|
1367
|
+
)
|
|
1368
|
+
return encodeHoleFrame(hole.name, { html, component }, options.frameBytes)
|
|
1369
|
+
}
|
|
1370
|
+
const html = await renderFragment.call(
|
|
1371
|
+
adapter,
|
|
1372
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: defineBoundary binds render to its own load's output; the registration erases that generic, and the value came from that load.
|
|
1373
|
+
() => hole.render(run.value as never),
|
|
1374
|
+
{ idPrefix: `nifra-${hole.name}-`, hydrates: hole.interactive === true },
|
|
1375
|
+
)
|
|
1376
|
+
if (options.holeHtmlValidation === "always" || !isProduction()) validateHoleHtml(html)
|
|
1377
|
+
return encodeHoleFrame(
|
|
1378
|
+
hole.name,
|
|
1379
|
+
hole.interactive === true ? { html, props: run.value } : { html },
|
|
1380
|
+
options.frameBytes,
|
|
1381
|
+
)
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
logFailure(request, hole, error)
|
|
1384
|
+
return errorFrame(hole.name)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const cspHeader = (request: PprRequest): Promise<string | undefined> => {
|
|
1389
|
+
const policy = config.csp
|
|
1390
|
+
if (policy === undefined) return Promise.resolve(undefined)
|
|
1391
|
+
let header = cspHeaders.get(request.routeId)
|
|
1392
|
+
if (header === undefined) {
|
|
1393
|
+
header = Promise.resolve(prepareCspPolicy(policy, adapter))
|
|
1394
|
+
.then(() => pprScriptHashes(request.holes.map((hole) => hole.name)))
|
|
1395
|
+
.then((hashes) => cspHeaderWithSources(policy, adapter, hashes.join(" ")))
|
|
1396
|
+
cspHeaders.set(request.routeId, header)
|
|
1397
|
+
header.catch(() => cspHeaders.delete(request.routeId))
|
|
1398
|
+
}
|
|
1399
|
+
return header
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// A route's policy is fixed (hash sources, no nonce), so there is one entry per route at most.
|
|
1403
|
+
// `Response` copies its init headers, so no response can change a shared one.
|
|
1404
|
+
const headersByPolicy = new Map<string | undefined, Headers>()
|
|
1405
|
+
const documentHeaders = (csp: string | undefined): Headers => {
|
|
1406
|
+
let headers = headersByPolicy.get(csp)
|
|
1407
|
+
if (headers === undefined) {
|
|
1408
|
+
headers = new Headers({
|
|
1409
|
+
"content-type": "text/html; charset=utf-8",
|
|
1410
|
+
"cache-control": "private, no-store",
|
|
1411
|
+
"x-accel-buffering": "no",
|
|
1412
|
+
})
|
|
1413
|
+
if (csp !== undefined) headers.set("content-security-policy", csp)
|
|
1414
|
+
headersByPolicy.set(csp, headers)
|
|
1415
|
+
}
|
|
1416
|
+
return headers
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/** Abort the streams with the most unsent bytes until `bytes` more fit the process budget. */
|
|
1420
|
+
const makeRoom = (bytes: number): void => {
|
|
1421
|
+
while (unsentBytes + bytes > options.maxUnsentBytes) {
|
|
1422
|
+
let largest: StreamState | undefined
|
|
1423
|
+
for (const stream of streams)
|
|
1424
|
+
if (largest === undefined || stream.queued > largest.queued) largest = stream
|
|
1425
|
+
if (largest === undefined || largest.queued === 0) return
|
|
1426
|
+
slowAborted++
|
|
1427
|
+
largest.kill(new DOMException("PPR unsent byte budget", "AbortError"))
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
return {
|
|
1432
|
+
async respond(request) {
|
|
1433
|
+
const key = shellKey(deployment, request)
|
|
1434
|
+
// Before any hole starts: a key with no shell yet renders whole and runs its holes there.
|
|
1435
|
+
if (!due(key, request.routeId)) throw new PprShellCold(request.routeId)
|
|
1436
|
+
if (request.request.method === "HEAD") {
|
|
1437
|
+
const [, csp] = await Promise.all([shellFor(request, key), cspHeader(request)])
|
|
1438
|
+
return new Response(null, { status: 200, headers: documentHeaders(csp) })
|
|
1439
|
+
}
|
|
1440
|
+
const token = randomToken()
|
|
1441
|
+
// Every abort of the response (disconnect, deadline, cancel, write grace) comes through here, so
|
|
1442
|
+
// the token retires at once and the holes need no controller or native listener of their own.
|
|
1443
|
+
const watchers: Array<() => void> = []
|
|
1444
|
+
const response: { aborted: boolean; reason: unknown } & AbortSource = {
|
|
1445
|
+
aborted: false,
|
|
1446
|
+
reason: undefined,
|
|
1447
|
+
watch(onAbort: () => void): void {
|
|
1448
|
+
watchers.push(onAbort)
|
|
1449
|
+
},
|
|
1450
|
+
}
|
|
1451
|
+
const abort = (reason: unknown): void => {
|
|
1452
|
+
if (response.aborted) return
|
|
1453
|
+
response.aborted = true
|
|
1454
|
+
response.reason = reason
|
|
1455
|
+
live.delete(token)
|
|
1456
|
+
for (const watcher of watchers) watcher()
|
|
1457
|
+
}
|
|
1458
|
+
live.set(token, abort)
|
|
1459
|
+
const unwatchDisconnect = watchAbort(request.request.signal, () =>
|
|
1460
|
+
abort(request.request.signal.reason),
|
|
1461
|
+
)
|
|
1462
|
+
const deadlineMs = totalDeadlineMs(
|
|
1463
|
+
options,
|
|
1464
|
+
request.holes.map((hole) => hole.timeout ?? options.holeTimeoutMs),
|
|
1465
|
+
)
|
|
1466
|
+
// One timer: the total deadline, then the write grace, which runs from the deadline or from the
|
|
1467
|
+
// stream's start, whichever is later.
|
|
1468
|
+
let killStream: ((reason: unknown) => void) | undefined
|
|
1469
|
+
let graceSpent = false
|
|
1470
|
+
const graceOver = (): void => {
|
|
1471
|
+
graceSpent = true
|
|
1472
|
+
killStream?.(new DOMException("PPR write grace", "TimeoutError"))
|
|
1473
|
+
}
|
|
1474
|
+
let timer = setTimeout(() => {
|
|
1475
|
+
abort(new DOMException("PPR total deadline", "TimeoutError"))
|
|
1476
|
+
timer = setTimeout(graceOver, options.writeGraceMs)
|
|
1477
|
+
}, deadlineMs)
|
|
1478
|
+
const cleanup = (): void => {
|
|
1479
|
+
clearTimeout(timer)
|
|
1480
|
+
live.delete(token)
|
|
1481
|
+
unwatchDisconnect()
|
|
1482
|
+
}
|
|
1483
|
+
// A cached shell is known at once, so holes start only once it is known to be a page; on a
|
|
1484
|
+
// miss they start beside the build, which is where starting early pays. The shell's build is
|
|
1485
|
+
// claimed first: a shared hole's build must not take the route's last one from it.
|
|
1486
|
+
const cached = settled.has(key)
|
|
1487
|
+
const shellAndCsp = Promise.all([shellFor(request, key), cspHeader(request)])
|
|
1488
|
+
let runs = cached ? undefined : startHoles(request, response)
|
|
1489
|
+
let shell: ShellEntry
|
|
1490
|
+
let csp: string | undefined
|
|
1491
|
+
try {
|
|
1492
|
+
;[shell, csp] = await shellAndCsp
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
abort(error)
|
|
1495
|
+
cleanup()
|
|
1496
|
+
throw error
|
|
1497
|
+
}
|
|
1498
|
+
runs ??= startHoles(request, response)
|
|
1499
|
+
|
|
1500
|
+
let sink: ReadableStreamDefaultController<Uint8Array> | undefined
|
|
1501
|
+
const state: StreamState = {
|
|
1502
|
+
queue: [],
|
|
1503
|
+
queued: 0,
|
|
1504
|
+
overflowed: false,
|
|
1505
|
+
ending: false,
|
|
1506
|
+
dead: false,
|
|
1507
|
+
wanted: false,
|
|
1508
|
+
kill(reason) {
|
|
1509
|
+
if (state.dead) return
|
|
1510
|
+
state.dead = true
|
|
1511
|
+
unsentBytes -= state.queued
|
|
1512
|
+
state.queued = 0
|
|
1513
|
+
state.queue.length = 0
|
|
1514
|
+
streams.delete(state)
|
|
1515
|
+
abort(reason)
|
|
1516
|
+
cleanup()
|
|
1517
|
+
try {
|
|
1518
|
+
sink?.error(reason)
|
|
1519
|
+
} catch {
|
|
1520
|
+
// Already closed or cancelled by the reader: nothing left to error.
|
|
1521
|
+
}
|
|
1522
|
+
},
|
|
1523
|
+
}
|
|
1524
|
+
// Everything queued goes out as one chunk: one write per read, not one per frame.
|
|
1525
|
+
const send = (stream: ReadableStreamDefaultController<Uint8Array>): void => {
|
|
1526
|
+
const chunk = state.queue.length > 1 ? concat(state.queue) : state.queue[0]
|
|
1527
|
+
state.queue.length = 0
|
|
1528
|
+
if (chunk !== undefined) {
|
|
1529
|
+
state.queued -= chunk.length
|
|
1530
|
+
unsentBytes -= chunk.length
|
|
1531
|
+
stream.enqueue(chunk)
|
|
1532
|
+
}
|
|
1533
|
+
if (state.ending) {
|
|
1534
|
+
state.dead = true
|
|
1535
|
+
streams.delete(state)
|
|
1536
|
+
stream.close()
|
|
1537
|
+
cleanup()
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
const wake = (): void => {
|
|
1541
|
+
if (!state.wanted || sink === undefined) return
|
|
1542
|
+
state.wanted = false
|
|
1543
|
+
send(sink)
|
|
1544
|
+
}
|
|
1545
|
+
const push = (name: string, frame: string | Uint8Array): void => {
|
|
1546
|
+
if (state.dead) return
|
|
1547
|
+
// A shared frame's bytes serve every visitor, and a reader may write into a chunk.
|
|
1548
|
+
let bytes = typeof frame === "string" ? ENCODER.encode(frame) : frame.slice()
|
|
1549
|
+
// Past the per-response cap every later hole sends its error frame, a few hundred bytes each.
|
|
1550
|
+
if (state.overflowed || state.queued + bytes.length > options.maxUnsentBytesPerResponse) {
|
|
1551
|
+
state.overflowed = true
|
|
1552
|
+
bytes = ENCODER.encode(errorFrame(name))
|
|
1553
|
+
}
|
|
1554
|
+
makeRoom(bytes.length)
|
|
1555
|
+
if (state.dead) return
|
|
1556
|
+
state.queue.push(bytes)
|
|
1557
|
+
state.queued += bytes.length
|
|
1558
|
+
unsentBytes += bytes.length
|
|
1559
|
+
wake()
|
|
1560
|
+
}
|
|
1561
|
+
streams.add(state)
|
|
1562
|
+
killStream = state.kill
|
|
1563
|
+
if (graceSpent) timer = setTimeout(graceOver, options.writeGraceMs)
|
|
1564
|
+
const pushEnd = (): void => {
|
|
1565
|
+
if (state.dead) return
|
|
1566
|
+
const end = ENCODER.encode(DOCUMENT_END)
|
|
1567
|
+
state.queue.push(end)
|
|
1568
|
+
state.queued += end.length
|
|
1569
|
+
unsentBytes += end.length
|
|
1570
|
+
state.ending = true
|
|
1571
|
+
wake()
|
|
1572
|
+
}
|
|
1573
|
+
// The end is queued in the same turn as the last frame, so the two go out as one write.
|
|
1574
|
+
let unframed = runs.length
|
|
1575
|
+
// Shared frames this process holds go out with the shell, in its one chunk.
|
|
1576
|
+
const hits: Uint8Array[] = []
|
|
1577
|
+
for (const start of runs) {
|
|
1578
|
+
if (start.hit !== undefined) {
|
|
1579
|
+
hits.push(start.hit.frame)
|
|
1580
|
+
unframed--
|
|
1581
|
+
continue
|
|
1582
|
+
}
|
|
1583
|
+
const { hole } = start
|
|
1584
|
+
const frame: Promise<string | Uint8Array> =
|
|
1585
|
+
start.entry !== undefined
|
|
1586
|
+
? start.entry.then((outcome) =>
|
|
1587
|
+
outcome.ok ? outcome.value.frame : errorFrame(hole.name),
|
|
1588
|
+
)
|
|
1589
|
+
: start.run.then((outcome) => frameOf(request, hole, outcome))
|
|
1590
|
+
frame.then((framed) => {
|
|
1591
|
+
push(hole.name, framed)
|
|
1592
|
+
if (--unframed === 0) pushEnd()
|
|
1593
|
+
})
|
|
1594
|
+
}
|
|
1595
|
+
if (unframed === 0) pushEnd()
|
|
1596
|
+
|
|
1597
|
+
const body = new ReadableStream<Uint8Array>(
|
|
1598
|
+
{
|
|
1599
|
+
start(stream) {
|
|
1600
|
+
sink = stream
|
|
1601
|
+
// A copy: a reader may write into a chunk, and the shell's bytes serve every visitor.
|
|
1602
|
+
const first = hits.length === 0 ? shell.first.slice() : concat([shell.first, ...hits])
|
|
1603
|
+
for (let at = 0; at < TOKEN_LENGTH; at++)
|
|
1604
|
+
first[shell.tokenAt + at] = token.charCodeAt(at)
|
|
1605
|
+
// Every visitor gets the same bytes up to the token, so compression() compresses them once.
|
|
1606
|
+
markRepeatedPrefix(first, shell.first, shell.tokenAt)
|
|
1607
|
+
stream.enqueue(first)
|
|
1608
|
+
},
|
|
1609
|
+
// Called only for a read the stream's queue cannot fill, so unread frames stay counted in
|
|
1610
|
+
// `state.queue`. Synchronous: an async pull and a promise per frame cost more than the frame.
|
|
1611
|
+
pull(stream) {
|
|
1612
|
+
if (state.dead) return
|
|
1613
|
+
if (state.queue.length === 0 && !state.ending) state.wanted = true
|
|
1614
|
+
else send(stream)
|
|
1615
|
+
},
|
|
1616
|
+
cancel(reason) {
|
|
1617
|
+
state.kill(reason)
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
{ highWaterMark: 0 },
|
|
1621
|
+
)
|
|
1622
|
+
return new Response(body, { status: 200, headers: documentHeaders(csp) })
|
|
1623
|
+
},
|
|
1624
|
+
|
|
1625
|
+
async shellData(request) {
|
|
1626
|
+
const key = shellKey(deployment, request)
|
|
1627
|
+
if (!due(key, request.routeId)) throw new PprShellCold(request.routeId)
|
|
1628
|
+
return (await shellFor(request, key)).data
|
|
1629
|
+
},
|
|
1630
|
+
|
|
1631
|
+
async holeStates(request, mode, signal) {
|
|
1632
|
+
const { live, kept } = request
|
|
1633
|
+
// A host the client holds reloads as a fill. A server component's hole is always a host: no
|
|
1634
|
+
// browser can render it.
|
|
1635
|
+
const fills = (hole: HoleRegistration): boolean =>
|
|
1636
|
+
(mode !== "omitted" && componentOf(hole) !== undefined) ||
|
|
1637
|
+
(mode === "deferred" && request.fill?.has(hole.name) === true)
|
|
1638
|
+
const keptStates: Record<string, BoundaryState> = {}
|
|
1639
|
+
const holes =
|
|
1640
|
+
kept === undefined || kept.size === 0
|
|
1641
|
+
? request.holes
|
|
1642
|
+
: request.holes.filter((hole) => {
|
|
1643
|
+
if (!kept.has(hole.name)) return true
|
|
1644
|
+
keptStates[hole.name] = fills(hole)
|
|
1645
|
+
? { name: hole.name, mode: hole.mode, status: "pending", ppr: true, kept: true }
|
|
1646
|
+
: { name: hole.name, mode: hole.mode, status: "pending", kept: true }
|
|
1647
|
+
return false
|
|
1648
|
+
})
|
|
1649
|
+
if (mode === "omitted") {
|
|
1650
|
+
live?.done([])
|
|
1651
|
+
return { ...pendingStates(holes), ...keptStates }
|
|
1652
|
+
}
|
|
1653
|
+
const loaded: string[] = []
|
|
1654
|
+
const settles: Promise<void>[] = []
|
|
1655
|
+
/** A hole that loaded owns its keys in the live load; one that did not has no owner. */
|
|
1656
|
+
const settle = (name: string, ok: boolean, keys: readonly string[] = []): void => {
|
|
1657
|
+
if (live === undefined) return
|
|
1658
|
+
try {
|
|
1659
|
+
if (ok && keys.length > 0) live.collector(name)(...keys)
|
|
1660
|
+
if (ok) loaded.push(name)
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
// A stored entry's keys over the load's budget: the hole loads but is not watched.
|
|
1663
|
+
console.error("[nifra/web] ppr: a shared hole's live keys were refused", {
|
|
1664
|
+
route: request.routeId,
|
|
1665
|
+
hole: name,
|
|
1666
|
+
reason: error instanceof Error ? error.message : typeof error,
|
|
1667
|
+
})
|
|
1668
|
+
}
|
|
1669
|
+
live.seal(name)
|
|
1670
|
+
}
|
|
1671
|
+
const entries = await Promise.all(
|
|
1672
|
+
startHoles(request, signalSource(signal), holes).map(
|
|
1673
|
+
async (start): Promise<[string, BoundaryState]> => {
|
|
1674
|
+
const { hole } = start
|
|
1675
|
+
const base: BoundaryState = { name: hole.name, mode: hole.mode, status: "pending" }
|
|
1676
|
+
if (hole.errorId !== undefined) Object.assign(base, { errorId: hole.errorId })
|
|
1677
|
+
// A held shared entry is ready at once, so a soft navigation renders it with the shell.
|
|
1678
|
+
if (start.hit !== undefined) {
|
|
1679
|
+
const hit = start.hit
|
|
1680
|
+
settle(hole.name, true, hit.keys)
|
|
1681
|
+
if (fills(hole))
|
|
1682
|
+
return [
|
|
1683
|
+
hole.name,
|
|
1684
|
+
{ ...base, ppr: true, fill: frameBodyOf(DECODER.decode(hit.frame)) },
|
|
1685
|
+
]
|
|
1686
|
+
return [
|
|
1687
|
+
hole.name,
|
|
1688
|
+
hit.data === undefined
|
|
1689
|
+
? { ...base, status: "ready" }
|
|
1690
|
+
: { ...base, status: "ready", data: hit.data },
|
|
1691
|
+
]
|
|
1692
|
+
}
|
|
1693
|
+
const run: Promise<HoleRun<unknown>> =
|
|
1694
|
+
start.entry === undefined
|
|
1695
|
+
? start.run
|
|
1696
|
+
: start.entry.then((outcome) =>
|
|
1697
|
+
outcome.ok ? { ok: true, value: outcome.value.data } : outcome,
|
|
1698
|
+
)
|
|
1699
|
+
settles.push(
|
|
1700
|
+
start.entry === undefined
|
|
1701
|
+
? start.run.then((outcome) => settle(hole.name, outcome.ok))
|
|
1702
|
+
: start.entry.then((outcome) =>
|
|
1703
|
+
settle(hole.name, outcome.ok, outcome.ok ? outcome.value.keys : undefined),
|
|
1704
|
+
),
|
|
1705
|
+
)
|
|
1706
|
+
if (fills(hole)) {
|
|
1707
|
+
const frame =
|
|
1708
|
+
start.entry === undefined
|
|
1709
|
+
? start.run.then((outcome) => frameOf(request, hole, outcome))
|
|
1710
|
+
: start.entry.then((outcome) =>
|
|
1711
|
+
outcome.ok ? DECODER.decode(outcome.value.frame) : errorFrame(hole.name),
|
|
1712
|
+
)
|
|
1713
|
+
const body = frame.then(frameBodyOf)
|
|
1714
|
+
// A blocking render puts the HTML in the host on the server.
|
|
1715
|
+
return [
|
|
1716
|
+
hole.name,
|
|
1717
|
+
{ ...base, ppr: true, fill: mode === "settled" ? await body : defer(body) },
|
|
1718
|
+
]
|
|
1719
|
+
}
|
|
1720
|
+
if (mode === "deferred") {
|
|
1721
|
+
const value = run.then((outcome) => {
|
|
1722
|
+
if (outcome.ok) return outcome.value
|
|
1723
|
+
// The request's own abort reason, so the stream sees a client that left, not a failure.
|
|
1724
|
+
throw signal.aborted
|
|
1725
|
+
? signal.reason
|
|
1726
|
+
: new Error(`[nifra/web] ppr: hole "${hole.name}" is unavailable`)
|
|
1727
|
+
})
|
|
1728
|
+
return [hole.name, { ...base, data: defer(value) }]
|
|
1729
|
+
}
|
|
1730
|
+
const outcome = await run
|
|
1731
|
+
if (!outcome.ok)
|
|
1732
|
+
return [hole.name, { ...base, status: "error", error: HOLE_ERROR_STATE }]
|
|
1733
|
+
return [
|
|
1734
|
+
hole.name,
|
|
1735
|
+
outcome.value === undefined
|
|
1736
|
+
? { ...base, status: "ready" }
|
|
1737
|
+
: { ...base, status: "ready", data: outcome.value },
|
|
1738
|
+
]
|
|
1739
|
+
},
|
|
1740
|
+
),
|
|
1741
|
+
)
|
|
1742
|
+
if (live !== undefined) void Promise.all(settles).then(() => live.done(loaded))
|
|
1743
|
+
return { ...Object.fromEntries(entries), ...keptStates }
|
|
1744
|
+
},
|
|
1745
|
+
|
|
1746
|
+
async invalidateTag(tag) {
|
|
1747
|
+
invalidations++
|
|
1748
|
+
if (builds.size > 0 || holeBuilds.size > 0) invalidatedAt.set(tag, invalidations)
|
|
1749
|
+
for (const [key, entry] of settled) if (entry.tags.includes(tag)) evict(key)
|
|
1750
|
+
await shared?.invalidateTag?.(tag)
|
|
1751
|
+
},
|
|
1752
|
+
|
|
1753
|
+
cancel(token) {
|
|
1754
|
+
const abort = live.get(token)
|
|
1755
|
+
if (abort === undefined) return false
|
|
1756
|
+
abort(new DOMException("PPR cancelled by the client", "AbortError"))
|
|
1757
|
+
return true
|
|
1758
|
+
},
|
|
1759
|
+
|
|
1760
|
+
stats() {
|
|
1761
|
+
return { ...admission.stats(), slowAborted, unsentBytes }
|
|
1762
|
+
},
|
|
1763
|
+
}
|
|
1764
|
+
}
|