@nifrajs/web 3.5.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 +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 +111 -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/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 +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 +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 +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 +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 +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 +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 +111 -18
- 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 +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 +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 +670 -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 +110 -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 +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
|
|
@@ -220,6 +233,8 @@ export function installHistory(
|
|
|
220
233
|
let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
221
234
|
let here = location.pathname + location.search + (location.hash ?? "")
|
|
222
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
|
|
223
238
|
type Registration = {
|
|
224
239
|
readonly shouldBlock: BlockerFunction
|
|
225
240
|
readonly emit: (blocker: Blocker) => void
|
|
@@ -355,11 +370,20 @@ export function installHistory(
|
|
|
355
370
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
356
371
|
}
|
|
357
372
|
here = url.pathname + url.search + url.hash
|
|
373
|
+
expected = routePath
|
|
358
374
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
359
|
-
transition(() => router.navigate(routePath).catch(() =>
|
|
375
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, path)))
|
|
360
376
|
settle()
|
|
361
377
|
}
|
|
362
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
|
+
|
|
363
387
|
const go = (path: string, mode: "push" | "replace", state?: unknown): void => {
|
|
364
388
|
// Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
|
|
365
389
|
// malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
|
|
@@ -384,7 +408,7 @@ export function installHistory(
|
|
|
384
408
|
}
|
|
385
409
|
go(to, navOptions?.replace === true ? "replace" : "push", navOptions?.state)
|
|
386
410
|
}
|
|
387
|
-
setBrowserNavigate(navigate)
|
|
411
|
+
setBrowserNavigate(navigate, router)
|
|
388
412
|
|
|
389
413
|
// Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
|
|
390
414
|
// adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
|
|
@@ -431,12 +455,45 @@ export function installHistory(
|
|
|
431
455
|
go(href, "push")
|
|
432
456
|
}
|
|
433
457
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
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)
|
|
437
467
|
if (href === null) return
|
|
438
468
|
const url = new URL(href, location.origin)
|
|
439
|
-
|
|
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
|
+
}
|
|
440
497
|
}
|
|
441
498
|
|
|
442
499
|
// Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
|
|
@@ -460,9 +517,9 @@ export function installHistory(
|
|
|
460
517
|
index = newIndex
|
|
461
518
|
here = dest
|
|
462
519
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
)
|
|
520
|
+
const routePath = location.pathname + location.search
|
|
521
|
+
expected = routePath
|
|
522
|
+
transition(() => router.navigate(routePath).catch((error) => leave(error, dest)))
|
|
466
523
|
settle()
|
|
467
524
|
}
|
|
468
525
|
|
|
@@ -484,24 +541,50 @@ export function installHistory(
|
|
|
484
541
|
event.returnValue = ""
|
|
485
542
|
}
|
|
486
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
|
+
|
|
487
562
|
// After a navigation settles (content rendered), apply the pending scroll target on the next frame:
|
|
488
563
|
// a fragment's element for a cross-page `#hash`, the saved position for back/forward, else the top.
|
|
489
|
-
//
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
492
573
|
const target = pendingScroll
|
|
493
574
|
pendingScroll = null
|
|
494
575
|
requestAnimationFrame(() => {
|
|
495
|
-
if ("hash" in target) {
|
|
576
|
+
if (target !== null && "hash" in target) {
|
|
496
577
|
const el = findAnchor(target.hash)
|
|
497
578
|
if (el !== null) el.scrollIntoView()
|
|
498
579
|
else window.scrollTo(0, 0) // fragment not found → top, like a fresh page load
|
|
499
|
-
} else {
|
|
580
|
+
} else if (target !== null) {
|
|
500
581
|
window.scrollTo(target.pos[0], target.pos[1])
|
|
501
582
|
}
|
|
583
|
+
scan()
|
|
502
584
|
})
|
|
503
585
|
}
|
|
504
|
-
const unsubscribe = router.subscribe(
|
|
586
|
+
const unsubscribe = router.subscribe(settled)
|
|
587
|
+
scan()
|
|
505
588
|
|
|
506
589
|
document.addEventListener("click", onClick)
|
|
507
590
|
document.addEventListener("pointerover", onPrefetch)
|
|
@@ -510,6 +593,7 @@ export function installHistory(
|
|
|
510
593
|
window.addEventListener("beforeunload", onBeforeUnload)
|
|
511
594
|
return () => {
|
|
512
595
|
unsubscribe()
|
|
596
|
+
inView?.disconnect()
|
|
513
597
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
514
598
|
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
515
599
|
document.removeEventListener("click", onClick)
|
|
@@ -532,6 +616,7 @@ export function installHistory(
|
|
|
532
616
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
533
617
|
*/
|
|
534
618
|
export function applyHead(head: Meta): void {
|
|
619
|
+
const documentNonce = currentDocumentNonce()
|
|
535
620
|
if (head.title !== undefined) document.title = head.title
|
|
536
621
|
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
537
622
|
// route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
|
|
@@ -584,6 +669,11 @@ export function applyHead(head: Meta): void {
|
|
|
584
669
|
`[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
|
|
585
670
|
)
|
|
586
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
|
+
}
|
|
587
677
|
const el = document.createElement("script")
|
|
588
678
|
el.setAttribute("type", s.type)
|
|
589
679
|
el.setAttribute("nonce", s.nonce)
|
|
@@ -606,8 +696,11 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
606
696
|
// `data-nifra-revalidate="false"` opts out of the post-action loader revalidation (the action's
|
|
607
697
|
// actionData drives the update); absent or any other value keeps the default revalidation.
|
|
608
698
|
const revalidate = form.dataset.nifraRevalidate !== "false"
|
|
609
|
-
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
|
|
610
|
-
|
|
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
|
|
611
704
|
})
|
|
612
705
|
}
|
|
613
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 {
|
package/src/csp.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { PRE_HYDRATION_GUARD } from "./internal/runtime-contract.ts"
|
|
2
|
+
import { createNonceResolver, type NonceResolver } from "./nonce.ts"
|
|
3
|
+
import type { RenderAdapter } from "./render-seam.ts"
|
|
4
|
+
|
|
5
|
+
/** What {@link CreateCspPolicyOptions.header} receives for one document. */
|
|
6
|
+
export interface CspHeaderContext {
|
|
7
|
+
/**
|
|
8
|
+
* The `script-src` sources this document needs, space-separated: a `'sha256-…'` hash for each
|
|
9
|
+
* constant inline script nifra writes, plus `'nonce-…'` when the document carries a script that is
|
|
10
|
+
* specific to this request. External scripts (the client entry, route chunks, islands) are not
|
|
11
|
+
* listed: allow their origin, usually `'self'`.
|
|
12
|
+
*/
|
|
13
|
+
readonly sources: string
|
|
14
|
+
/** The nonce, when this document carries one. Such a document is `private, no-store`. */
|
|
15
|
+
readonly nonce?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreateCspPolicyOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Build the complete Content-Security-Policy value for one document, or `undefined` to send none:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
readonly header: (context: CspHeaderContext) => string | undefined
|
|
27
|
+
/** Generate the nonce for a document that needs one. Default: 122 random bits, CSP base64-safe. */
|
|
28
|
+
readonly generate?: () => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CSP_POLICY: unique symbol = Symbol.for("nifra.web.cspPolicy")
|
|
32
|
+
|
|
33
|
+
/** How a `createWebApp` app's documents meet a CSP: `"nonce"` (every document carries one, so none is
|
|
34
|
+
* cacheable) or `"hash"` (a {@link CspPolicy}). Read by wrappers that cache documents. */
|
|
35
|
+
export const DOCUMENT_POLICY: unique symbol = Symbol.for("nifra.web.documentPolicy")
|
|
36
|
+
|
|
37
|
+
/** The hash sources for one adapter, and the hydration head they were computed from. */
|
|
38
|
+
interface AdapterSources {
|
|
39
|
+
readonly head: string
|
|
40
|
+
readonly sources: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CspPolicyState {
|
|
44
|
+
readonly header: (context: CspHeaderContext) => string | undefined
|
|
45
|
+
readonly generate: () => string
|
|
46
|
+
readonly adapters: WeakMap<RenderAdapter, AdapterSources | Promise<AdapterSources>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A hash-based Content-Security-Policy for nifra documents - see {@link createCspPolicy}. Opaque: pass
|
|
51
|
+
* it to `createWebApp({ csp })` or `renderPage({ csp })`.
|
|
52
|
+
*/
|
|
53
|
+
export interface CspPolicy {
|
|
54
|
+
readonly [CSP_POLICY]: CspPolicyState
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a hash-based Content-Security-Policy for nifra documents, so a page can carry a strict CSP
|
|
59
|
+
* and still be cached.
|
|
60
|
+
*
|
|
61
|
+
* A per-request nonce makes every document unique: nifra marks a nonce-bearing document
|
|
62
|
+
* `private, no-store`, so no shared cache (ISR, a CDN) may store it. Under this policy a document
|
|
63
|
+
* gets a nonce only when it carries a script specific to this request: a page that `defer()`s streams
|
|
64
|
+
* one per settled value, and `unsafeInlineScript` names the nonce. Every other document is nonce-free.
|
|
65
|
+
* Its inline scripts are the constant ones nifra writes, listed by hash, so the CSP header is the
|
|
66
|
+
* same on every request and the page stays cacheable. Page data rides in an inert JSON script, which
|
|
67
|
+
* a CSP does not govern.
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* const csp = createCspPolicy({
|
|
71
|
+
* header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`,
|
|
72
|
+
* })
|
|
73
|
+
* const app = createWebApp({ adapter, manifest, clientEntry, csp })
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function createCspPolicy(options: CreateCspPolicyOptions): CspPolicy {
|
|
77
|
+
if (typeof options?.header !== "function") {
|
|
78
|
+
throw new TypeError("[nifra/web] createCspPolicy requires a header function")
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
[CSP_POLICY]: {
|
|
82
|
+
header: options.header,
|
|
83
|
+
// A UUID's hyphens are outside the CSP base64-value grammar; the remaining 122 random bits are
|
|
84
|
+
// plenty for a per-response nonce, on every fetch runtime.
|
|
85
|
+
generate: options.generate ?? (() => crypto.randomUUID().replaceAll("-", "")),
|
|
86
|
+
adapters: new WeakMap(),
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True for a value made by {@link createCspPolicy}. */
|
|
92
|
+
export const isCspPolicy = (value: unknown): value is CspPolicy =>
|
|
93
|
+
typeof value === "object" && value !== null && CSP_POLICY in value
|
|
94
|
+
|
|
95
|
+
// A closing tag ends at any space, slash or `>` after its name, as a browser reads it.
|
|
96
|
+
const INLINE_SCRIPT = /<script\b([^>]*)>([\s\S]*?)<\/script(?=[\s/>])[^>]*>/gi
|
|
97
|
+
|
|
98
|
+
/** The bodies of the executable inline scripts in a fragment of markup (no `src`, no inert type). */
|
|
99
|
+
const inlineScriptBodies = (html: string): string[] => {
|
|
100
|
+
const bodies: string[] = []
|
|
101
|
+
for (const match of html.matchAll(INLINE_SCRIPT)) {
|
|
102
|
+
const attrs = match[1] ?? ""
|
|
103
|
+
if (/\bsrc\s*=/i.test(attrs) || /\btype\s*=\s*["']?application\/(ld\+)?json/i.test(attrs))
|
|
104
|
+
continue
|
|
105
|
+
bodies.push(match[2] ?? "")
|
|
106
|
+
}
|
|
107
|
+
return bodies
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const ENCODER = new TextEncoder()
|
|
111
|
+
|
|
112
|
+
/** A CSP hash source for one inline script body: `'sha256-<base64 of the UTF-8 bytes>'`. */
|
|
113
|
+
async function hashSource(body: string): Promise<string> {
|
|
114
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", ENCODER.encode(body)))
|
|
115
|
+
let binary = ""
|
|
116
|
+
for (const byte of digest) binary += String.fromCharCode(byte)
|
|
117
|
+
return `'sha256-${btoa(binary)}'`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The `'sha256-…'` sources for the constant inline scripts nifra writes into a document rendered
|
|
122
|
+
* with `adapter`: the pre-hydration form guard and the adapter's hydration head. For a CSP set outside
|
|
123
|
+
* the app (a proxy, a CDN rule); {@link createCspPolicy} computes the same list itself.
|
|
124
|
+
*/
|
|
125
|
+
export async function nifraScriptHashes(adapter: RenderAdapter): Promise<readonly string[]> {
|
|
126
|
+
const bodies = [PRE_HYDRATION_GUARD, ...inlineScriptBodies(adapter.hydrationHead())]
|
|
127
|
+
return Promise.all([...new Set(bodies)].map(hashSource))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute (once per adapter) the policy's hash sources. Returns `undefined` once they are ready, so
|
|
132
|
+
* the renderer stays synchronous after the first document.
|
|
133
|
+
*/
|
|
134
|
+
export function prepareCspPolicy(
|
|
135
|
+
policy: CspPolicy,
|
|
136
|
+
adapter: RenderAdapter,
|
|
137
|
+
): Promise<void> | undefined {
|
|
138
|
+
const state = policy[CSP_POLICY]
|
|
139
|
+
const known = state.adapters.get(adapter)
|
|
140
|
+
if (known !== undefined) return known instanceof Promise ? known.then(() => undefined) : undefined
|
|
141
|
+
const head = adapter.hydrationHead()
|
|
142
|
+
const pending = nifraScriptHashes(adapter).then((hashes) => {
|
|
143
|
+
const sources = { head, sources: hashes.join(" ") }
|
|
144
|
+
state.adapters.set(adapter, sources)
|
|
145
|
+
return sources
|
|
146
|
+
})
|
|
147
|
+
state.adapters.set(adapter, pending)
|
|
148
|
+
// A failed digest must not pin a rejected promise: the next document retries.
|
|
149
|
+
pending.catch(() => state.adapters.delete(adapter))
|
|
150
|
+
return pending.then(() => undefined)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const preparedSources = (policy: CspPolicy, adapter: RenderAdapter): AdapterSources => {
|
|
154
|
+
const known = policy[CSP_POLICY].adapters.get(adapter)
|
|
155
|
+
if (known === undefined || known instanceof Promise) {
|
|
156
|
+
throw new Error("[nifra/web] CSP policy used before prepareCspPolicy resolved")
|
|
157
|
+
}
|
|
158
|
+
return known
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The nonce one document carries under `policy`: `undefined` unless the document needs one -
|
|
163
|
+
* `needsNonce` (a deferred value or a head tag naming the nonce), or an adapter whose hydration head
|
|
164
|
+
* no longer matches the one the policy hashed. A needed nonce reuses the caller's, else is generated.
|
|
165
|
+
*/
|
|
166
|
+
export function cspDocumentNonce(
|
|
167
|
+
policy: CspPolicy,
|
|
168
|
+
adapter: RenderAdapter,
|
|
169
|
+
hydrate: boolean,
|
|
170
|
+
needsNonce: boolean,
|
|
171
|
+
requested: string | undefined,
|
|
172
|
+
): string | undefined {
|
|
173
|
+
const varies = hydrate && adapter.hydrationHead() !== preparedSources(policy, adapter).head
|
|
174
|
+
if (!needsNonce && !varies) return undefined
|
|
175
|
+
return requested ?? policy[CSP_POLICY].generate()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The Content-Security-Policy value for one document, or `undefined` when the policy sends none. */
|
|
179
|
+
export function cspHeaderValue(
|
|
180
|
+
policy: CspPolicy,
|
|
181
|
+
adapter: RenderAdapter,
|
|
182
|
+
nonce: string | undefined,
|
|
183
|
+
): string | undefined {
|
|
184
|
+
const { sources } = preparedSources(policy, adapter)
|
|
185
|
+
const state = policy[CSP_POLICY]
|
|
186
|
+
return nonce === undefined
|
|
187
|
+
? state.header({ sources })
|
|
188
|
+
: state.header({ sources: `${sources} 'nonce-${nonce}'`, nonce })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The per-request nonce source for a page executor under `policy`: generated eagerly so `meta` can
|
|
192
|
+
* name it in `unsafeInlineScript`, and dropped by the renderer from documents that do not need it. */
|
|
193
|
+
export const cspNonceResolver = (policy: CspPolicy): NonceResolver =>
|
|
194
|
+
createNonceResolver({ generate: () => policy[CSP_POLICY].generate() })
|