@nifrajs/web 3.3.0 → 4.0.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/dist/backend-only.d.ts +15 -0
- package/dist/backend-only.d.ts.map +1 -0
- package/dist/backend-only.js +16 -0
- package/dist/backend-only.js.map +1 -0
- package/dist/boundary.d.ts +5 -0
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +11 -2
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +35 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +89 -35
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +18 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +97 -17
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +72 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +411 -98
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- package/dist/cdn.d.ts +74 -0
- package/dist/cdn.d.ts.map +1 -0
- package/dist/cdn.js +132 -0
- package/dist/cdn.js.map +1 -0
- package/dist/client.d.ts +21 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +226 -15
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +4 -3
- package/dist/conformance.d.ts.map +1 -1
- package/dist/conformance.js +12 -3
- package/dist/conformance.js.map +1 -1
- package/dist/csp.d.ts +93 -0
- package/dist/csp.d.ts.map +1 -0
- package/dist/csp.js +122 -0
- package/dist/csp.js.map +1 -0
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/dev-client.d.ts +76 -0
- package/dist/dev-client.d.ts.map +1 -0
- package/dist/dev-client.js +280 -0
- package/dist/dev-client.js.map +1 -0
- package/dist/dev-diagnostics.d.ts +26 -2
- package/dist/dev-diagnostics.d.ts.map +1 -1
- package/dist/dev-diagnostics.js +38 -7
- package/dist/dev-diagnostics.js.map +1 -1
- package/dist/dev-error.d.ts +4 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +46 -4
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-feed.d.ts +298 -0
- package/dist/dev-feed.d.ts.map +1 -0
- package/dist/dev-feed.js +797 -0
- package/dist/dev-feed.js.map +1 -0
- package/dist/dev-indicator.d.ts +38 -0
- package/dist/dev-indicator.d.ts.map +1 -0
- package/dist/dev-indicator.js +93 -0
- package/dist/dev-indicator.js.map +1 -0
- package/dist/dev-session.d.ts +77 -0
- package/dist/dev-session.d.ts.map +1 -0
- package/dist/dev-session.js +580 -0
- package/dist/dev-session.js.map +1 -0
- package/dist/dev-sourcemap.d.ts +27 -0
- package/dist/dev-sourcemap.d.ts.map +1 -0
- package/dist/dev-sourcemap.js +235 -0
- package/dist/dev-sourcemap.js.map +1 -0
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +13 -5
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/dev.d.ts +15 -2
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +69 -44
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +35 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -0
- package/dist/diagnostic-catalog.js +203 -0
- package/dist/diagnostic-catalog.js.map +1 -0
- package/dist/diagnostic-prompt.d.ts +49 -0
- package/dist/diagnostic-prompt.d.ts.map +1 -0
- package/dist/diagnostic-prompt.js +303 -0
- package/dist/diagnostic-prompt.js.map +1 -0
- package/dist/diagnostic.d.ts +17 -23
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +31 -39
- package/dist/diagnostic.js.map +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +4 -1
- package/dist/draft.js.map +1 -1
- package/dist/fn.d.ts +5 -0
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +13 -3
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +4 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -12
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +48 -0
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -0
- package/dist/internal/cdn/cloudflare.js +94 -0
- package/dist/internal/cdn/cloudflare.js.map +1 -0
- package/dist/internal/cdn/core.d.ts +91 -0
- package/dist/internal/cdn/core.d.ts.map +1 -0
- package/dist/internal/cdn/core.js +220 -0
- package/dist/internal/cdn/core.js.map +1 -0
- package/dist/internal/cdn/fastly.d.ts +17 -0
- package/dist/internal/cdn/fastly.d.ts.map +1 -0
- package/dist/internal/cdn/fastly.js +43 -0
- package/dist/internal/cdn/fastly.js.map +1 -0
- package/dist/internal/cdn/vercel.d.ts +31 -0
- package/dist/internal/cdn/vercel.d.ts.map +1 -0
- package/dist/internal/cdn/vercel.js +56 -0
- package/dist/internal/cdn/vercel.js.map +1 -0
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +151 -30
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-csp.d.ts +24 -0
- package/dist/internal/dev-csp.d.ts.map +1 -0
- package/dist/internal/dev-csp.js +160 -0
- package/dist/internal/dev-csp.js.map +1 -0
- package/dist/internal/dev-reserved.d.ts +25 -0
- package/dist/internal/dev-reserved.d.ts.map +1 -0
- package/dist/internal/dev-reserved.js +39 -0
- package/dist/internal/dev-reserved.js.map +1 -0
- package/dist/internal/html-spans.d.ts +10 -0
- package/dist/internal/html-spans.d.ts.map +1 -0
- package/dist/internal/html-spans.js +46 -0
- package/dist/internal/html-spans.js.map +1 -0
- package/dist/internal/loading-runtime.d.ts +44 -0
- package/dist/internal/loading-runtime.d.ts.map +1 -0
- package/dist/internal/loading-runtime.js +206 -0
- package/dist/internal/loading-runtime.js.map +1 -0
- package/dist/internal/matches-runtime.d.ts +8 -0
- package/dist/internal/matches-runtime.d.ts.map +1 -0
- package/dist/internal/matches-runtime.js +61 -0
- package/dist/internal/matches-runtime.js.map +1 -0
- package/dist/internal/mount-shadow.d.ts +36 -0
- package/dist/internal/mount-shadow.d.ts.map +1 -0
- package/dist/internal/mount-shadow.js +65 -0
- package/dist/internal/mount-shadow.js.map +1 -0
- package/dist/internal/node-builtins.d.ts +3 -0
- package/dist/internal/node-builtins.d.ts.map +1 -0
- package/dist/internal/node-builtins.js +79 -0
- package/dist/internal/node-builtins.js.map +1 -0
- package/dist/internal/output-guard.d.ts +65 -0
- package/dist/internal/output-guard.d.ts.map +1 -0
- package/dist/internal/output-guard.js +630 -0
- package/dist/internal/output-guard.js.map +1 -0
- package/dist/internal/page-execution.d.ts +69 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +1021 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/parity.d.ts +21 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +104 -22
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/private-env.d.ts +12 -0
- package/dist/internal/private-env.d.ts.map +1 -0
- package/dist/internal/private-env.js +335 -0
- package/dist/internal/private-env.js.map +1 -0
- package/dist/internal/public-url.d.ts +3 -0
- package/dist/internal/public-url.d.ts.map +1 -0
- package/dist/internal/public-url.js +18 -0
- package/dist/internal/public-url.js.map +1 -0
- package/dist/internal/render-document.d.ts +37 -21
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +104 -65
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/response-controls.d.ts +75 -0
- package/dist/internal/response-controls.d.ts.map +1 -0
- package/dist/internal/response-controls.js +256 -0
- package/dist/internal/response-controls.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +11 -16
- package/dist/internal/runtime-contract.d.ts.map +1 -1
- package/dist/internal/runtime-contract.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +104 -0
- package/dist/internal/secret-scan.d.ts.map +1 -0
- package/dist/internal/secret-scan.js +485 -0
- package/dist/internal/secret-scan.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +6 -29
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +6 -66
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/source-map.d.ts +47 -0
- package/dist/internal/source-map.d.ts.map +1 -0
- package/dist/internal/source-map.js +225 -0
- package/dist/internal/source-map.js.map +1 -0
- package/dist/internal/target-compat.d.ts +27 -0
- package/dist/internal/target-compat.d.ts.map +1 -0
- package/dist/internal/target-compat.js +77 -0
- package/dist/internal/target-compat.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +6 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -1
- package/dist/internal/timing-safe-equal.js +10 -0
- package/dist/internal/timing-safe-equal.js.map +1 -1
- package/dist/internal/vitals-runtime.d.ts +18 -0
- package/dist/internal/vitals-runtime.d.ts.map +1 -0
- package/dist/internal/vitals-runtime.js +38 -0
- package/dist/internal/vitals-runtime.js.map +1 -0
- package/dist/internal/web-app.d.ts +56 -8
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +215 -772
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/zone-graph.d.ts +89 -0
- package/dist/internal/zone-graph.d.ts.map +1 -0
- package/dist/internal/zone-graph.js +492 -0
- package/dist/internal/zone-graph.js.map +1 -0
- package/dist/isr.d.ts +83 -6
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +301 -39
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +37 -9
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +221 -14
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +386 -9
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +26 -5
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +17 -7
- package/dist/module-graph.js.map +1 -1
- package/dist/navigation.d.ts +17 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +11 -5
- package/dist/navigation.js.map +1 -1
- package/dist/nonce.d.ts +47 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +63 -0
- package/dist/nonce.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +4 -3
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +9 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +18 -0
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/svg.d.ts +22 -3
- package/dist/plugins/svg.d.ts.map +1 -1
- package/dist/plugins/svg.js +112 -1
- package/dist/plugins/svg.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +107 -3
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +378 -17
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +61 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -0
- package/dist/plugins/vite-zone-guard.js +208 -0
- package/dist/plugins/vite-zone-guard.js.map +1 -0
- package/dist/plugins/zone-guard.d.ts +27 -0
- package/dist/plugins/zone-guard.d.ts.map +1 -0
- package/dist/plugins/zone-guard.js +72 -0
- package/dist/plugins/zone-guard.js.map +1 -0
- package/dist/prerender.d.ts +10 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +35 -3
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +9 -0
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +131 -48
- package/dist/public-dir.js.map +1 -1
- package/dist/pwa-manifest.d.ts +68 -0
- package/dist/pwa-manifest.d.ts.map +1 -0
- package/dist/pwa-manifest.js +178 -0
- package/dist/pwa-manifest.js.map +1 -0
- package/dist/query.js +4 -4
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +42 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +6 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +26 -3
- package/dist/route-manifest.js.map +1 -1
- package/dist/route-types.d.ts +24 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +195 -0
- package/dist/route-types.js.map +1 -0
- package/dist/router.d.ts +28 -6
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +129 -40
- package/dist/router.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/dist/vitals.d.ts +33 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +16 -0
- package/dist/vitals.js.map +1 -0
- package/dist/vite.d.ts +16 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +187 -36
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +70 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +365 -0
- package/dist/zones.js.map +1 -0
- package/package.json +67 -14
- package/src/backend-only.ts +16 -0
- package/src/boundary.ts +18 -5
- package/src/build-plan.ts +113 -36
- package/src/build-vite.ts +124 -15
- package/src/build.ts +535 -102
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +251 -18
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -0
- package/src/css-contract.ts +44 -0
- package/src/dev-client.ts +349 -0
- package/src/dev-diagnostics.ts +60 -7
- package/src/dev-error.ts +49 -5
- package/src/dev-feed.ts +1140 -0
- package/src/dev-indicator.ts +113 -0
- package/src/dev-session.ts +729 -0
- package/src/dev-sourcemap.ts +281 -0
- package/src/dev-ssr-graph.ts +16 -5
- package/src/dev.ts +86 -43
- package/src/diagnostic-catalog.ts +243 -0
- package/src/diagnostic-prompt.ts +355 -0
- package/src/diagnostic.ts +63 -66
- package/src/draft.ts +4 -1
- package/src/fn.ts +19 -3
- package/src/fs.ts +13 -2
- package/src/index.ts +57 -10
- package/src/internal/cdn/cloudflare.ts +146 -0
- package/src/internal/cdn/core.ts +316 -0
- package/src/internal/cdn/fastly.ts +62 -0
- package/src/internal/cdn/vercel.ts +95 -0
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/codegen.ts +180 -29
- package/src/internal/dev-csp.ts +176 -0
- package/src/internal/dev-reserved.ts +52 -0
- package/src/internal/html-spans.ts +50 -0
- package/src/internal/loading-runtime.ts +244 -0
- package/src/internal/matches-runtime.ts +62 -0
- package/src/internal/mount-shadow.ts +88 -0
- package/src/internal/node-builtins.ts +79 -0
- package/src/internal/output-guard.ts +701 -0
- package/src/internal/page-execution.ts +1371 -0
- package/src/internal/parity.ts +150 -36
- package/src/internal/private-env.ts +334 -0
- package/src/internal/public-url.ts +22 -0
- package/src/internal/render-document.ts +153 -73
- package/src/internal/request-url.ts +14 -0
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +623 -0
- package/src/internal/server-boundary.ts +6 -69
- package/src/internal/source-map.ts +256 -0
- package/src/internal/target-compat.ts +92 -0
- package/src/internal/timing-safe-equal.ts +13 -0
- package/src/internal/vitals-runtime.ts +53 -0
- package/src/internal/web-app.ts +290 -879
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +366 -39
- package/src/llms-txt.ts +44 -8
- package/src/manifest.ts +671 -17
- package/src/module-graph.ts +53 -9
- package/src/navigation.ts +29 -6
- package/src/nonce.ts +104 -0
- package/src/plugins/css-modules.ts +5 -3
- package/src/plugins/kit.ts +24 -0
- package/src/plugins/svg.ts +135 -4
- package/src/plugins/vite-leak-guard.ts +529 -16
- package/src/plugins/vite-zone-guard.ts +263 -0
- package/src/plugins/zone-guard.ts +110 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +135 -49
- package/src/pwa-manifest.ts +226 -0
- package/src/query.ts +4 -4
- package/src/render-seam.ts +46 -1
- package/src/route-manifest.ts +15 -3
- package/src/route-types.ts +264 -0
- package/src/router.ts +163 -63
- package/src/trusted-html.ts +44 -0
- package/src/vitals.ts +44 -0
- package/src/vite.ts +207 -36
- package/src/zones.ts +430 -0
- package/dist/plugins/vite-server-only.d.ts +0 -49
- package/dist/plugins/vite-server-only.d.ts.map +0 -1
- package/dist/plugins/vite-server-only.js +0 -57
- package/dist/plugins/vite-server-only.js.map +0 -1
- package/dist/server-only.d.ts +0 -21
- package/dist/server-only.d.ts.map +0 -1
- package/dist/server-only.js +0 -25
- package/dist/server-only.js.map +0 -1
- package/src/plugins/vite-server-only.ts +0 -75
- package/src/server-only.ts +0 -25
package/src/bun-dev-entry.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ## Why this file exists at all
|
|
5
5
|
*
|
|
6
|
-
* Bun's dev server bundles an HTML route and rewrites its `<script src="./entry.tsx">` into a
|
|
6
|
+
* Bun's dev server bundles an HTML route and rewrites its `<script src="./nifra-dev-entry.tsx">` into a
|
|
7
7
|
* content-hashed URL under `/_bun/client/`. That rewrite is the whole point: the hash changes per build,
|
|
8
8
|
* so the URL cannot be predicted or hard-coded.
|
|
9
9
|
*
|
package/src/cdn.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/cdn` - put a CDN in front of nifra pages and purge it by tag.
|
|
3
|
+
*
|
|
4
|
+
* `withCdn` wraps the app (or a `withISR` handler) and, on every page a shared cache may store, sets
|
|
5
|
+
* the CDN's tag and TTL headers: the route's `revalidateTags` plus a tag for the page's own path, and
|
|
6
|
+
* the route's `revalidate` as the CDN's freshness. Browsers get `max-age=0, must-revalidate`, because
|
|
7
|
+
* a purge reaches the CDN, never a browser. Every other HTML response is marked no-store for the CDN.
|
|
8
|
+
* `revalidateEndpoint({ cdn })` and `createInvalidator` purge the origin store first, then the CDN.
|
|
9
|
+
*
|
|
10
|
+
* Providers: Cloudflare (a proxied zone, or Workers Cache), Vercel and Fastly; `defineCdnProvider`
|
|
11
|
+
* for any other. Backend-only: a browser build that reaches this module fails.
|
|
12
|
+
*/
|
|
13
|
+
import "@nifrajs/web/backend-only"
|
|
14
|
+
import { isDraftEnabled } from "./draft.ts"
|
|
15
|
+
import type { CdnProvider } from "./internal/cdn/core.ts"
|
|
16
|
+
import { pathTag } from "./internal/cdn/core.ts"
|
|
17
|
+
import {
|
|
18
|
+
type CacheStore,
|
|
19
|
+
type CdnPurgeOutcome,
|
|
20
|
+
type CdnPurgeTarget,
|
|
21
|
+
ISR_REVALIDATE_HEADER,
|
|
22
|
+
ISR_REVALIDATE_TAGS_HEADER,
|
|
23
|
+
type ISRApp,
|
|
24
|
+
type ISRPlatform,
|
|
25
|
+
type ISRQuery,
|
|
26
|
+
isCacheablePage,
|
|
27
|
+
MAX_REVALIDATE_PATHS,
|
|
28
|
+
MAX_REVALIDATE_TAGS,
|
|
29
|
+
normalizeTags,
|
|
30
|
+
openCacheChannel,
|
|
31
|
+
tagsFromHeader,
|
|
32
|
+
urlKeyOf,
|
|
33
|
+
withoutCacheChannel,
|
|
34
|
+
} from "./isr.ts"
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
type CloudflareWorkersCacheOptions,
|
|
38
|
+
type CloudflareZoneOptions,
|
|
39
|
+
cloudflareWorkersCache,
|
|
40
|
+
cloudflareZone,
|
|
41
|
+
type WorkersCacheLike,
|
|
42
|
+
} from "./internal/cdn/cloudflare.ts"
|
|
43
|
+
export {
|
|
44
|
+
type CdnCacheInput,
|
|
45
|
+
type CdnProvider,
|
|
46
|
+
type CdnProviderDefinition,
|
|
47
|
+
type CdnPurgeError,
|
|
48
|
+
defineCdnProvider,
|
|
49
|
+
type PurgeAttempt,
|
|
50
|
+
type PurgeQueueOptions,
|
|
51
|
+
pathTag,
|
|
52
|
+
} from "./internal/cdn/core.ts"
|
|
53
|
+
export { type FastlyOptions, fastly } from "./internal/cdn/fastly.ts"
|
|
54
|
+
export { type VercelOptions, vercel } from "./internal/cdn/vercel.ts"
|
|
55
|
+
export type { CdnPurgeOutcome, CdnPurgeTarget }
|
|
56
|
+
|
|
57
|
+
/** What browsers are told about a page the CDN may store: revalidate every time. */
|
|
58
|
+
export const CDN_BROWSER_CACHE_CONTROL = "public, max-age=0, must-revalidate"
|
|
59
|
+
|
|
60
|
+
type Handler = (req: Request, platform?: ISRPlatform) => Response | Promise<Response>
|
|
61
|
+
|
|
62
|
+
export interface WithCdnOptions {
|
|
63
|
+
readonly provider: CdnProvider
|
|
64
|
+
/**
|
|
65
|
+
* Seconds the CDN may keep serving a page past its freshness while it refetches. Default: the page's
|
|
66
|
+
* own freshness window. With `withISR` underneath, a page can be this much older than ISR's own.
|
|
67
|
+
*/
|
|
68
|
+
readonly staleWhileRevalidate?: number
|
|
69
|
+
/** Seconds the CDN may serve a page past its freshness while the origin errors. Default: unset. */
|
|
70
|
+
readonly staleIfError?: number
|
|
71
|
+
/** The draft secret given to `createWebApp`: a draft render is never stored by the CDN. */
|
|
72
|
+
readonly draftSecret?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const nonNegative = (value: number | undefined, name: string): number | undefined => {
|
|
76
|
+
if (value === undefined) return undefined
|
|
77
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
78
|
+
throw new RangeError(`[nifra/web/cdn] ${name} must be a non-negative integer number of seconds`)
|
|
79
|
+
}
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isHtml = (res: Response): boolean =>
|
|
84
|
+
(res.headers.get("content-type") ?? "").toLowerCase().includes("text/html")
|
|
85
|
+
|
|
86
|
+
function withHeaders(res: Response, set: Readonly<Record<string, string>>): Response {
|
|
87
|
+
try {
|
|
88
|
+
for (const [name, value] of Object.entries(set)) res.headers.set(name, value)
|
|
89
|
+
return res
|
|
90
|
+
} catch {
|
|
91
|
+
// A `fetch()` response has immutable headers: rebuild it around the same body instead.
|
|
92
|
+
const headers = new Headers(res.headers)
|
|
93
|
+
for (const [name, value] of Object.entries(set)) headers.set(name, value)
|
|
94
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wrap `app` (a `createWebApp` app or a `withISR` handler) so a CDN stores exactly the pages a shared
|
|
100
|
+
* cache may serve to anyone, tagged for purging, for as long as the route's `revalidate` allows.
|
|
101
|
+
* Non-HTML responses (assets, JSON, navigation data) pass through with the app's own headers.
|
|
102
|
+
*/
|
|
103
|
+
export function withCdn(
|
|
104
|
+
app: ISRApp | Handler,
|
|
105
|
+
options: WithCdnOptions,
|
|
106
|
+
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
107
|
+
const { provider, draftSecret } = options
|
|
108
|
+
const swr = nonNegative(options.staleWhileRevalidate, "staleWhileRevalidate")
|
|
109
|
+
const staleIfError = nonNegative(options.staleIfError, "staleIfError")
|
|
110
|
+
openCacheChannel(app)
|
|
111
|
+
const fetchOf: Handler =
|
|
112
|
+
typeof app === "function" ? app : (req, platform) => app.fetch(req, platform)
|
|
113
|
+
return async (req, platform) => {
|
|
114
|
+
const res = await fetchOf(req, platform)
|
|
115
|
+
const advertised = res.headers.get(ISR_REVALIDATE_HEADER)
|
|
116
|
+
const tags = tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
|
|
117
|
+
const out = withoutCacheChannel(res)
|
|
118
|
+
if (!isHtml(out)) return out
|
|
119
|
+
const seconds = advertised === null ? Number.NaN : Math.floor(Number(advertised))
|
|
120
|
+
const storable =
|
|
121
|
+
Number.isFinite(seconds) &&
|
|
122
|
+
seconds > 0 &&
|
|
123
|
+
isCacheablePage(req, out) &&
|
|
124
|
+
!(draftSecret !== undefined && (await isDraftEnabled(req, draftSecret)))
|
|
125
|
+
if (!storable) return withHeaders(out, provider.noStoreHeaders())
|
|
126
|
+
const pageTags = [...tags, await pathTag(new URL(req.url).pathname)]
|
|
127
|
+
return withHeaders(out, {
|
|
128
|
+
...provider.cacheHeaders({
|
|
129
|
+
tags: pageTags.slice(-provider.maxResponseTags),
|
|
130
|
+
maxAge: seconds,
|
|
131
|
+
staleWhileRevalidate: swr ?? seconds,
|
|
132
|
+
staleIfError,
|
|
133
|
+
}),
|
|
134
|
+
"cache-control": CDN_BROWSER_CACHE_CONTROL,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface InvalidatorOptions {
|
|
140
|
+
/** The ISR store to purge first. */
|
|
141
|
+
readonly store?: CacheStore
|
|
142
|
+
/** The CDN to purge after the store. */
|
|
143
|
+
readonly cdn?: CdnPurgeTarget
|
|
144
|
+
/**
|
|
145
|
+
* The site's public origin (`https://example.com`), for the store keys of purged paths under
|
|
146
|
+
* `withISR`'s default key. Not needed with `key`, or without a store.
|
|
147
|
+
*/
|
|
148
|
+
readonly origin?: string
|
|
149
|
+
/** A store key for a path; must match the `withISR` `key`. Return `null` for a path never cached. */
|
|
150
|
+
readonly key?: (path: string) => string | null
|
|
151
|
+
/** The `query` policy given to `withISR`. Default `"bypass"`. Ignored with `key`. */
|
|
152
|
+
readonly query?: ISRQuery
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface InvalidateResult {
|
|
156
|
+
readonly origin: "done" | "skipped"
|
|
157
|
+
readonly cdn: CdnPurgeOutcome["cdn"] | "skipped"
|
|
158
|
+
readonly retryable: boolean
|
|
159
|
+
readonly error?: string
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Purge pages after a mutation, from app code: `await invalidate({ tags: ["product:42"] })`. The
|
|
164
|
+
* origin store goes first, so a CDN refetch cannot repopulate from a stale origin entry. At most 32
|
|
165
|
+
* tags and 100 paths a call.
|
|
166
|
+
*/
|
|
167
|
+
export function createInvalidator(options: InvalidatorOptions): {
|
|
168
|
+
invalidate(
|
|
169
|
+
target: { readonly tags?: readonly string[]; readonly paths?: readonly string[] },
|
|
170
|
+
platform?: ISRPlatform,
|
|
171
|
+
): Promise<InvalidateResult>
|
|
172
|
+
} {
|
|
173
|
+
const { store, cdn } = options
|
|
174
|
+
let keyOf: ((path: string) => string | null) | undefined = options.key
|
|
175
|
+
if (keyOf === undefined && store !== undefined) {
|
|
176
|
+
if (options.origin === undefined) {
|
|
177
|
+
throw new TypeError(
|
|
178
|
+
"[nifra/web/cdn] createInvalidator: a store needs `origin` (or `key`) to find a path's entry",
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
const origin = new URL(options.origin).origin
|
|
182
|
+
const urlKey = urlKeyOf(options.query)
|
|
183
|
+
keyOf = (path) => urlKey(new URL(origin + path))
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
async invalidate(target, platform) {
|
|
187
|
+
const tags = normalizeTags(target.tags ?? [])
|
|
188
|
+
const paths = [...new Set(target.paths ?? [])]
|
|
189
|
+
if (tags.length > MAX_REVALIDATE_TAGS || paths.length > MAX_REVALIDATE_PATHS) {
|
|
190
|
+
throw new RangeError(
|
|
191
|
+
"[nifra/web/cdn] invalidate takes at most 32 tags and 100 paths a call",
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
for (const path of paths) {
|
|
195
|
+
if (typeof path !== "string" || !path.startsWith("/")) {
|
|
196
|
+
throw new TypeError("[nifra/web/cdn] invalidate paths must start with /")
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
let origin: InvalidateResult["origin"] = "skipped"
|
|
200
|
+
if (store !== undefined) {
|
|
201
|
+
if (tags.length > 0 && store.invalidateTag === undefined) {
|
|
202
|
+
throw new TypeError("[nifra/web/cdn] this store cannot invalidate by tag")
|
|
203
|
+
}
|
|
204
|
+
for (const path of paths) {
|
|
205
|
+
const key = keyOf?.(path)
|
|
206
|
+
if (key !== null && key !== undefined) await store.delete(key)
|
|
207
|
+
}
|
|
208
|
+
for (const tag of tags) await store.invalidateTag?.(tag)
|
|
209
|
+
origin = "done"
|
|
210
|
+
}
|
|
211
|
+
if (cdn === undefined) return { origin, cdn: "skipped", retryable: false }
|
|
212
|
+
const outcome = await cdn.purge({ tags, paths }, platform)
|
|
213
|
+
return { origin, ...outcome }
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -18,7 +18,20 @@ import {
|
|
|
18
18
|
setBlockerController,
|
|
19
19
|
setBrowserNavigate,
|
|
20
20
|
} from "./navigation.ts"
|
|
21
|
-
import type
|
|
21
|
+
import { type ClientRouter, redirectOf } from "./router.ts"
|
|
22
|
+
|
|
23
|
+
/** Read the nonce carried by the current server-rendered document, if any. */
|
|
24
|
+
export function currentDocumentNonce(): string | undefined {
|
|
25
|
+
if (typeof document === "undefined") return undefined
|
|
26
|
+
const documentLike = document as unknown as {
|
|
27
|
+
querySelector?: (selector: string) => Element | null
|
|
28
|
+
}
|
|
29
|
+
const script = documentLike.querySelector?.("script[nonce]")
|
|
30
|
+
if (script === null || script === undefined) return undefined
|
|
31
|
+
const property = (script as Element & { readonly nonce?: unknown }).nonce
|
|
32
|
+
if (typeof property === "string" && property !== "") return property
|
|
33
|
+
return script.getAttribute("nonce") ?? undefined
|
|
34
|
+
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
24
37
|
* Everything the generated client entry needs, re-exported here so that entry imports ONE module and
|
|
@@ -53,6 +66,146 @@ export interface InstallHistoryOptions {
|
|
|
53
66
|
readonly fallback?: (path: string) => void
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
/** Options for {@link waitForStyles}. */
|
|
70
|
+
export interface WaitForStylesOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Maximum time to wait for one framework-owned deferred stylesheet before hydrating anyway.
|
|
73
|
+
* Defaults to 4000 ms and is capped at 30000 ms so a bad value cannot leave the app inert forever.
|
|
74
|
+
*/
|
|
75
|
+
readonly timeoutMs?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DEFAULT_CSS_WAIT_MS = 4_000
|
|
79
|
+
const MAX_CSS_WAIT_MS = 30_000
|
|
80
|
+
type CssLinkState = "loaded" | "error" | "timeout"
|
|
81
|
+
|
|
82
|
+
// Per-link promises keep repeated calls idempotent without retaining an entire document after a hard
|
|
83
|
+
// navigation. A WeakMap also lets tests and embedded documents replace `globalThis.document` safely.
|
|
84
|
+
const stylesheetWaits = new WeakMap<HTMLLinkElement, Promise<void>>()
|
|
85
|
+
|
|
86
|
+
const cssLinkStateOf = (link: HTMLLinkElement): CssLinkState | undefined => {
|
|
87
|
+
const state = link.getAttribute("data-nifra-css-state")
|
|
88
|
+
return state === "loaded" || state === "error" || state === "timeout" ? state : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hasLoadedStylesheet = (link: HTMLLinkElement): boolean => {
|
|
92
|
+
// Accessing `sheet` is safe for cross-origin stylesheets, but a browser may still throw while it is
|
|
93
|
+
// tearing down a document. Treat that as "not observed yet"; the load/error listener or the bounded
|
|
94
|
+
// timer is the authoritative fallback.
|
|
95
|
+
try {
|
|
96
|
+
return link.sheet !== null
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stylesheetLabel = (link: HTMLLinkElement): string =>
|
|
103
|
+
link.getAttribute("href") ?? link.href ?? "<unknown>"
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wait for one server-emitted deferred stylesheet. The promise ALWAYS settles: a failed or slow CSS
|
|
107
|
+
* request must not leave the server-rendered page's loader visible forever. `media="all"` is applied on
|
|
108
|
+
* every terminal state so a late network completion cannot leave a link permanently in print media.
|
|
109
|
+
*/
|
|
110
|
+
const waitForStylesheet = (link: HTMLLinkElement, timeoutMs: number): Promise<void> =>
|
|
111
|
+
new Promise<void>((resolve) => {
|
|
112
|
+
let settled = false
|
|
113
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
114
|
+
|
|
115
|
+
const cleanup = (): void => {
|
|
116
|
+
link.removeEventListener("load", onLoad)
|
|
117
|
+
link.removeEventListener("error", onError)
|
|
118
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
119
|
+
}
|
|
120
|
+
const finish = (state: CssLinkState): void => {
|
|
121
|
+
if (settled) return
|
|
122
|
+
settled = true
|
|
123
|
+
cleanup()
|
|
124
|
+
link.setAttribute("data-nifra-css-state", state)
|
|
125
|
+
// Only the coordinator promotes the framework-owned link, after the resource has settled.
|
|
126
|
+
link.media = "all"
|
|
127
|
+
resolve()
|
|
128
|
+
}
|
|
129
|
+
const onLoad = (): void => finish("loaded")
|
|
130
|
+
const onError = (): void => {
|
|
131
|
+
console.error(`[nifra/web] stylesheet failed to load: ${stylesheetLabel(link)}`)
|
|
132
|
+
finish("error")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const prior = cssLinkStateOf(link)
|
|
136
|
+
if (prior !== undefined) {
|
|
137
|
+
finish(prior)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (hasLoadedStylesheet(link)) {
|
|
141
|
+
finish("loaded")
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Attach listeners before the second loaded-sheet check: a cached resource can transition between
|
|
146
|
+
// the first check and listener registration, and the second check closes that small race.
|
|
147
|
+
link.addEventListener("load", onLoad, { once: true })
|
|
148
|
+
link.addEventListener("error", onError, { once: true })
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
console.warn(
|
|
151
|
+
`[nifra/web] stylesheet load timed out after ${timeoutMs}ms: ${stylesheetLabel(link)}`,
|
|
152
|
+
)
|
|
153
|
+
finish("timeout")
|
|
154
|
+
}, timeoutMs)
|
|
155
|
+
if (hasLoadedStylesheet(link)) finish("loaded")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const validateCssWaitMs = (value: number): number => {
|
|
159
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
160
|
+
throw new RangeError(
|
|
161
|
+
`[nifra/web] css wait timeout must be a finite positive number, received ${value}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return Math.min(value, MAX_CSS_WAIT_MS)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Wait for the framework-owned deferred stylesheet links in the initial document, then promote them to
|
|
169
|
+
* `media="all"`. It is safe to call more than once: repeated calls for one document share the same
|
|
170
|
+
* per-link waits, and duplicate framework links are removed before waiting. Blocking links are not
|
|
171
|
+
* touched.
|
|
172
|
+
*
|
|
173
|
+
* This function lives in the browser-only `@nifrajs/web/client` entry. It is nevertheless defensive
|
|
174
|
+
* when imported in a non-DOM environment so an accidental import cannot crash a server process.
|
|
175
|
+
*/
|
|
176
|
+
export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void> {
|
|
177
|
+
if (typeof document === "undefined") return Promise.resolve()
|
|
178
|
+
const timeoutMs = validateCssWaitMs(options.timeoutMs ?? DEFAULT_CSS_WAIT_MS)
|
|
179
|
+
const links = Array.from(
|
|
180
|
+
document.querySelectorAll<HTMLLinkElement>('link[data-nifra-css="deferred"]'),
|
|
181
|
+
).filter(
|
|
182
|
+
(link) =>
|
|
183
|
+
link.getAttribute("data-nifra-css") === "deferred" && link.rel.toLowerCase() === "stylesheet",
|
|
184
|
+
)
|
|
185
|
+
const unique: HTMLLinkElement[] = []
|
|
186
|
+
const seen = new Set<string>()
|
|
187
|
+
for (const link of links) {
|
|
188
|
+
const key = stylesheetLabel(link)
|
|
189
|
+
if (seen.has(key)) {
|
|
190
|
+
// Only remove a duplicate carrying the framework marker; author-owned links are outside this
|
|
191
|
+
// selector and cannot be affected. Keeping the first link preserves its event state and cache.
|
|
192
|
+
link.remove()
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
seen.add(key)
|
|
196
|
+
unique.push(link)
|
|
197
|
+
}
|
|
198
|
+
if (unique.length === 0) return Promise.resolve()
|
|
199
|
+
const promises = unique.map((link) => {
|
|
200
|
+
const existing = stylesheetWaits.get(link)
|
|
201
|
+
if (existing !== undefined) return existing
|
|
202
|
+
const promise = waitForStylesheet(link, timeoutMs)
|
|
203
|
+
stylesheetWaits.set(link, promise)
|
|
204
|
+
return promise
|
|
205
|
+
})
|
|
206
|
+
return Promise.all(promises).then(() => undefined)
|
|
207
|
+
}
|
|
208
|
+
|
|
56
209
|
/**
|
|
57
210
|
* Attach history + link interception to a router. Returns a teardown function that removes the
|
|
58
211
|
* listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
|
|
@@ -80,6 +233,8 @@ export function installHistory(
|
|
|
80
233
|
let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
81
234
|
let here = location.pathname + location.search + (location.hash ?? "")
|
|
82
235
|
let reversing = false
|
|
236
|
+
// The path the router was last seen loading, so a redirect it follows shows up as a change.
|
|
237
|
+
let expected: string | undefined
|
|
83
238
|
type Registration = {
|
|
84
239
|
readonly shouldBlock: BlockerFunction
|
|
85
240
|
readonly emit: (blocker: Blocker) => void
|
|
@@ -215,11 +370,20 @@ export function installHistory(
|
|
|
215
370
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
216
371
|
}
|
|
217
372
|
here = url.pathname + url.search + url.hash
|
|
373
|
+
expected = routePath
|
|
218
374
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
219
|
-
transition(() => router.navigate(routePath).catch(() =>
|
|
375
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
|
|
220
376
|
settle()
|
|
221
377
|
}
|
|
222
378
|
|
|
379
|
+
// A redirect out of the app replaces the entry it answered, as the browser's own redirect would; any
|
|
380
|
+
// other failure loads the page as a document.
|
|
381
|
+
const leave = (error: unknown, path: string): void => {
|
|
382
|
+
const to = redirectOf(error)
|
|
383
|
+
if (to === undefined) fallback(path)
|
|
384
|
+
else location.replace(to)
|
|
385
|
+
}
|
|
386
|
+
|
|
223
387
|
const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
|
|
224
388
|
// Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
|
|
225
389
|
// malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
|
|
@@ -244,7 +408,7 @@ export function installHistory(
|
|
|
244
408
|
}
|
|
245
409
|
go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
|
|
246
410
|
}
|
|
247
|
-
setBrowserNavigate(navigate)
|
|
411
|
+
setBrowserNavigate(navigate, router)
|
|
248
412
|
|
|
249
413
|
// Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
|
|
250
414
|
// adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
|
|
@@ -291,12 +455,45 @@ export function installHistory(
|
|
|
291
455
|
go(href, "push")
|
|
292
456
|
}
|
|
293
457
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
458
|
+
// `data-nifra-prefetch` on a link or any ancestor (the nearest wins) says when the link warms its
|
|
459
|
+
// route's chunk and data: `intent` (the default) on hover or focus, `viewport` once it scrolls into
|
|
460
|
+
// view, `render` as soon as a page shows it, `none` never.
|
|
461
|
+
const prefetchModeOf = (el: Element): string | undefined =>
|
|
462
|
+
(el.closest("[data-nifra-prefetch]") as HTMLElement | null)?.dataset.nifraPrefetch
|
|
463
|
+
|
|
464
|
+
// Warm an in-app link's route (the store dedupes the spam). The page on screen has nothing to warm.
|
|
465
|
+
const warm = (target: EventTarget | null): void => {
|
|
466
|
+
const href = inAppHref(target)
|
|
297
467
|
if (href === null) return
|
|
298
468
|
const url = new URL(href, location.origin)
|
|
299
|
-
|
|
469
|
+
const path = url.pathname + url.search // the #hash isn't data
|
|
470
|
+
if (path !== location.pathname + location.search) void router.prefetch(path)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const onPrefetch = (event: Event): void => {
|
|
474
|
+
const target = event.target
|
|
475
|
+
if (target instanceof Element && prefetchModeOf(target) !== "none") warm(target)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// `viewport` and `render` links are looked for when history is installed (the server-rendered page)
|
|
479
|
+
// and whenever the router settles (a navigation, a submit's revalidation). A link the page adds in
|
|
480
|
+
// between warms on intent until the next settle.
|
|
481
|
+
let inView: IntersectionObserver | undefined
|
|
482
|
+
const scan = (): void => {
|
|
483
|
+
inView?.disconnect()
|
|
484
|
+
for (const anchor of document.querySelectorAll(
|
|
485
|
+
"a[data-nifra-prefetch],[data-nifra-prefetch] a",
|
|
486
|
+
)) {
|
|
487
|
+
const mode = prefetchModeOf(anchor)
|
|
488
|
+
if (mode === "render") warm(anchor)
|
|
489
|
+
else if (mode === "viewport") {
|
|
490
|
+
// Scrolling back into view asks again; the store holds a fresh prefetch, so nothing refetches.
|
|
491
|
+
inView ??= new IntersectionObserver((entries) => {
|
|
492
|
+
for (const entry of entries) if (entry.isIntersecting) warm(entry.target)
|
|
493
|
+
})
|
|
494
|
+
inView.observe(anchor)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
300
497
|
}
|
|
301
498
|
|
|
302
499
|
// Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
|
|
@@ -320,9 +517,9 @@ export function installHistory(
|
|
|
320
517
|
index = newIndex
|
|
321
518
|
here = dest
|
|
322
519
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
)
|
|
520
|
+
const routePath = location.pathname + location.search
|
|
521
|
+
expected = routePath
|
|
522
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
|
|
326
523
|
settle()
|
|
327
524
|
}
|
|
328
525
|
|
|
@@ -344,24 +541,50 @@ export function installHistory(
|
|
|
344
541
|
event.returnValue = ""
|
|
345
542
|
}
|
|
346
543
|
|
|
544
|
+
// The router is loading a page the address bar does not show: a redirect it follows. That replaces the
|
|
545
|
+
// entry a navigation pushed, or adds one after a form post, as the browser would. A redirect from a
|
|
546
|
+
// form post back to the page it was on adds nothing.
|
|
547
|
+
const follow = (path: string): void => {
|
|
548
|
+
if (path !== location.pathname + location.search) {
|
|
549
|
+
if (expected === undefined) {
|
|
550
|
+
history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
|
|
551
|
+
index += 1
|
|
552
|
+
history.pushState({ nifraIndex: index }, "", path)
|
|
553
|
+
} else {
|
|
554
|
+
history.replaceState(history.state, "", path + location.hash)
|
|
555
|
+
}
|
|
556
|
+
here = location.pathname + location.search + location.hash
|
|
557
|
+
pendingScroll = location.hash !== "" ? { hash: hashId(location.hash) } : { pos: [0, 0] }
|
|
558
|
+
}
|
|
559
|
+
expected = path
|
|
560
|
+
}
|
|
561
|
+
|
|
347
562
|
// After a navigation settles (content rendered), apply the pending scroll target on the next frame:
|
|
348
563
|
// a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
564
|
+
// A settle with no navigation (a submit's revalidation) scrolls nothing. Either way the rendered
|
|
565
|
+
// page is then scanned for links to warm.
|
|
566
|
+
const settled = (): void => {
|
|
567
|
+
const { pending, pendingPath } = router.snapshot()
|
|
568
|
+
if (pending) {
|
|
569
|
+
if (pendingPath !== undefined && pendingPath !== expected) follow(pendingPath)
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
expected = undefined
|
|
352
573
|
const target = pendingScroll
|
|
353
574
|
pendingScroll = null
|
|
354
575
|
requestAnimationFrame(() => {
|
|
355
|
-
if ("hash" in target) {
|
|
576
|
+
if (target !== null && "hash" in target) {
|
|
356
577
|
const el = findAnchor(target.hash)
|
|
357
578
|
if (el !== null) el.scrollIntoView()
|
|
358
579
|
else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
|
|
359
|
-
} else {
|
|
580
|
+
} else if (target !== null) {
|
|
360
581
|
window.scrollTo(target.pos[0], target.pos[1])
|
|
361
582
|
}
|
|
583
|
+
scan()
|
|
362
584
|
})
|
|
363
585
|
}
|
|
364
|
-
const unsubscribe = router.subscribe(
|
|
586
|
+
const unsubscribe = router.subscribe(settled)
|
|
587
|
+
scan()
|
|
365
588
|
|
|
366
589
|
document.addEventListener("click", onClick)
|
|
367
590
|
document.addEventListener("pointerover", onPrefetch)
|
|
@@ -370,6 +593,7 @@ export function installHistory(
|
|
|
370
593
|
window.addEventListener("beforeunload", onBeforeUnload)
|
|
371
594
|
return () => {
|
|
372
595
|
unsubscribe()
|
|
596
|
+
inView?.disconnect()
|
|
373
597
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
374
598
|
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
375
599
|
document.removeEventListener("click", onClick)
|
|
@@ -392,6 +616,7 @@ export function installHistory(
|
|
|
392
616
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
393
617
|
*/
|
|
394
618
|
export function applyHead(head: Meta): void {
|
|
619
|
+
const documentNonce = currentDocumentNonce()
|
|
395
620
|
if (head.title !== undefined) document.title = head.title
|
|
396
621
|
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
397
622
|
// route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
|
|
@@ -444,6 +669,11 @@ export function applyHead(head: Meta): void {
|
|
|
444
669
|
`[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
|
|
445
670
|
)
|
|
446
671
|
}
|
|
672
|
+
if (documentNonce !== undefined && s.nonce !== documentNonce) {
|
|
673
|
+
throw new TypeError(
|
|
674
|
+
"[nifra/web] executable head scripts must use the same CSP nonce as the document",
|
|
675
|
+
)
|
|
676
|
+
}
|
|
447
677
|
const el = document.createElement("script")
|
|
448
678
|
el.setAttribute("type", s.type)
|
|
449
679
|
el.setAttribute("nonce", s.nonce)
|
|
@@ -466,8 +696,11 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
466
696
|
// `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
|
|
467
697
|
// actionData drives the update); absent or any other value keeps the default revalidation.
|
|
468
698
|
const revalidate = form.dataset.nifraRevalidate !== "false"
|
|
469
|
-
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
|
|
470
|
-
|
|
699
|
+
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch((error) => {
|
|
700
|
+
const to = redirectOf(error)
|
|
701
|
+
// The action ran: load the page that shows its result rather than run it again.
|
|
702
|
+
if (to !== undefined) location.assign(to)
|
|
703
|
+
else form.submit() // data submit failed - fall back to a full-page POST
|
|
471
704
|
})
|
|
472
705
|
}
|
|
473
706
|
document.addEventListener("submit", onSubmit)
|
package/src/conformance.ts
CHANGED
|
@@ -88,9 +88,10 @@ const readHtml = async (
|
|
|
88
88
|
* Execute the observable {@link RenderAdapter} interface against a framework-specific fixture.
|
|
89
89
|
*
|
|
90
90
|
* The promise resolves only when the adapter proves all shared invariants: page props reach a
|
|
91
|
-
* page-only render, streams contain byte chunks, layout chains nest outermost-first,
|
|
92
|
-
* buffered rendering is byte-for-byte
|
|
93
|
-
* and hydration-head output is a stable
|
|
91
|
+
* page-only render, streams contain byte chunks, layout chains nest outermost-first, a leaf that
|
|
92
|
+
* renders nothing leaves its layouts standing, optional buffered rendering is byte-for-byte
|
|
93
|
+
* hydration-equivalent to streaming for non-deferred content, and hydration-head output is a stable
|
|
94
|
+
* string. It rejects with
|
|
94
95
|
* {@link RenderAdapterConformanceError} naming the first failed check.
|
|
95
96
|
*
|
|
96
97
|
* Framework semantics outside the interface-Suspense scheduling, compiler plugins, DOM hydration,
|
|
@@ -125,6 +126,18 @@ export async function assertRenderAdapterConformance(
|
|
|
125
126
|
fail("layout order", "expected outer layout → inner layout → page source order")
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// The page slot of an `ssr = false` route with no `HydrateFallback`: a plain function returning
|
|
130
|
+
// `null`, in every framework. The layouts must render around it and the slot must be empty.
|
|
131
|
+
const emptyHtml = await readHtml(
|
|
132
|
+
() => adapter.renderToStream([outerLayout, innerLayout, () => null], props),
|
|
133
|
+
"empty leaf",
|
|
134
|
+
)
|
|
135
|
+
contains(emptyHtml, markers.outer, "empty leaf")
|
|
136
|
+
contains(emptyHtml, markers.inner, "empty leaf")
|
|
137
|
+
if (emptyHtml.includes(markers.page)) {
|
|
138
|
+
fail("empty leaf", "a leaf that renders nothing must leave the page slot empty")
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
if (adapter.renderToString !== undefined) {
|
|
129
142
|
let buffered: unknown
|
|
130
143
|
try {
|