@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/isr.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
|
|
8
8
|
*/
|
|
9
9
|
import { isDraftEnabled } from "./draft.ts"
|
|
10
|
-
import { timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
10
|
+
import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
11
11
|
|
|
12
12
|
/** A cached SSR response - the bytes + metadata a {@link CacheStore} persists. */
|
|
13
13
|
export interface CachedResponse {
|
|
@@ -51,7 +51,7 @@ function normalizeTag(tag: string): string {
|
|
|
51
51
|
return tag
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
|
|
54
|
+
export function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
|
|
55
55
|
if (tags === undefined) return []
|
|
56
56
|
if (!Array.isArray(tags) || tags.length > MAX_ISR_TAGS) {
|
|
57
57
|
throw new TypeError("[nifra/web] ISR tags must contain at most 32 bounded tokens")
|
|
@@ -61,7 +61,8 @@ function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
|
|
|
61
61
|
return Object.freeze([...unique])
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/** The route tags a response carries on {@link ISR_REVALIDATE_TAGS_HEADER}; malformed means none. */
|
|
65
|
+
export function tagsFromHeader(value: string | null): readonly string[] {
|
|
65
66
|
if (value === null || value.trim() === "") return []
|
|
66
67
|
try {
|
|
67
68
|
return normalizeTags(value.split(",").map((tag) => tag.trim()))
|
|
@@ -78,6 +79,51 @@ export function serializeISRTags(tags: readonly string[]): string | undefined {
|
|
|
78
79
|
return normalized.length === 0 ? undefined : normalized.join(",")
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/** What a `revalidateTags` function is given: the URL and route params only, both already public. */
|
|
83
|
+
export interface RevalidateTagsInput {
|
|
84
|
+
readonly params: Readonly<Record<string, string>>
|
|
85
|
+
readonly url: URL
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A route's `revalidateTags`: a fixed list, or one computed per request from its params and URL. The
|
|
90
|
+
* function is declared as a method so a route may annotate its own params (`{ params: { id: string } }`).
|
|
91
|
+
*/
|
|
92
|
+
export type RevalidateTags =
|
|
93
|
+
| readonly string[]
|
|
94
|
+
| { tags(input: RevalidateTagsInput): readonly string[] }["tags"]
|
|
95
|
+
|
|
96
|
+
const warnedTagRoutes = new Set<string>()
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tags `declared` gives this request. A fixed list is returned as declared (it is validated when
|
|
100
|
+
* the header is written). A function's result keeps its valid, distinct tags, at most 32; anything
|
|
101
|
+
* else is dropped with one warning per route, naming the route but never the tag.
|
|
102
|
+
*/
|
|
103
|
+
export function routeTags(
|
|
104
|
+
declared: RevalidateTags,
|
|
105
|
+
input: RevalidateTagsInput,
|
|
106
|
+
routeId: string,
|
|
107
|
+
): readonly string[] {
|
|
108
|
+
if (typeof declared !== "function") return declared
|
|
109
|
+
const result: unknown = declared(input)
|
|
110
|
+
const listed: readonly unknown[] = Array.isArray(result) ? result : []
|
|
111
|
+
const kept = new Set<string>()
|
|
112
|
+
let dropped = 0
|
|
113
|
+
for (const tag of listed) {
|
|
114
|
+
if (typeof tag === "string" && ISR_TAG_PATTERN.test(tag) && kept.size < MAX_ISR_TAGS) {
|
|
115
|
+
kept.add(tag)
|
|
116
|
+
} else dropped++
|
|
117
|
+
}
|
|
118
|
+
if ((dropped > 0 || !Array.isArray(result)) && !warnedTagRoutes.has(routeId)) {
|
|
119
|
+
warnedTagRoutes.add(routeId)
|
|
120
|
+
console.warn(
|
|
121
|
+
`[nifra/web] NIFRA_CDN_TAG_INVALID: revalidateTags of route "${routeId}" returned ${Array.isArray(result) ? `${dropped} tag(s) that are not a letter followed by up to 127 of A-Z a-z 0-9 . _ : / -, or past the first 32` : "something other than an array"}; those were dropped.`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return [...kept]
|
|
125
|
+
}
|
|
126
|
+
|
|
81
127
|
export interface MemoryCacheStoreOptions {
|
|
82
128
|
/** Allow the in-memory store in production. Off by default - per-instance caching means revalidation
|
|
83
129
|
* won't propagate across instances and each instance caches separately. */
|
|
@@ -389,6 +435,34 @@ export const ISR_REVALIDATE_TAGS_HEADER = "x-nifra-isr-tags"
|
|
|
389
435
|
*/
|
|
390
436
|
export const ISR_REVALIDATE_HEADER = "x-nifra-isr-revalidate"
|
|
391
437
|
|
|
438
|
+
/** A `createWebApp` app answers this with a function that turns its ISR headers on. A wrapper that
|
|
439
|
+
* reads them calls it, so an app nothing caches never sends a route's freshness or tags. */
|
|
440
|
+
export const CACHE_CHANNEL: unique symbol = Symbol.for("nifra.web.cacheChannel")
|
|
441
|
+
|
|
442
|
+
/** Ask `app` to emit its ISR headers; an app without the channel emits whatever it emits. */
|
|
443
|
+
export function openCacheChannel(app: object): void {
|
|
444
|
+
const open: unknown = Reflect.get(app, CACHE_CHANNEL)
|
|
445
|
+
if (typeof open === "function") open()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Drop the ISR headers from a response that leaves the cache layer. */
|
|
449
|
+
export function withoutCacheChannel(res: Response): Response {
|
|
450
|
+
if (!res.headers.has(ISR_REVALIDATE_HEADER) && !res.headers.has(ISR_REVALIDATE_TAGS_HEADER)) {
|
|
451
|
+
return res
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
res.headers.delete(ISR_REVALIDATE_HEADER)
|
|
455
|
+
res.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
|
|
456
|
+
return res
|
|
457
|
+
} catch {
|
|
458
|
+
// A `fetch()` response has immutable headers: rebuild it around the same body instead.
|
|
459
|
+
const headers = new Headers(res.headers)
|
|
460
|
+
headers.delete(ISR_REVALIDATE_HEADER)
|
|
461
|
+
headers.delete(ISR_REVALIDATE_TAGS_HEADER)
|
|
462
|
+
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
392
466
|
export interface ISROptions {
|
|
393
467
|
readonly store: CacheStore
|
|
394
468
|
/** Default freshness window (**seconds**) for a cached page; older ⇒ stale (served, regenerated
|
|
@@ -397,21 +471,68 @@ export interface ISROptions {
|
|
|
397
471
|
readonly revalidate: number
|
|
398
472
|
/** Monotonic clock (ms) - injected for testability; production passes `() => Date.now()`. */
|
|
399
473
|
readonly now: () => number
|
|
400
|
-
/** Cache key for a request. Default: `origin + pathname
|
|
401
|
-
* entries across tenants. Return `null` to bypass the cache for
|
|
402
|
-
* app, uncached). */
|
|
474
|
+
/** Cache key for a request. Default: `origin + pathname`, plus the query parameters `query` admits,
|
|
475
|
+
* so host-routed apps do not share entries across tenants. Return `null` to bypass the cache for
|
|
476
|
+
* this request (it goes straight to the app, uncached). Replaces `query` entirely. */
|
|
403
477
|
readonly key?: (req: Request) => string | null
|
|
478
|
+
/**
|
|
479
|
+
* How the query string reaches the default key. Default `"bypass"`: a request carrying any query
|
|
480
|
+
* parameter skips the cache - rendered fresh, never stored - so `?a=1`, `?a=2`, ... cannot each
|
|
481
|
+
* store a full page. A list caches by those parameters only, in any order (`?b=2&a=1` and
|
|
482
|
+
* `?a=1&b=2` share an entry); a request carrying any other parameter still bypasses, because a
|
|
483
|
+
* loader that reads it would otherwise be served another request's page. `"all"` keys on the whole
|
|
484
|
+
* query string, so every distinct query is a new entry.
|
|
485
|
+
*/
|
|
486
|
+
readonly query?: ISRQuery
|
|
404
487
|
/** Draft/preview secret (the same one given to `createWebApp({ draftSecret })` + `enableDraft`). When
|
|
405
488
|
* set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
|
|
406
489
|
* fresh, and a draft render is never written to the store (it can't poison the public cache). */
|
|
407
490
|
readonly draftSecret?: string
|
|
408
491
|
}
|
|
409
492
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
493
|
+
/** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
|
|
494
|
+
* through the global symbol registry so a cache wrapper never imports the CSP module. */
|
|
495
|
+
const DOCUMENT_POLICY = Symbol.for("nifra.web.documentPolicy")
|
|
496
|
+
|
|
497
|
+
/** Remembered `no-store` keys per `withISR` wrapper. */
|
|
498
|
+
const MAX_UNCACHEABLE_KEYS = 1024
|
|
499
|
+
|
|
500
|
+
/** Which query parameters an ISR key carries - see {@link ISROptions.query}. */
|
|
501
|
+
export type ISRQuery = "bypass" | "all" | readonly string[]
|
|
502
|
+
|
|
503
|
+
/** The default key for a URL under a `query` policy, or `null` when the URL must bypass the cache. */
|
|
504
|
+
export const urlKeyOf = (query: ISRQuery = "bypass"): ((url: URL) => string | null) => {
|
|
505
|
+
if (query === "all") return (url) => url.origin + url.pathname + url.search
|
|
506
|
+
if (query !== "bypass" && !Array.isArray(query)) {
|
|
507
|
+
throw new TypeError(
|
|
508
|
+
'[nifra/web] ISR query must be "bypass", "all", or a list of parameter names',
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
const allowed = new Set<string>(query === "bypass" ? [] : query)
|
|
512
|
+
for (const name of allowed) {
|
|
513
|
+
if (typeof name !== "string" || name === "") {
|
|
514
|
+
throw new TypeError("[nifra/web] ISR query parameter names must be non-empty strings")
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return (url) => {
|
|
518
|
+
if (url.search === "") return url.origin + url.pathname
|
|
519
|
+
if (allowed.size === 0) return null
|
|
520
|
+
const entries: [string, string][] = []
|
|
521
|
+
for (const entry of url.searchParams) {
|
|
522
|
+
if (!allowed.has(entry[0])) return null
|
|
523
|
+
entries.push(entry)
|
|
524
|
+
}
|
|
525
|
+
// Stable by name, so a repeated name keeps its value order (`getAll` can tell them apart).
|
|
526
|
+
entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
527
|
+
return `${url.origin}${url.pathname}?${new URLSearchParams(entries)}`
|
|
528
|
+
}
|
|
413
529
|
}
|
|
414
530
|
|
|
531
|
+
const requestKeyOf =
|
|
532
|
+
(urlKey: (url: URL) => string | null) =>
|
|
533
|
+
(req: Request): string | null =>
|
|
534
|
+
urlKey(new URL(req.url))
|
|
535
|
+
|
|
415
536
|
function assertRevalidate(value: number, name: string): void {
|
|
416
537
|
if (!Number.isFinite(value) || value < 0) {
|
|
417
538
|
throw new RangeError(`[nifra/web] ${name} must be a finite non-negative number`)
|
|
@@ -439,12 +560,25 @@ const requestCarriesPrivateState = (req: Request): boolean =>
|
|
|
439
560
|
const responseIsExplicitlyPublic = (res: Response): boolean =>
|
|
440
561
|
cacheControlHas(res.headers, ["public"])
|
|
441
562
|
|
|
563
|
+
// `x-nifra-data` alone is not a variation the URL key misses: a navigation data request never reads
|
|
564
|
+
// or writes the store, so every stored entry is the document.
|
|
442
565
|
const responseDeclaresVary = (res: Response): boolean => {
|
|
443
566
|
const value = res.headers.get("vary")
|
|
444
|
-
|
|
567
|
+
if (value === null) return false
|
|
568
|
+
for (const part of value.split(",")) {
|
|
569
|
+
const name = part.trim().toLowerCase()
|
|
570
|
+
if (name !== "" && name !== "x-nifra-data") return true
|
|
571
|
+
}
|
|
572
|
+
return false
|
|
445
573
|
}
|
|
446
574
|
|
|
447
|
-
|
|
575
|
+
/**
|
|
576
|
+
* Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
|
|
577
|
+
* full-document `GET` 200 `text/html` with no Set-Cookie, no `private`/`no-store`, no `Vary` beyond
|
|
578
|
+
* the data header, and, for a request carrying a cookie or Authorization, an explicit `public`.
|
|
579
|
+
* The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
|
|
580
|
+
*/
|
|
581
|
+
export const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
448
582
|
if (req.method !== "GET") return false
|
|
449
583
|
if (req.headers.get("x-nifra-data") !== null) return false
|
|
450
584
|
if (res.status !== 200) return false
|
|
@@ -481,6 +615,7 @@ const CACHEABLE_RESPONSE_HEADERS = new Set([
|
|
|
481
615
|
"vary",
|
|
482
616
|
"x-content-type-options",
|
|
483
617
|
"x-frame-options",
|
|
618
|
+
"x-robots-tag",
|
|
484
619
|
])
|
|
485
620
|
|
|
486
621
|
const isCacheableResponseHeader = (key: string): boolean => {
|
|
@@ -538,9 +673,47 @@ export function withISR(
|
|
|
538
673
|
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
539
674
|
const { store, now } = options
|
|
540
675
|
assertRevalidate(options.revalidate, "revalidate")
|
|
541
|
-
const keyOf = options.key ??
|
|
676
|
+
const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
|
|
542
677
|
const draftSecret = options.draftSecret
|
|
543
678
|
const regenerating = new Set<string>()
|
|
679
|
+
openCacheChannel(app)
|
|
680
|
+
// An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
|
|
681
|
+
// much freshness they have left and their tags, so the CDN never holds a page longer than ISR would.
|
|
682
|
+
let channelOpen = false
|
|
683
|
+
const advertise = (headers: Headers, remainingMs: number, tags: readonly string[]): void => {
|
|
684
|
+
if (!channelOpen) return
|
|
685
|
+
headers.set(ISR_REVALIDATE_HEADER, String(Math.max(0, Math.floor(remainingMs / 1000))))
|
|
686
|
+
const serialized = tags.length === 0 ? undefined : tags.join(",")
|
|
687
|
+
if (serialized !== undefined) headers.set(ISR_REVALIDATE_TAGS_HEADER, serialized)
|
|
688
|
+
}
|
|
689
|
+
const served = (entry: CachedResponse, status: "hit" | "stale"): Response => {
|
|
690
|
+
const res = responseFrom(entry, status)
|
|
691
|
+
advertise(
|
|
692
|
+
res.headers,
|
|
693
|
+
status === "stale" ? 0 : entry.revalidate - (now() - entry.storedAt),
|
|
694
|
+
entry.tags ?? [],
|
|
695
|
+
)
|
|
696
|
+
return res
|
|
697
|
+
}
|
|
698
|
+
if ((app as unknown as Record<symbol, unknown>)[DOCUMENT_POLICY] === "nonce") {
|
|
699
|
+
console.warn(
|
|
700
|
+
"[nifra/web] withISR wraps an app whose every document carries a CSP nonce. Such documents are " +
|
|
701
|
+
"`private, no-store`, so this cache will never store a page. Use " +
|
|
702
|
+
"`createWebApp({ csp: createCspPolicy(...) })` instead of `nonce` to cache pages under a CSP.",
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
// Keys whose last render declared itself `private` or `no-store`, with when that memo expires. A
|
|
706
|
+
// remembered key skips the store lookup (a network round trip on a shared store) and renders fresh;
|
|
707
|
+
// a render that turns out cacheable is stored and forgets the key. So the memo can only cost a cache
|
|
708
|
+
// hit, never serve the wrong page. Bounded, oldest first, so a URL flood cannot grow it.
|
|
709
|
+
const uncacheable = new Map<string, number>()
|
|
710
|
+
const rememberUncacheable = (key: string, res: Response): void => {
|
|
711
|
+
if (options.revalidate === 0 || !cacheControlHas(res.headers, ["private", "no-store"])) return
|
|
712
|
+
if (uncacheable.size >= MAX_UNCACHEABLE_KEYS) {
|
|
713
|
+
uncacheable.delete(uncacheable.keys().next().value as string)
|
|
714
|
+
}
|
|
715
|
+
uncacheable.set(key, now() + options.revalidate * 1000)
|
|
716
|
+
}
|
|
544
717
|
|
|
545
718
|
// Per-page TTL (ms): the app's `x-nifra-isr-revalidate` header (seconds) if present, else the default.
|
|
546
719
|
const ttlMs = (res: Response): number => {
|
|
@@ -556,7 +729,11 @@ export function withISR(
|
|
|
556
729
|
key: string,
|
|
557
730
|
): Promise<Response> => {
|
|
558
731
|
const res = await app.fetch(req, platform)
|
|
559
|
-
if (!isCacheablePage(req, res))
|
|
732
|
+
if (!isCacheablePage(req, res)) {
|
|
733
|
+
rememberUncacheable(key, res)
|
|
734
|
+
return withoutCacheChannel(res)
|
|
735
|
+
}
|
|
736
|
+
uncacheable.delete(key)
|
|
560
737
|
const body = await res.text()
|
|
561
738
|
const tags = tagsOf(res)
|
|
562
739
|
const entry: CachedResponse = {
|
|
@@ -568,10 +745,12 @@ export function withISR(
|
|
|
568
745
|
...(tags.length === 0 ? {} : { tags }),
|
|
569
746
|
}
|
|
570
747
|
await store.set(key, entry)
|
|
571
|
-
|
|
748
|
+
const fresh = new Response(body, {
|
|
572
749
|
status: res.status,
|
|
573
750
|
headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
|
|
574
751
|
})
|
|
752
|
+
advertise(fresh.headers, entry.revalidate, tags)
|
|
753
|
+
return fresh
|
|
575
754
|
}
|
|
576
755
|
|
|
577
756
|
// Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
|
|
@@ -610,35 +789,68 @@ export function withISR(
|
|
|
610
789
|
}
|
|
611
790
|
}
|
|
612
791
|
|
|
613
|
-
|
|
792
|
+
const handler = async (req: Request, platform?: ISRPlatform): Promise<Response> => {
|
|
614
793
|
// A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
|
|
615
794
|
// by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
|
|
616
795
|
// the loader payload. (The write path already refuses to cache data-mode responses.)
|
|
617
796
|
const key = req.method === "GET" && req.headers.get("x-nifra-data") === null ? keyOf(req) : null
|
|
618
|
-
if (key === null) return app.fetch(req, platform)
|
|
797
|
+
if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
|
|
619
798
|
|
|
620
799
|
// Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
|
|
621
800
|
// unpublished content can't leak into the public cache (and the editor isn't served a stale page).
|
|
622
801
|
if (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret))) {
|
|
623
|
-
return app.fetch(req, platform)
|
|
802
|
+
return withoutCacheChannel(await app.fetch(req, platform))
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const until = uncacheable.get(key)
|
|
806
|
+
if (until !== undefined) {
|
|
807
|
+
if (now() < until) return render(req, platform, key)
|
|
808
|
+
uncacheable.delete(key)
|
|
624
809
|
}
|
|
625
810
|
|
|
626
811
|
const hit = await store.get(key)
|
|
627
812
|
if (hit !== undefined) {
|
|
628
|
-
if (now() - hit.storedAt < hit.revalidate) return
|
|
813
|
+
if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
|
|
629
814
|
// Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
|
|
630
815
|
const task = regenerate(req, platform, key)
|
|
631
816
|
if (typeof platform?.waitUntil === "function") platform.waitUntil(task)
|
|
632
817
|
else void task.catch(() => {})
|
|
633
|
-
return
|
|
818
|
+
return served(hit, "stale")
|
|
634
819
|
}
|
|
635
820
|
return render(req, platform, key)
|
|
636
821
|
}
|
|
822
|
+
Object.defineProperty(handler, CACHE_CHANNEL, {
|
|
823
|
+
value: () => {
|
|
824
|
+
channelOpen = true
|
|
825
|
+
},
|
|
826
|
+
})
|
|
827
|
+
return handler
|
|
637
828
|
}
|
|
638
829
|
|
|
639
830
|
const jsonError = (status: number, error: string): Response =>
|
|
640
831
|
Response.json({ ok: false, error }, { status })
|
|
641
832
|
|
|
833
|
+
/** What a CDN purge came to: sent and accepted, queued behind a rate limit or retry, or refused. */
|
|
834
|
+
export interface CdnPurgeOutcome {
|
|
835
|
+
readonly cdn: "accepted" | "queued" | "failed"
|
|
836
|
+
/** Whether the same purge may succeed if sent again. */
|
|
837
|
+
readonly retryable: boolean
|
|
838
|
+
/** A short reason when it did not succeed (never the provider's credentials). */
|
|
839
|
+
readonly error?: string
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** A CDN a revalidation purges after the origin store; every `@nifrajs/web/cdn` provider is one. */
|
|
843
|
+
export interface CdnPurgeTarget {
|
|
844
|
+
purge(
|
|
845
|
+
target: { readonly tags?: readonly string[]; readonly paths?: readonly string[] },
|
|
846
|
+
platform?: ISRPlatform,
|
|
847
|
+
): Promise<CdnPurgeOutcome>
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Most paths and tags one revalidation request may name, so a leaked token cannot fan out. */
|
|
851
|
+
export const MAX_REVALIDATE_PATHS = 100
|
|
852
|
+
export const MAX_REVALIDATE_TAGS = MAX_ISR_TAGS
|
|
853
|
+
|
|
642
854
|
export interface RevalidateEndpointOptions {
|
|
643
855
|
readonly store: CacheStore
|
|
644
856
|
/** Shared secret; the request's token must match it (constant-time). */
|
|
@@ -648,22 +860,46 @@ export interface RevalidateEndpointOptions {
|
|
|
648
860
|
/** Map a to-purge path → its cache key - MUST match the `withISR` `key` fn. The default uses the
|
|
649
861
|
* revalidation request's origin plus the purged path, matching `withISR`'s default host-aware key. */
|
|
650
862
|
readonly key?: (path: string, req: Request) => string
|
|
863
|
+
/** The `query` policy given to `withISR`, so a purged path's query string is keyed the same way.
|
|
864
|
+
* Default `"bypass"`. A path whose query `withISR` never caches is refused with `400`. Ignored
|
|
865
|
+
* when `key` is supplied. */
|
|
866
|
+
readonly query?: ISRQuery
|
|
867
|
+
/**
|
|
868
|
+
* A CDN to purge after the origin store (a `@nifrajs/web/cdn` provider). The origin goes first so a
|
|
869
|
+
* CDN refetch cannot repopulate from a stale origin entry. The reply is `200` when the CDN accepted
|
|
870
|
+
* the purge, `202` when it is queued (rate limit or retry), and `502` when the CDN refused it.
|
|
871
|
+
*/
|
|
872
|
+
readonly cdn?: CdnPurgeTarget
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const stringList = (value: unknown): string[] | null | undefined => {
|
|
876
|
+
if (value === undefined) return undefined
|
|
877
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null
|
|
878
|
+
return value
|
|
651
879
|
}
|
|
652
880
|
|
|
653
881
|
/**
|
|
654
|
-
* An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops
|
|
882
|
+
* An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops cached pages by path
|
|
655
883
|
* or invalidates every entry carrying a tag. `POST` with the secret in the token header and either
|
|
656
|
-
* `?path=/blog/x`, `?tag=products`,
|
|
884
|
+
* `?path=/blog/x`, `?tag=products`, a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body, or a
|
|
885
|
+
* batch `{ "paths": [...], "tags": [...] }` of at most 100 paths and 32 tags.
|
|
657
886
|
* The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
|
|
658
887
|
* non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
659
888
|
* `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
|
|
660
889
|
*/
|
|
661
890
|
export function revalidateEndpoint(
|
|
662
891
|
options: RevalidateEndpointOptions,
|
|
663
|
-
): (req: Request) => Promise<Response> {
|
|
892
|
+
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
893
|
+
// An empty secret matches a request that omits the header (`"" === ""`), so an unset env var passed
|
|
894
|
+
// through `?? ""` would open the purge to anyone. Refuse it at construction instead.
|
|
895
|
+
assertTokenSecret(options.secret, "revalidateEndpoint")
|
|
664
896
|
const tokenHeader = options.tokenHeader ?? "x-nifra-revalidate-token"
|
|
665
|
-
const
|
|
666
|
-
|
|
897
|
+
const urlKey = options.key === undefined ? urlKeyOf(options.query) : undefined
|
|
898
|
+
// Joined as text, never resolved against the origin: `//host/x` must stay a path on this origin.
|
|
899
|
+
const keyOf =
|
|
900
|
+
options.key ??
|
|
901
|
+
((path: string, req: Request) => urlKey?.(new URL(new URL(req.url).origin + path)) ?? null)
|
|
902
|
+
return async (req, platform) => {
|
|
667
903
|
if (req.method !== "POST") return jsonError(405, "method_not_allowed")
|
|
668
904
|
if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
|
|
669
905
|
return jsonError(401, "unauthorized")
|
|
@@ -671,29 +907,81 @@ export function revalidateEndpoint(
|
|
|
671
907
|
const url = new URL(req.url)
|
|
672
908
|
let path = url.searchParams.get("path")
|
|
673
909
|
let tag = url.searchParams.get("tag")
|
|
910
|
+
let batch: { paths: string[]; tags: string[] } | undefined
|
|
674
911
|
if (path === null && tag === null) {
|
|
675
912
|
const body: unknown = await req.json().catch(() => null)
|
|
676
913
|
if (typeof body === "object" && body !== null) {
|
|
677
|
-
const candidate = body as { path?: unknown; tag?: unknown }
|
|
678
|
-
|
|
679
|
-
|
|
914
|
+
const candidate = body as { path?: unknown; tag?: unknown; paths?: unknown; tags?: unknown }
|
|
915
|
+
if (candidate.paths !== undefined || candidate.tags !== undefined) {
|
|
916
|
+
const paths = stringList(candidate.paths)
|
|
917
|
+
const tags = stringList(candidate.tags)
|
|
918
|
+
if (paths === null || tags === null) return jsonError(400, "invalid_batch")
|
|
919
|
+
if ((paths?.length ?? 0) > MAX_REVALIDATE_PATHS) return jsonError(400, "too_many_paths")
|
|
920
|
+
if ((tags?.length ?? 0) > MAX_REVALIDATE_TAGS) return jsonError(400, "too_many_tags")
|
|
921
|
+
batch = { paths: paths ?? [], tags: tags ?? [] }
|
|
922
|
+
if (batch.paths.length + batch.tags.length === 0) return jsonError(400, "empty_batch")
|
|
923
|
+
} else {
|
|
924
|
+
path = typeof candidate.path === "string" ? candidate.path : null
|
|
925
|
+
tag = typeof candidate.tag === "string" ? candidate.tag : null
|
|
926
|
+
}
|
|
680
927
|
}
|
|
681
928
|
}
|
|
682
|
-
if (
|
|
683
|
-
|
|
929
|
+
if (batch === undefined) {
|
|
930
|
+
if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
|
|
931
|
+
if (path === null && tag === null) return jsonError(400, "invalid_path")
|
|
932
|
+
batch = { paths: path === null ? [] : [path], tags: tag === null ? [] : [tag] }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const tags = [...new Set(batch.tags)]
|
|
936
|
+
for (const candidate of tags) {
|
|
684
937
|
try {
|
|
685
|
-
normalizeTag(
|
|
938
|
+
normalizeTag(candidate)
|
|
686
939
|
} catch {
|
|
687
940
|
return jsonError(400, "invalid_tag")
|
|
688
941
|
}
|
|
689
|
-
|
|
690
|
-
|
|
942
|
+
}
|
|
943
|
+
const paths = [...new Set(batch.paths)]
|
|
944
|
+
const keys: string[] = []
|
|
945
|
+
for (const candidate of paths) {
|
|
946
|
+
if (!candidate.startsWith("/")) return jsonError(400, "invalid_path")
|
|
947
|
+
const key = keyOf(candidate, req)
|
|
948
|
+
if (key === null) return jsonError(400, "uncached_query")
|
|
949
|
+
keys.push(key)
|
|
950
|
+
}
|
|
951
|
+
const invalidateTag = options.store.invalidateTag
|
|
952
|
+
if (tags.length > 0 && invalidateTag === undefined) {
|
|
953
|
+
return jsonError(501, "tag_invalidation_not_supported")
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
for (const key of keys) await options.store.delete(key)
|
|
957
|
+
for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
|
|
958
|
+
const done =
|
|
959
|
+
path !== null
|
|
960
|
+
? { revalidated: path }
|
|
961
|
+
: tag !== null
|
|
962
|
+
? { revalidatedTag: tag }
|
|
963
|
+
: { revalidated: paths, revalidatedTags: tags }
|
|
964
|
+
if (options.cdn === undefined) return Response.json(done)
|
|
965
|
+
|
|
966
|
+
let outcome: CdnPurgeOutcome
|
|
967
|
+
try {
|
|
968
|
+
outcome = await options.cdn.purge({ tags, paths }, platform)
|
|
969
|
+
} catch {
|
|
970
|
+
outcome = { cdn: "failed", retryable: true, error: "cdn_purge_threw" }
|
|
971
|
+
}
|
|
972
|
+
if (outcome.cdn === "failed") {
|
|
973
|
+
const failure: Record<string, unknown> = {
|
|
974
|
+
ok: false,
|
|
975
|
+
error: "cdn_purge_failed",
|
|
976
|
+
origin: "done",
|
|
977
|
+
retryable: outcome.retryable,
|
|
691
978
|
}
|
|
692
|
-
|
|
693
|
-
return Response.json({
|
|
979
|
+
if (outcome.error !== undefined) failure.reason = outcome.error
|
|
980
|
+
return Response.json(failure, { status: 502 })
|
|
694
981
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
982
|
+
return Response.json(
|
|
983
|
+
{ ...done, cdn: outcome.cdn },
|
|
984
|
+
{ status: outcome.cdn === "queued" ? 202 : 200 },
|
|
985
|
+
)
|
|
698
986
|
}
|
|
699
987
|
}
|
package/src/llms-txt.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
reflectedRoutesFromEvidence,
|
|
4
4
|
snapshotProjectEvidence,
|
|
5
5
|
} from "@nifrajs/core/evidence"
|
|
6
|
+
import { compileRoutePattern, type RoutePatternSegment } from "@nifrajs/core/pattern"
|
|
6
7
|
|
|
7
8
|
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
|
8
9
|
|
|
@@ -58,6 +59,32 @@ function tsTypeOf(schema: unknown, depth = 0): string {
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The call a non-static segment adds to the typed-client chain, or `undefined` for a static one. Read
|
|
64
|
+
* by the router's own grammar: a whole param or a wildcard is called by its name (`:id` and
|
|
65
|
+
* `:id{[0-9]+}` are `({ id })`), and a segment that is part literal, part parameter is called with
|
|
66
|
+
* the segment text (`:name.json` is ``(`${name}.json`)``).
|
|
67
|
+
*/
|
|
68
|
+
function paramCall(seg: string): string | undefined {
|
|
69
|
+
let segment: RoutePatternSegment | undefined
|
|
70
|
+
try {
|
|
71
|
+
segment = compileRoutePattern(`/${seg}`).segments[0]
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
if (segment === undefined || segment.kind === "static") return undefined
|
|
76
|
+
if (segment.kind === "param") return `({ ${segment.name} })`
|
|
77
|
+
if (segment.kind === "wildcard") {
|
|
78
|
+
return segment.name === "*" ? '({ "*": rest })' : `({ ${segment.name} })`
|
|
79
|
+
}
|
|
80
|
+
const [only] = segment.parts
|
|
81
|
+
if (segment.parts.length === 1 && only?.t === "param") return `({ ${only.name} })`
|
|
82
|
+
const text = segment.parts
|
|
83
|
+
.map((part) => (part.t === "lit" ? part.v.replace(/[`\\]|\$\{/g, "\\$&") : `\${${part.name}}`))
|
|
84
|
+
.join("")
|
|
85
|
+
return `(\`${text}\`)`
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
function clientCall(method: string, path: string, schema: unknown): string {
|
|
62
89
|
const s = schema as { body?: unknown; query?: unknown } | undefined
|
|
63
90
|
const verb = method.toLowerCase()
|
|
@@ -66,10 +93,7 @@ function clientCall(method: string, path: string, schema: unknown): string {
|
|
|
66
93
|
if (segs.length === 0) chain += ".index"
|
|
67
94
|
else
|
|
68
95
|
for (const seg of segs) {
|
|
69
|
-
|
|
70
|
-
const name = seg.replace(/^[:*]/, "") || "value"
|
|
71
|
-
chain += `({ ${name} })`
|
|
72
|
-
} else chain += IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`
|
|
96
|
+
chain += paramCall(seg) ?? (IDENT.test(seg) ? `.${seg}` : `[${JSON.stringify(seg)}]`)
|
|
73
97
|
}
|
|
74
98
|
const isBodyVerb = verb === "post" || verb === "put" || verb === "patch"
|
|
75
99
|
let call: string
|