@nifrajs/web 3.5.0 → 4.0.2
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 +18 -6
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +82 -34
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +4 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +68 -13
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +57 -23
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +356 -85
- 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 +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +136 -35
- 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/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 +13 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -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.map +1 -1
- package/dist/internal/codegen.js +141 -25
- 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 +20 -8
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +571 -105
- package/dist/internal/page-execution.js.map +1 -1
- 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 +25 -19
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +77 -61
- package/dist/internal/render-document.js.map +1 -1
- 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 +503 -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 +38 -9
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +199 -59
- 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 +263 -36
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +31 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +220 -13
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +394 -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 +80 -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 +67 -9
- 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/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 +248 -43
- 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 +90 -35
- package/src/build-vite.ts +77 -11
- package/src/build.ts +466 -96
- package/src/bun-dev-entry.ts +1 -1
- package/src/cdn.ts +216 -0
- package/src/client.ts +135 -36
- package/src/conformance.ts +16 -3
- package/src/csp.ts +194 -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 +42 -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 +161 -24
- 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 +795 -185
- 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 +114 -67
- package/src/internal/response-controls.ts +284 -0
- package/src/internal/runtime-contract.ts +10 -15
- package/src/internal/secret-scan.ts +638 -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 +262 -84
- package/src/internal/zone-graph.ts +593 -0
- package/src/isr.ts +325 -37
- package/src/llms-txt.ts +28 -4
- package/src/manifest.ts +682 -16
- 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 +119 -0
- package/src/prerender.ts +49 -3
- package/src/public-dir.ts +69 -8
- 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/vitals.ts +44 -0
- package/src/vite.ts +257 -42
- 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
|
|
@@ -193,6 +206,23 @@ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void>
|
|
|
193
206
|
return Promise.all(promises).then(() => undefined)
|
|
194
207
|
}
|
|
195
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Resolve `href` against this page and keep it only when history may move there without a load: same
|
|
211
|
+
* scheme and host as the document, or null. Compared by protocol + host rather than `URL.origin`,
|
|
212
|
+
* which is `"null"` for every non-special scheme - a native webview's `capacitor://localhost` page and
|
|
213
|
+
* a `javascript:` target alike. An opaque or `file:` document may not push another path at all, so its
|
|
214
|
+
* links load natively.
|
|
215
|
+
*/
|
|
216
|
+
function sameDocumentUrl(href: string): URL | null {
|
|
217
|
+
if (location.origin === "null" || location.protocol === "file:") return null
|
|
218
|
+
try {
|
|
219
|
+
const url = new URL(href, location.href)
|
|
220
|
+
return url.protocol === location.protocol && url.host === location.host ? url : null
|
|
221
|
+
} catch {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
196
226
|
/**
|
|
197
227
|
* Attach history + link interception to a router. Returns a teardown function that removes the
|
|
198
228
|
* listeners. A data-fetch failure during a client navigation falls back to a full-page load, so
|
|
@@ -220,6 +250,8 @@ export function installHistory(
|
|
|
220
250
|
let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
221
251
|
let here = location.pathname + location.search + (location.hash ?? "")
|
|
222
252
|
let reversing = false
|
|
253
|
+
// The path the router was last seen loading, so a redirect it follows shows up as a change.
|
|
254
|
+
let expected: string | undefined
|
|
223
255
|
type Registration = {
|
|
224
256
|
readonly shouldBlock: BlockerFunction
|
|
225
257
|
readonly emit: (blocker: Blocker) => void
|
|
@@ -273,7 +305,7 @@ export function installHistory(
|
|
|
273
305
|
|
|
274
306
|
// Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
|
|
275
307
|
const locationOf = (path: string): BlockerLocation => {
|
|
276
|
-
const url = new URL(path, location.
|
|
308
|
+
const url = new URL(path, location.href)
|
|
277
309
|
return { pathname: url.pathname, search: url.search, hash: url.hash }
|
|
278
310
|
}
|
|
279
311
|
|
|
@@ -317,17 +349,6 @@ export function installHistory(
|
|
|
317
349
|
// Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
|
|
318
350
|
// becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
|
|
319
351
|
// router intentionally kept rendering the old route.
|
|
320
|
-
const parseNavigationUrl = (path: string): URL | null => {
|
|
321
|
-
try {
|
|
322
|
-
const url = new URL(path, location.origin)
|
|
323
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") return null
|
|
324
|
-
if (url.origin !== location.origin) return null
|
|
325
|
-
return url
|
|
326
|
-
} catch {
|
|
327
|
-
return null
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
352
|
const commit = (path: string, url: URL, mode: "push" | "replace", state?: unknown): void => {
|
|
332
353
|
const routePath = url.pathname + url.search
|
|
333
354
|
if (router.match(routePath) === null) {
|
|
@@ -355,15 +376,24 @@ export function installHistory(
|
|
|
355
376
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
356
377
|
}
|
|
357
378
|
here = url.pathname + url.search + url.hash
|
|
379
|
+
expected = routePath
|
|
358
380
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
359
|
-
transition(() => router.navigate(routePath).catch(() =>
|
|
381
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
|
|
360
382
|
settle()
|
|
361
383
|
}
|
|
362
384
|
|
|
385
|
+
// A redirect out of the app replaces the entry it answered, as the browser's own redirect would; any
|
|
386
|
+
// other failure loads the page as a document.
|
|
387
|
+
const leave = (error: unknown, path: string): void => {
|
|
388
|
+
const to = redirectOf(error)
|
|
389
|
+
if (to === undefined) fallback(path)
|
|
390
|
+
else location.replace(to)
|
|
391
|
+
}
|
|
392
|
+
|
|
363
393
|
const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
|
|
364
394
|
// Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
|
|
365
395
|
// malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
|
|
366
|
-
const url =
|
|
396
|
+
const url = sameDocumentUrl(path)
|
|
367
397
|
if (url === null) {
|
|
368
398
|
settle()
|
|
369
399
|
return
|
|
@@ -384,7 +414,7 @@ export function installHistory(
|
|
|
384
414
|
}
|
|
385
415
|
go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
|
|
386
416
|
}
|
|
387
|
-
setBrowserNavigate(navigate)
|
|
417
|
+
setBrowserNavigate(navigate, router)
|
|
388
418
|
|
|
389
419
|
// Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
|
|
390
420
|
// adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
|
|
@@ -408,8 +438,8 @@ export function installHistory(
|
|
|
408
438
|
if (anchor.target !== "" && anchor.target !== "_self") return null
|
|
409
439
|
if (anchor.hasAttribute("download")) return null
|
|
410
440
|
if (anchor.getAttribute("rel")?.split(/\s+/).includes("external")) return null
|
|
411
|
-
const url =
|
|
412
|
-
if (url
|
|
441
|
+
const url = sameDocumentUrl(anchor.href)
|
|
442
|
+
if (url === null) return null
|
|
413
443
|
if (router.match(url.pathname) === null) return null
|
|
414
444
|
// A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
|
|
415
445
|
// browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
|
|
@@ -431,12 +461,45 @@ export function installHistory(
|
|
|
431
461
|
go(href, "push")
|
|
432
462
|
}
|
|
433
463
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
464
|
+
// `data-nifra-prefetch` on a link or any ancestor (the nearest wins) says when the link warms its
|
|
465
|
+
// route's chunk and data: `intent` (the default) on hover or focus, `viewport` once it scrolls into
|
|
466
|
+
// view, `render` as soon as a page shows it, `none` never.
|
|
467
|
+
const prefetchModeOf = (el: Element): string | undefined =>
|
|
468
|
+
(el.closest("[data-nifra-prefetch]") as HTMLElement | null)?.dataset.nifraPrefetch
|
|
469
|
+
|
|
470
|
+
// Warm an in-app link's route (the store dedupes the spam). The page on screen has nothing to warm.
|
|
471
|
+
const warm = (target: EventTarget | null): void => {
|
|
472
|
+
const href = inAppHref(target)
|
|
437
473
|
if (href === null) return
|
|
438
|
-
const url = new URL(href, location.
|
|
439
|
-
|
|
474
|
+
const url = new URL(href, location.href)
|
|
475
|
+
const path = url.pathname + url.search // the #hash isn't data
|
|
476
|
+
if (path !== location.pathname + location.search) void router.prefetch(path)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const onPrefetch = (event: Event): void => {
|
|
480
|
+
const target = event.target
|
|
481
|
+
if (target instanceof Element && prefetchModeOf(target) !== "none") warm(target)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// `viewport` and `render` links are looked for when history is installed (the server-rendered page)
|
|
485
|
+
// and whenever the router settles (a navigation, a submit's revalidation). A link the page adds in
|
|
486
|
+
// between warms on intent until the next settle.
|
|
487
|
+
let inView: IntersectionObserver | undefined
|
|
488
|
+
const scan = (): void => {
|
|
489
|
+
inView?.disconnect()
|
|
490
|
+
for (const anchor of document.querySelectorAll(
|
|
491
|
+
"a[data-nifra-prefetch],[data-nifra-prefetch] a",
|
|
492
|
+
)) {
|
|
493
|
+
const mode = prefetchModeOf(anchor)
|
|
494
|
+
if (mode === "render") warm(anchor)
|
|
495
|
+
else if (mode === "viewport") {
|
|
496
|
+
// Scrolling back into view asks again; the store holds a fresh prefetch, so nothing refetches.
|
|
497
|
+
inView ??= new IntersectionObserver((entries) => {
|
|
498
|
+
for (const entry of entries) if (entry.isIntersecting) warm(entry.target)
|
|
499
|
+
})
|
|
500
|
+
inView.observe(anchor)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
440
503
|
}
|
|
441
504
|
|
|
442
505
|
// Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
|
|
@@ -460,9 +523,9 @@ export function installHistory(
|
|
|
460
523
|
index = newIndex
|
|
461
524
|
here = dest
|
|
462
525
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
526
|
+
const routePath = location.pathname + location.search
|
|
527
|
+
expected = routePath
|
|
528
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
|
|
466
529
|
settle()
|
|
467
530
|
}
|
|
468
531
|
|
|
@@ -484,24 +547,50 @@ export function installHistory(
|
|
|
484
547
|
event.returnValue = ""
|
|
485
548
|
}
|
|
486
549
|
|
|
550
|
+
// The router is loading a page the address bar does not show: a redirect it follows. That replaces the
|
|
551
|
+
// entry a navigation pushed, or adds one after a form post, as the browser would. A redirect from a
|
|
552
|
+
// form post back to the page it was on adds nothing.
|
|
553
|
+
const follow = (path: string): void => {
|
|
554
|
+
if (path !== location.pathname + location.search) {
|
|
555
|
+
if (expected === undefined) {
|
|
556
|
+
history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
|
|
557
|
+
index += 1
|
|
558
|
+
history.pushState({ nifraIndex: index }, "", path)
|
|
559
|
+
} else {
|
|
560
|
+
history.replaceState(history.state, "", path + location.hash)
|
|
561
|
+
}
|
|
562
|
+
here = location.pathname + location.search + location.hash
|
|
563
|
+
pendingScroll = location.hash !== "" ? { hash: hashId(location.hash) } : { pos: [0, 0] }
|
|
564
|
+
}
|
|
565
|
+
expected = path
|
|
566
|
+
}
|
|
567
|
+
|
|
487
568
|
// After a navigation settles (content rendered), apply the pending scroll target on the next frame:
|
|
488
569
|
// a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
570
|
+
// A settle with no navigation (a submit's revalidation) scrolls nothing. Either way the rendered
|
|
571
|
+
// page is then scanned for links to warm.
|
|
572
|
+
const settled = (): void => {
|
|
573
|
+
const { pending, pendingPath } = router.snapshot()
|
|
574
|
+
if (pending) {
|
|
575
|
+
if (pendingPath !== undefined && pendingPath !== expected) follow(pendingPath)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
expected = undefined
|
|
492
579
|
const target = pendingScroll
|
|
493
580
|
pendingScroll = null
|
|
494
581
|
requestAnimationFrame(() => {
|
|
495
|
-
if ("hash" in target) {
|
|
582
|
+
if (target !== null && "hash" in target) {
|
|
496
583
|
const el = findAnchor(target.hash)
|
|
497
584
|
if (el !== null) el.scrollIntoView()
|
|
498
585
|
else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
|
|
499
|
-
} else {
|
|
586
|
+
} else if (target !== null) {
|
|
500
587
|
window.scrollTo(target.pos[0], target.pos[1])
|
|
501
588
|
}
|
|
589
|
+
scan()
|
|
502
590
|
})
|
|
503
591
|
}
|
|
504
|
-
const unsubscribe = router.subscribe(
|
|
592
|
+
const unsubscribe = router.subscribe(settled)
|
|
593
|
+
scan()
|
|
505
594
|
|
|
506
595
|
document.addEventListener("click", onClick)
|
|
507
596
|
document.addEventListener("pointerover", onPrefetch)
|
|
@@ -510,6 +599,7 @@ export function installHistory(
|
|
|
510
599
|
window.addEventListener("beforeunload", onBeforeUnload)
|
|
511
600
|
return () => {
|
|
512
601
|
unsubscribe()
|
|
602
|
+
inView?.disconnect()
|
|
513
603
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
514
604
|
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
515
605
|
document.removeEventListener("click", onClick)
|
|
@@ -532,6 +622,7 @@ export function installHistory(
|
|
|
532
622
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
533
623
|
*/
|
|
534
624
|
export function applyHead(head: Meta): void {
|
|
625
|
+
const documentNonce = currentDocumentNonce()
|
|
535
626
|
if (head.title !== undefined) document.title = head.title
|
|
536
627
|
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
537
628
|
// route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
|
|
@@ -584,6 +675,11 @@ export function applyHead(head: Meta): void {
|
|
|
584
675
|
`[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
|
|
585
676
|
)
|
|
586
677
|
}
|
|
678
|
+
if (documentNonce !== undefined && s.nonce !== documentNonce) {
|
|
679
|
+
throw new TypeError(
|
|
680
|
+
"[nifra/web] executable head scripts must use the same CSP nonce as the document",
|
|
681
|
+
)
|
|
682
|
+
}
|
|
587
683
|
const el = document.createElement("script")
|
|
588
684
|
el.setAttribute("type", s.type)
|
|
589
685
|
el.setAttribute("nonce", s.nonce)
|
|
@@ -599,15 +695,18 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
599
695
|
const form = event.target
|
|
600
696
|
if (!(form instanceof HTMLFormElement)) return
|
|
601
697
|
if (form.method.toLowerCase() !== "post") return // GET forms submit natively
|
|
602
|
-
const url =
|
|
603
|
-
if (url
|
|
698
|
+
const url = sameDocumentUrl(form.action) // resolved (or the current document URL if unset)
|
|
699
|
+
if (url === null) return
|
|
604
700
|
if (router.match(url.pathname) === null) return // not an app route → native submit
|
|
605
701
|
event.preventDefault()
|
|
606
702
|
// `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
|
|
607
703
|
// actionData drives the update); absent or any other value keeps the default revalidation.
|
|
608
704
|
const revalidate = form.dataset.nifraRevalidate !== "false"
|
|
609
|
-
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
|
|
610
|
-
|
|
705
|
+
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch((error) => {
|
|
706
|
+
const to = redirectOf(error)
|
|
707
|
+
// The action ran: load the page that shows its result rather than run it again.
|
|
708
|
+
if (to !== undefined) location.assign(to)
|
|
709
|
+
else form.submit() // data submit failed - fall back to a full-page POST
|
|
611
710
|
})
|
|
612
711
|
}
|
|
613
712
|
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 {
|