@nifrajs/web 4.0.2 → 5.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/README.md +11 -0
- package/dist/build-vite.d.ts +19 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +31 -10
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +8 -10
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +90 -31
- package/dist/build.js.map +1 -1
- package/dist/cdn.d.ts +8 -0
- package/dist/cdn.d.ts.map +1 -1
- package/dist/cdn.js +14 -15
- package/dist/cdn.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -7
- package/dist/client.js.map +1 -1
- package/dist/deferred.d.ts +2 -2
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +28 -7
- package/dist/deferred.js.map +1 -1
- package/dist/dev-feed.d.ts.map +1 -1
- package/dist/dev-feed.js +24 -5
- package/dist/dev-feed.js.map +1 -1
- package/dist/dev-session.d.ts.map +1 -1
- package/dist/dev-session.js +4 -14
- package/dist/dev-session.js.map +1 -1
- package/dist/dev.d.ts +7 -17
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +13 -1
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +8 -5
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +30 -13
- package/dist/draft.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +7 -0
- package/dist/fonts-google.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +1 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +56 -29
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/disposable-dir.d.ts +8 -0
- package/dist/internal/disposable-dir.d.ts.map +1 -0
- package/dist/internal/disposable-dir.js +25 -0
- package/dist/internal/disposable-dir.js.map +1 -0
- package/dist/internal/entry-chunks.d.ts +9 -0
- package/dist/internal/entry-chunks.d.ts.map +1 -0
- package/dist/internal/entry-chunks.js +15 -0
- package/dist/internal/entry-chunks.js.map +1 -0
- package/dist/internal/evidence-traversal.d.ts +12 -0
- package/dist/internal/evidence-traversal.d.ts.map +1 -0
- package/dist/internal/evidence-traversal.js +48 -0
- package/dist/internal/evidence-traversal.js.map +1 -0
- package/dist/internal/head-merge.d.ts +6 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +9 -0
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/js-string.d.ts +2 -0
- package/dist/internal/js-string.d.ts.map +1 -1
- package/dist/internal/js-string.js +5 -1
- package/dist/internal/js-string.js.map +1 -1
- package/dist/internal/mount-shadow.js +1 -1
- package/dist/internal/mount-shadow.js.map +1 -1
- package/dist/internal/output-guard.d.ts +7 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +80 -8
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +6 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +377 -108
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/progressive-cookies.d.ts +14 -0
- package/dist/internal/progressive-cookies.d.ts.map +1 -0
- package/dist/internal/progressive-cookies.js +43 -0
- package/dist/internal/progressive-cookies.js.map +1 -0
- package/dist/internal/public-env.d.ts +2 -0
- package/dist/internal/public-env.d.ts.map +1 -0
- package/dist/internal/public-env.js +13 -0
- package/dist/internal/public-env.js.map +1 -0
- package/dist/internal/render-document.d.ts +1 -0
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +28 -5
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/response-controls.d.ts +2 -0
- package/dist/internal/response-controls.d.ts.map +1 -1
- package/dist/internal/response-controls.js +26 -16
- package/dist/internal/response-controls.js.map +1 -1
- package/dist/internal/revive-dates.d.ts +16 -0
- package/dist/internal/revive-dates.d.ts.map +1 -0
- package/dist/internal/revive-dates.js +32 -0
- package/dist/internal/revive-dates.js.map +1 -0
- package/dist/internal/route-path.d.ts +8 -0
- package/dist/internal/route-path.d.ts.map +1 -0
- package/dist/internal/route-path.js +8 -0
- package/dist/internal/route-path.js.map +1 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +88 -20
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +33 -5
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/web-app.d.ts +21 -16
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +73 -21
- package/dist/internal/web-app.js.map +1 -1
- package/dist/internal/wire-date.d.ts +3 -0
- package/dist/internal/wire-date.d.ts.map +1 -0
- package/dist/internal/wire-date.js +14 -0
- package/dist/internal/wire-date.js.map +1 -0
- package/dist/internal/zone-graph.d.ts +4 -0
- package/dist/internal/zone-graph.d.ts.map +1 -1
- package/dist/internal/zone-graph.js +54 -14
- package/dist/internal/zone-graph.js.map +1 -1
- package/dist/isr.d.ts +58 -2
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +296 -39
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +2 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +29 -3
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +28 -4
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +3 -0
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/plugins/kit.d.ts +1 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +26 -11
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +3 -1
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -1
- package/dist/plugins/vite-leak-guard.js +4 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -1
- package/dist/public-dir.d.ts +8 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +17 -6
- package/dist/public-dir.js.map +1 -1
- package/dist/query.d.ts +13 -4
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +89 -26
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +2 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +1 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-types.d.ts.map +1 -1
- package/dist/route-types.js +20 -9
- package/dist/route-types.js.map +1 -1
- package/dist/router.d.ts +9 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +39 -46
- package/dist/router.js.map +1 -1
- package/dist/server-components/build-info.d.ts +3 -0
- package/dist/server-components/build-info.d.ts.map +1 -0
- package/dist/server-components/build-info.js +7 -0
- package/dist/server-components/build-info.js.map +1 -0
- package/dist/server-components/build-target.d.ts +12 -0
- package/dist/server-components/build-target.d.ts.map +1 -0
- package/dist/server-components/build-target.js +153 -0
- package/dist/server-components/build-target.js.map +1 -0
- package/dist/server-components/build.d.ts +24 -0
- package/dist/server-components/build.d.ts.map +1 -0
- package/dist/server-components/build.js +366 -0
- package/dist/server-components/build.js.map +1 -0
- package/dist/server-components/bundle-proof.d.ts +3 -0
- package/dist/server-components/bundle-proof.d.ts.map +1 -0
- package/dist/server-components/bundle-proof.js +23 -0
- package/dist/server-components/bundle-proof.js.map +1 -0
- package/dist/server-components/client.d.ts +46 -0
- package/dist/server-components/client.d.ts.map +1 -0
- package/dist/server-components/client.js +594 -0
- package/dist/server-components/client.js.map +1 -0
- package/dist/server-components/definition.d.ts +11 -0
- package/dist/server-components/definition.d.ts.map +1 -0
- package/dist/server-components/definition.js +94 -0
- package/dist/server-components/definition.js.map +1 -0
- package/dist/server-components/dev-http.d.ts +5 -0
- package/dist/server-components/dev-http.d.ts.map +1 -0
- package/dist/server-components/dev-http.js +71 -0
- package/dist/server-components/dev-http.js.map +1 -0
- package/dist/server-components/error.d.ts +9 -0
- package/dist/server-components/error.d.ts.map +1 -0
- package/dist/server-components/error.js +24 -0
- package/dist/server-components/error.js.map +1 -0
- package/dist/server-components/frames.d.ts +7 -0
- package/dist/server-components/frames.d.ts.map +1 -0
- package/dist/server-components/frames.js +169 -0
- package/dist/server-components/frames.js.map +1 -0
- package/dist/server-components/html-policy.d.ts +6 -0
- package/dist/server-components/html-policy.d.ts.map +1 -0
- package/dist/server-components/html-policy.js +75 -0
- package/dist/server-components/html-policy.js.map +1 -0
- package/dist/server-components/html.d.ts +4 -0
- package/dist/server-components/html.d.ts.map +1 -0
- package/dist/server-components/html.js +67 -0
- package/dist/server-components/html.js.map +1 -0
- package/dist/server-components/islands.d.ts +44 -0
- package/dist/server-components/islands.d.ts.map +1 -0
- package/dist/server-components/islands.js +42 -0
- package/dist/server-components/islands.js.map +1 -0
- package/dist/server-components/native.d.ts +4 -0
- package/dist/server-components/native.d.ts.map +1 -0
- package/dist/server-components/native.js +81 -0
- package/dist/server-components/native.js.map +1 -0
- package/dist/server-components/portal.d.ts +6 -0
- package/dist/server-components/portal.d.ts.map +1 -0
- package/dist/server-components/portal.js +35 -0
- package/dist/server-components/portal.js.map +1 -0
- package/dist/server-components/props.d.ts +3 -0
- package/dist/server-components/props.d.ts.map +1 -0
- package/dist/server-components/props.js +14 -0
- package/dist/server-components/props.js.map +1 -0
- package/dist/server-components/protocol.d.ts +44 -0
- package/dist/server-components/protocol.d.ts.map +1 -0
- package/dist/server-components/protocol.js +139 -0
- package/dist/server-components/protocol.js.map +1 -0
- package/dist/server-components/range.d.ts +24 -0
- package/dist/server-components/range.d.ts.map +1 -0
- package/dist/server-components/range.js +263 -0
- package/dist/server-components/range.js.map +1 -0
- package/dist/server-components/registry.d.ts +6 -0
- package/dist/server-components/registry.d.ts.map +1 -0
- package/dist/server-components/registry.js +30 -0
- package/dist/server-components/registry.js.map +1 -0
- package/dist/server-components/response.d.ts +4 -0
- package/dist/server-components/response.d.ts.map +1 -0
- package/dist/server-components/response.js +32 -0
- package/dist/server-components/response.js.map +1 -0
- package/dist/server-components/router-integration.d.ts +9 -0
- package/dist/server-components/router-integration.d.ts.map +1 -0
- package/dist/server-components/router-integration.js +29 -0
- package/dist/server-components/router-integration.js.map +1 -0
- package/dist/server-components/runtime.d.ts +14 -0
- package/dist/server-components/runtime.d.ts.map +1 -0
- package/dist/server-components/runtime.js +276 -0
- package/dist/server-components/runtime.js.map +1 -0
- package/dist/server-components/scheduler.d.ts +14 -0
- package/dist/server-components/scheduler.d.ts.map +1 -0
- package/dist/server-components/scheduler.js +74 -0
- package/dist/server-components/scheduler.js.map +1 -0
- package/dist/server-components/schema.d.ts +4 -0
- package/dist/server-components/schema.d.ts.map +1 -0
- package/dist/server-components/schema.js +77 -0
- package/dist/server-components/schema.js.map +1 -0
- package/dist/server-components/server-entry.d.ts +12 -0
- package/dist/server-components/server-entry.d.ts.map +1 -0
- package/dist/server-components/server-entry.js +90 -0
- package/dist/server-components/server-entry.js.map +1 -0
- package/dist/server-components/source.d.ts +15 -0
- package/dist/server-components/source.d.ts.map +1 -0
- package/dist/server-components/source.js +269 -0
- package/dist/server-components/source.js.map +1 -0
- package/dist/server-components/state.d.ts +4 -0
- package/dist/server-components/state.d.ts.map +1 -0
- package/dist/server-components/state.js +123 -0
- package/dist/server-components/state.js.map +1 -0
- package/dist/server-components/stream.d.ts +6 -0
- package/dist/server-components/stream.d.ts.map +1 -0
- package/dist/server-components/stream.js +70 -0
- package/dist/server-components/stream.js.map +1 -0
- package/dist/server-components/trusted-html.d.ts +3 -0
- package/dist/server-components/trusted-html.d.ts.map +1 -0
- package/dist/server-components/trusted-html.js +14 -0
- package/dist/server-components/trusted-html.js.map +1 -0
- package/dist/server-components/types.d.ts +107 -0
- package/dist/server-components/types.d.ts.map +1 -0
- package/dist/server-components/types.js +2 -0
- package/dist/server-components/types.js.map +1 -0
- package/dist/server-components/typescript.d.ts +11 -0
- package/dist/server-components/typescript.d.ts.map +1 -0
- package/dist/server-components/typescript.js +28 -0
- package/dist/server-components/typescript.js.map +1 -0
- package/dist/server-components/value.d.ts +5 -0
- package/dist/server-components/value.d.ts.map +1 -0
- package/dist/server-components/value.js +77 -0
- package/dist/server-components/value.js.map +1 -0
- package/dist/server-components/vite.d.ts +12 -0
- package/dist/server-components/vite.d.ts.map +1 -0
- package/dist/server-components/vite.js +197 -0
- package/dist/server-components/vite.js.map +1 -0
- package/dist/server-components.d.ts +5 -0
- package/dist/server-components.d.ts.map +1 -0
- package/dist/server-components.js +4 -0
- package/dist/server-components.js.map +1 -0
- package/dist/service-worker.d.ts +8 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +20 -6
- package/dist/service-worker.js.map +1 -1
- package/dist/vite.d.ts +12 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +7 -1
- package/dist/vite.js.map +1 -1
- package/dist/zones.d.ts +40 -0
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +170 -6
- package/dist/zones.js.map +1 -1
- package/package.json +97 -4
- package/src/build-vite.ts +31 -9
- package/src/build.ts +109 -31
- package/src/cdn.ts +18 -12
- package/src/client.ts +25 -6
- package/src/deferred.ts +27 -9
- package/src/dev-feed.ts +24 -5
- package/src/dev-session.ts +4 -13
- package/src/dev.ts +29 -1
- package/src/draft.ts +35 -14
- package/src/fonts-google.ts +10 -0
- package/src/index.ts +3 -2
- package/src/internal/codegen.ts +60 -29
- package/src/internal/disposable-dir.ts +24 -0
- package/src/internal/entry-chunks.ts +18 -0
- package/src/internal/evidence-traversal.ts +57 -0
- package/src/internal/head-merge.ts +16 -0
- package/src/internal/js-string.ts +6 -1
- package/src/internal/mount-shadow.ts +1 -1
- package/src/internal/output-guard.ts +88 -7
- package/src/internal/page-execution.ts +506 -108
- package/src/internal/progressive-cookies.ts +60 -0
- package/src/internal/public-env.ts +14 -0
- package/src/internal/render-document.ts +26 -11
- package/src/internal/response-controls.ts +31 -14
- package/src/internal/revive-dates.ts +35 -0
- package/src/internal/route-path.ts +8 -0
- package/src/internal/secret-scan.ts +90 -18
- package/src/internal/server-boundary.ts +29 -5
- package/src/internal/web-app.ts +116 -37
- package/src/internal/wire-date.ts +16 -0
- package/src/internal/zone-graph.ts +59 -19
- package/src/isr.ts +351 -43
- package/src/llms-txt.ts +32 -3
- package/src/manifest.ts +28 -4
- package/src/open-resolved.ts +64 -0
- package/src/plugins/kit.ts +31 -11
- package/src/plugins/vite-leak-guard.ts +5 -1
- package/src/public-dir.ts +27 -8
- package/src/query.ts +107 -26
- package/src/render-seam.ts +2 -0
- package/src/route-types.ts +34 -7
- package/src/router.ts +58 -55
- package/src/server-components/build-info.ts +11 -0
- package/src/server-components/build-target.ts +194 -0
- package/src/server-components/build.ts +419 -0
- package/src/server-components/bundle-proof.ts +33 -0
- package/src/server-components/client.ts +663 -0
- package/src/server-components/definition.ts +125 -0
- package/src/server-components/dev-http.ts +75 -0
- package/src/server-components/error.ts +24 -0
- package/src/server-components/frames.ts +181 -0
- package/src/server-components/html-policy.ts +94 -0
- package/src/server-components/html.ts +81 -0
- package/src/server-components/islands.ts +111 -0
- package/src/server-components/native.ts +101 -0
- package/src/server-components/portal.ts +42 -0
- package/src/server-components/props.ts +19 -0
- package/src/server-components/protocol.ts +167 -0
- package/src/server-components/range.ts +313 -0
- package/src/server-components/registry.ts +34 -0
- package/src/server-components/response.ts +44 -0
- package/src/server-components/router-integration.ts +40 -0
- package/src/server-components/runtime.ts +376 -0
- package/src/server-components/scheduler.ts +84 -0
- package/src/server-components/schema.ts +69 -0
- package/src/server-components/server-entry.ts +98 -0
- package/src/server-components/source.ts +324 -0
- package/src/server-components/state.ts +143 -0
- package/src/server-components/stream.ts +71 -0
- package/src/server-components/trusted-html.ts +21 -0
- package/src/server-components/types.ts +138 -0
- package/src/server-components/typescript.ts +39 -0
- package/src/server-components/value.ts +82 -0
- package/src/server-components/vite.ts +242 -0
- package/src/server-components.ts +32 -0
- package/src/service-worker.ts +24 -7
- package/src/vite.ts +8 -1
- package/src/zones.ts +208 -7
package/src/isr.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
|
|
8
8
|
*/
|
|
9
|
+
import { isAuthenticatedRequest, writableResponse } from "@nifrajs/core/server"
|
|
9
10
|
import { isDraftEnabled } from "./draft.ts"
|
|
10
11
|
import { assertTokenSecret, timingSafeEqual } from "./internal/timing-safe-equal.ts"
|
|
11
12
|
|
|
@@ -22,6 +23,9 @@ export interface CachedResponse {
|
|
|
22
23
|
readonly revalidate: number
|
|
23
24
|
/** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
|
|
24
25
|
readonly tags?: readonly string[]
|
|
26
|
+
/** The store's {@link CacheStore.fence} as the render behind this entry began. A store that keeps
|
|
27
|
+
* purge order skips the write when this key or one of its tags was purged after it. */
|
|
28
|
+
readonly fence?: number | undefined
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -39,6 +43,13 @@ export interface CacheStore {
|
|
|
39
43
|
delete(key: string): Promise<void>
|
|
40
44
|
/** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
|
|
41
45
|
readonly invalidateTag?: (tag: string) => Promise<void>
|
|
46
|
+
/**
|
|
47
|
+
* The current point in this store's purge order. `withISR` takes one before each render and hands it
|
|
48
|
+
* back as {@link CachedResponse.fence}, and `set` skips that entry when its key or one of its tags
|
|
49
|
+
* was purged after the point. Without it, a purge that lands while a page renders is undone when the
|
|
50
|
+
* render stores the page it built from data read before the purge.
|
|
51
|
+
*/
|
|
52
|
+
readonly fence?: () => number | Promise<number>
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
|
|
@@ -145,6 +156,10 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
145
156
|
>()
|
|
146
157
|
private readonly max: number
|
|
147
158
|
private readonly tagEpoch = new Map<string, string>()
|
|
159
|
+
// Purge order: `fence` reads it; each purge advances it and records where, per key and per tag.
|
|
160
|
+
private order = 0
|
|
161
|
+
private readonly purgedKeys = new Map<string, number>()
|
|
162
|
+
private readonly purgedTags = new Map<string, number>()
|
|
148
163
|
|
|
149
164
|
constructor(options: MemoryCacheStoreOptions = {}) {
|
|
150
165
|
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
|
|
@@ -188,8 +203,14 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
188
203
|
return Promise.resolve(value)
|
|
189
204
|
}
|
|
190
205
|
|
|
206
|
+
fence(): number {
|
|
207
|
+
return this.order
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
set(key: string, value: CachedResponse): Promise<void> {
|
|
192
211
|
const tags = normalizeTags(value.tags)
|
|
212
|
+
if (value.fence !== undefined && this.purgedAfter(key, tags, value.fence))
|
|
213
|
+
return Promise.resolve()
|
|
193
214
|
const tagEpochs: Record<string, string> = {}
|
|
194
215
|
for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
|
|
195
216
|
this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
|
|
@@ -205,6 +226,7 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
205
226
|
|
|
206
227
|
delete(key: string): Promise<void> {
|
|
207
228
|
this.cache.delete(key)
|
|
229
|
+
this.markPurged(this.purgedKeys, key)
|
|
208
230
|
this.pruneTagEpochs()
|
|
209
231
|
return Promise.resolve()
|
|
210
232
|
}
|
|
@@ -212,6 +234,7 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
212
234
|
invalidateTag(tag: string): Promise<void> {
|
|
213
235
|
normalizeTag(tag)
|
|
214
236
|
this.tagEpoch.set(tag, crypto.randomUUID())
|
|
237
|
+
this.markPurged(this.purgedTags, tag)
|
|
215
238
|
// This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
|
|
216
239
|
// immediate and lets us discard epoch keys that no retained entry references anymore.
|
|
217
240
|
for (const [key, row] of this.cache) {
|
|
@@ -221,6 +244,22 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
221
244
|
return Promise.resolve()
|
|
222
245
|
}
|
|
223
246
|
|
|
247
|
+
private purgedAfter(key: string, tags: readonly string[], fence: number): boolean {
|
|
248
|
+
if ((this.purgedKeys.get(key) ?? 0) > fence) return true
|
|
249
|
+
return tags.some((tag) => (this.purgedTags.get(tag) ?? 0) > fence)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private markPurged(marks: Map<string, number>, name: string): void {
|
|
253
|
+
marks.delete(name)
|
|
254
|
+
marks.set(name, ++this.order)
|
|
255
|
+
// Bounded like the entries: a mark matters only to a render still in flight.
|
|
256
|
+
while (marks.size > this.max) {
|
|
257
|
+
const oldest = marks.keys().next().value
|
|
258
|
+
if (oldest === undefined) break
|
|
259
|
+
marks.delete(oldest)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
224
263
|
private pruneTagEpochs(): void {
|
|
225
264
|
if (this.tagEpoch.size === 0) return
|
|
226
265
|
const used = new Set<string>()
|
|
@@ -276,13 +315,22 @@ export interface KVCacheStoreOptions {
|
|
|
276
315
|
const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
277
316
|
if (typeof value !== "object" || value === null) return false
|
|
278
317
|
const v = value as Record<string, unknown>
|
|
318
|
+
// Everything a replay hands to `new Response` must be legal there, or a corrupt record would throw
|
|
319
|
+
// on every hit instead of reading as a miss.
|
|
279
320
|
if (
|
|
280
321
|
typeof v.body !== "string" ||
|
|
281
322
|
typeof v.status !== "number" ||
|
|
323
|
+
!Number.isInteger(v.status) ||
|
|
324
|
+
v.status < 200 ||
|
|
325
|
+
v.status > 599 ||
|
|
282
326
|
typeof v.storedAt !== "number" ||
|
|
327
|
+
!Number.isFinite(v.storedAt) ||
|
|
283
328
|
typeof v.revalidate !== "number" ||
|
|
329
|
+
!Number.isFinite(v.revalidate) ||
|
|
330
|
+
v.revalidate < 0 ||
|
|
284
331
|
typeof v.headers !== "object" ||
|
|
285
|
-
v.headers === null
|
|
332
|
+
v.headers === null ||
|
|
333
|
+
Array.isArray(v.headers)
|
|
286
334
|
) {
|
|
287
335
|
return false
|
|
288
336
|
}
|
|
@@ -294,13 +342,54 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
|
294
342
|
return false
|
|
295
343
|
}
|
|
296
344
|
}
|
|
297
|
-
|
|
345
|
+
const headers: Record<string, string> = {}
|
|
346
|
+
for (const [name, headerValue] of Object.entries(v.headers)) {
|
|
298
347
|
if (typeof headerValue !== "string") return false
|
|
348
|
+
headers[name] = headerValue
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
new Headers(headers)
|
|
352
|
+
} catch {
|
|
353
|
+
return false
|
|
299
354
|
}
|
|
300
355
|
return true
|
|
301
356
|
}
|
|
302
357
|
|
|
303
358
|
const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
|
|
359
|
+
const KV_PURGED_AT = "__nifraPurgedAt"
|
|
360
|
+
|
|
361
|
+
/** When a KV page key was purged, from the marker `delete` leaves; `-Infinity` for a page or nothing. */
|
|
362
|
+
function kvPurgedAt(raw: string | null): number {
|
|
363
|
+
if (raw === null || !raw.includes(KV_PURGED_AT)) return Number.NEGATIVE_INFINITY
|
|
364
|
+
try {
|
|
365
|
+
const parsed: unknown = JSON.parse(raw)
|
|
366
|
+
if (typeof parsed === "object" && parsed !== null && KV_PURGED_AT in parsed) {
|
|
367
|
+
const at = parsed[KV_PURGED_AT]
|
|
368
|
+
if (typeof at === "number") return at
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
// Not a marker.
|
|
372
|
+
}
|
|
373
|
+
return Number.NEGATIVE_INFINITY
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** When a tag epoch was written (`<time>:<uuid>`); an epoch from before epochs carried a time is older
|
|
377
|
+
* than any render. */
|
|
378
|
+
function kvEpochTime(epoch: string): number {
|
|
379
|
+
const at = epoch.indexOf(":")
|
|
380
|
+
const time = at === -1 ? Number.NaN : Number(epoch.slice(0, at))
|
|
381
|
+
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let lastKvTime = 0
|
|
385
|
+
/** Wall-clock ms, strictly increasing per process so a purge orders after a render it follows. Not
|
|
386
|
+
* `timeOrigin + performance.now()`: that clock stops while the host sleeps, so a purge from a process
|
|
387
|
+
* that slept would order before renders other instances began earlier. */
|
|
388
|
+
const kvClock = (): number => {
|
|
389
|
+
const now = Date.now()
|
|
390
|
+
lastKvTime = now > lastKvTime ? now : lastKvTime + 0.001
|
|
391
|
+
return lastKvTime
|
|
392
|
+
}
|
|
304
393
|
|
|
305
394
|
type StoredTaggedResponse = CachedResponse & {
|
|
306
395
|
readonly __nifraTagEpochs?: Readonly<Record<string, string>>
|
|
@@ -373,10 +462,18 @@ export class KVCacheStore implements CacheStore {
|
|
|
373
462
|
return value
|
|
374
463
|
}
|
|
375
464
|
|
|
465
|
+
/** Wall-clock time, so purges on any instance order against it. KV is eventually consistent, so the
|
|
466
|
+
* fence holds only for purges this location has already seen. */
|
|
467
|
+
fence(): number {
|
|
468
|
+
return kvClock()
|
|
469
|
+
}
|
|
470
|
+
|
|
376
471
|
async set(key: string, value: CachedResponse): Promise<void> {
|
|
377
|
-
const
|
|
472
|
+
const { fence, ...entry } = value
|
|
473
|
+
if (fence !== undefined && kvPurgedAt(await this.kv.get(key)) > fence) return
|
|
474
|
+
const tags = normalizeTags(entry.tags)
|
|
378
475
|
if (tags.length === 0) {
|
|
379
|
-
await this.kv.put(key, JSON.stringify(
|
|
476
|
+
await this.kv.put(key, JSON.stringify(entry), this.putOptions)
|
|
380
477
|
return
|
|
381
478
|
}
|
|
382
479
|
const epochs = Object.fromEntries(
|
|
@@ -384,24 +481,29 @@ export class KVCacheStore implements CacheStore {
|
|
|
384
481
|
tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
|
|
385
482
|
),
|
|
386
483
|
)
|
|
484
|
+
if (fence !== undefined && Object.values(epochs).some((epoch) => kvEpochTime(epoch) > fence)) {
|
|
485
|
+
return
|
|
486
|
+
}
|
|
387
487
|
const stored: StoredTaggedResponse = {
|
|
388
|
-
...
|
|
488
|
+
...entry,
|
|
389
489
|
tags,
|
|
390
490
|
__nifraTagEpochs: epochs,
|
|
391
491
|
}
|
|
392
492
|
await this.kv.put(key, JSON.stringify(stored), this.putOptions)
|
|
393
493
|
}
|
|
394
494
|
|
|
495
|
+
/** Leaves a marker rather than removing the key, so `set` can refuse a render that began before
|
|
496
|
+
* the purge. The marker reads as a miss. */
|
|
395
497
|
async delete(key: string): Promise<void> {
|
|
396
|
-
await this.kv.
|
|
498
|
+
await this.kv.put(key, JSON.stringify({ [KV_PURGED_AT]: kvClock() }), this.putOptions)
|
|
397
499
|
}
|
|
398
500
|
|
|
399
501
|
async invalidateTag(tag: string): Promise<void> {
|
|
400
502
|
normalizeTag(tag)
|
|
401
503
|
// Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
|
|
402
504
|
// pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
|
|
403
|
-
// written before the invalidation when their page key still exists.
|
|
404
|
-
await this.kv.put(this.tagKey(tag), crypto.randomUUID())
|
|
505
|
+
// written before the invalidation when their page key still exists. The time orders it for `set`.
|
|
506
|
+
await this.kv.put(this.tagKey(tag), `${kvClock()}:${crypto.randomUUID()}`)
|
|
405
507
|
}
|
|
406
508
|
|
|
407
509
|
private tagKey(tag: string): string {
|
|
@@ -450,17 +552,10 @@ export function withoutCacheChannel(res: Response): Response {
|
|
|
450
552
|
if (!res.headers.has(ISR_REVALIDATE_HEADER) && !res.headers.has(ISR_REVALIDATE_TAGS_HEADER)) {
|
|
451
553
|
return res
|
|
452
554
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
}
|
|
555
|
+
const target = writableResponse(res)
|
|
556
|
+
target.headers.delete(ISR_REVALIDATE_HEADER)
|
|
557
|
+
target.headers.delete(ISR_REVALIDATE_TAGS_HEADER)
|
|
558
|
+
return target
|
|
464
559
|
}
|
|
465
560
|
|
|
466
561
|
export interface ISROptions {
|
|
@@ -473,8 +568,17 @@ export interface ISROptions {
|
|
|
473
568
|
readonly now: () => number
|
|
474
569
|
/** Cache key for a request. Default: `origin + pathname`, plus the query parameters `query` admits,
|
|
475
570
|
* 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.
|
|
571
|
+
* this request (it goes straight to the app, uncached). Replaces `query` entirely. Under the default
|
|
572
|
+
* key a request carrying `Authorization` or a cookie outside `anonymousCookies` never reads the
|
|
573
|
+
* store; a custom key takes that decision over, so include whatever identifies the visitor in it. */
|
|
477
574
|
readonly key?: (req: Request) => string | null
|
|
575
|
+
/**
|
|
576
|
+
* Cookie names that never change the page, such as a consent or analytics cookie. Under the default
|
|
577
|
+
* key a request carrying `Authorization` or any other cookie skips the stored page and renders fresh,
|
|
578
|
+
* because a signed-in visitor's page can differ from the anonymous one; a request whose cookies are
|
|
579
|
+
* all listed here is served from the cache like one with none. Default none.
|
|
580
|
+
*/
|
|
581
|
+
readonly anonymousCookies?: readonly string[]
|
|
478
582
|
/**
|
|
479
583
|
* How the query string reaches the default key. Default `"bypass"`: a request carrying any query
|
|
480
584
|
* parameter skips the cache - rendered fresh, never stored - so `?a=1`, `?a=2`, ... cannot each
|
|
@@ -488,6 +592,41 @@ export interface ISROptions {
|
|
|
488
592
|
* set, a request carrying a valid signed draft cookie **bypasses the cache** - editors always render
|
|
489
593
|
* fresh, and a draft render is never written to the store (it can't poison the public cache). */
|
|
490
594
|
readonly draftSecret?: string
|
|
595
|
+
/**
|
|
596
|
+
* Called when a store call fails: it throws, rejects, or outlives {@link storeTimeoutMs}. The request
|
|
597
|
+
* is served without the cache either way - a failed read is a miss, a failed write still returns the
|
|
598
|
+
* rendered page, a failed regeneration keeps the stale entry - so a store outage never changes a
|
|
599
|
+
* status. Default: one `console.error` line per operation per minute, never naming the cache key.
|
|
600
|
+
*/
|
|
601
|
+
readonly onStoreError?: (error: unknown, info: { readonly op: ISRStoreOp }) => void
|
|
602
|
+
/**
|
|
603
|
+
* Longest a store call may take (ms) before it counts as failed. Default none, so a hung store holds
|
|
604
|
+
* the request; set it when the store is a network service.
|
|
605
|
+
*/
|
|
606
|
+
readonly storeTimeoutMs?: number
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** The store operation an {@link ISROptions.onStoreError} report is about. */
|
|
610
|
+
export type ISRStoreOp = "get" | "set" | "delete" | "fence"
|
|
611
|
+
|
|
612
|
+
const STORE_FAILED = Symbol("nifra.isr.store-failed")
|
|
613
|
+
const STORE_REPORT_INTERVAL_MS = 60_000
|
|
614
|
+
const STORE_REPORT_DETAIL_CHARS = 200
|
|
615
|
+
|
|
616
|
+
/** The error's own words with the cache key cut out, since a store may name the key it failed on. */
|
|
617
|
+
function storeErrorDetail(error: unknown, key: string): string {
|
|
618
|
+
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
|
|
619
|
+
let path: string | undefined
|
|
620
|
+
try {
|
|
621
|
+
path = new URL(key).pathname
|
|
622
|
+
} catch {
|
|
623
|
+
path = undefined
|
|
624
|
+
}
|
|
625
|
+
let redacted = text.split(key).join("<key>")
|
|
626
|
+
if (path !== undefined && path.length > 1) redacted = redacted.split(path).join("<key>")
|
|
627
|
+
return redacted.length > STORE_REPORT_DETAIL_CHARS
|
|
628
|
+
? `${redacted.slice(0, STORE_REPORT_DETAIL_CHARS)}...`
|
|
629
|
+
: redacted
|
|
491
630
|
}
|
|
492
631
|
|
|
493
632
|
/** `createWebApp`'s marker for how its documents meet a CSP (see `DOCUMENT_POLICY` in ./csp.ts). Read
|
|
@@ -554,8 +693,19 @@ const cacheControlHas = (headers: Headers, names: readonly string[]): boolean =>
|
|
|
554
693
|
const hasSetCookie = (headers: Headers): boolean =>
|
|
555
694
|
headers.has("set-cookie") || (headers.getSetCookie?.().length ?? 0) > 0
|
|
556
695
|
|
|
696
|
+
/** Whether every cookie in a `Cookie` header is named in `names`. */
|
|
697
|
+
const cookieNamesWithin = (header: string, names: ReadonlySet<string>): boolean => {
|
|
698
|
+
for (const pair of header.split(";")) {
|
|
699
|
+
const name = pair.split("=", 1)[0]?.trim() ?? ""
|
|
700
|
+
if (name !== "" && !names.has(name)) return false
|
|
701
|
+
}
|
|
702
|
+
return true
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// An authentication stage may read any header, so a request it accepted is private whatever carried
|
|
706
|
+
// the credential. The mark is set on the request the app was handed, which is why this runs after it.
|
|
557
707
|
const requestCarriesPrivateState = (req: Request): boolean =>
|
|
558
|
-
req.headers.has("authorization") || req.headers.has("cookie")
|
|
708
|
+
req.headers.has("authorization") || req.headers.has("cookie") || isAuthenticatedRequest(req)
|
|
559
709
|
|
|
560
710
|
const responseIsExplicitlyPublic = (res: Response): boolean =>
|
|
561
711
|
cacheControlHas(res.headers, ["public"])
|
|
@@ -575,7 +725,8 @@ const responseDeclaresVary = (res: Response): boolean => {
|
|
|
575
725
|
/**
|
|
576
726
|
* Whether `res` may be stored by a shared cache and served to anyone requesting `req`'s URL: a
|
|
577
727
|
* 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
|
|
728
|
+
* the data header, and, for a request carrying a cookie or Authorization or accepted by an
|
|
729
|
+
* authentication stage, an explicit `public`.
|
|
579
730
|
* The one rule both `withISR` and `@nifrajs/web/cdn` cache by.
|
|
580
731
|
*/
|
|
581
732
|
export const isCacheablePage = (req: Request, res: Response): boolean => {
|
|
@@ -653,8 +804,22 @@ function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Recor
|
|
|
653
804
|
return out
|
|
654
805
|
}
|
|
655
806
|
|
|
656
|
-
|
|
657
|
-
|
|
807
|
+
/**
|
|
808
|
+
* Whether a regeneration's answer withdraws the stored page: it is gone or moved, or, rendered for a
|
|
809
|
+
* request with no cookie or Authorization, refused (401, 403) or declared `private`/`no-store`. A
|
|
810
|
+
* signed-in visitor's own 403 or private copy says nothing about the shared page. A 5xx or 429 keeps
|
|
811
|
+
* serving the stale copy.
|
|
812
|
+
*/
|
|
813
|
+
const isInvalidationResponse = (req: Request, res: Response): boolean => {
|
|
814
|
+
if (res.status === 404 || res.status === 410 || (res.status >= 300 && res.status < 400))
|
|
815
|
+
return true
|
|
816
|
+
if (requestCarriesPrivateState(req)) return false
|
|
817
|
+
return (
|
|
818
|
+
res.status === 401 ||
|
|
819
|
+
res.status === 403 ||
|
|
820
|
+
(res.status >= 200 && res.status < 300 && cacheControlHas(res.headers, ["private", "no-store"]))
|
|
821
|
+
)
|
|
822
|
+
}
|
|
658
823
|
|
|
659
824
|
/**
|
|
660
825
|
* Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
|
|
@@ -673,8 +838,77 @@ export function withISR(
|
|
|
673
838
|
): (req: Request, platform?: ISRPlatform) => Promise<Response> {
|
|
674
839
|
const { store, now } = options
|
|
675
840
|
assertRevalidate(options.revalidate, "revalidate")
|
|
841
|
+
const storeTimeoutMs = options.storeTimeoutMs
|
|
842
|
+
if (storeTimeoutMs !== undefined && !(Number.isFinite(storeTimeoutMs) && storeTimeoutMs > 0)) {
|
|
843
|
+
throw new RangeError("[nifra/web] ISR storeTimeoutMs must be a positive finite number")
|
|
844
|
+
}
|
|
845
|
+
const lastReport = new Map<ISRStoreOp, number>()
|
|
846
|
+
const reportStoreError = (op: ISRStoreOp, key: string, error: unknown): void => {
|
|
847
|
+
try {
|
|
848
|
+
if (options.onStoreError !== undefined) {
|
|
849
|
+
options.onStoreError(error, { op })
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
const last = lastReport.get(op)
|
|
853
|
+
const at = now()
|
|
854
|
+
if (last !== undefined && at - last < STORE_REPORT_INTERVAL_MS) return
|
|
855
|
+
lastReport.set(op, at)
|
|
856
|
+
console.error(
|
|
857
|
+
`[nifra/web] ISR store ${op} failed; serving without the cache (${storeErrorDetail(error, key)})`,
|
|
858
|
+
)
|
|
859
|
+
} catch {
|
|
860
|
+
// A failing reporter must not turn a served page into an error.
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// Runs one store call; a throw, a rejection or a timeout is reported and answered with STORE_FAILED.
|
|
864
|
+
const attempt = async <T>(
|
|
865
|
+
op: ISRStoreOp,
|
|
866
|
+
key: string,
|
|
867
|
+
call: () => Promise<T>,
|
|
868
|
+
): Promise<T | typeof STORE_FAILED> => {
|
|
869
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
870
|
+
try {
|
|
871
|
+
const pending = call()
|
|
872
|
+
if (storeTimeoutMs === undefined) return await pending
|
|
873
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
874
|
+
timer = setTimeout(() => {
|
|
875
|
+
const error = new Error(`ISR store ${op} exceeded storeTimeoutMs (${storeTimeoutMs} ms)`)
|
|
876
|
+
error.name = "TimeoutError"
|
|
877
|
+
reject(error)
|
|
878
|
+
}, storeTimeoutMs)
|
|
879
|
+
})
|
|
880
|
+
return await Promise.race([pending, timeout])
|
|
881
|
+
} catch (error) {
|
|
882
|
+
reportStoreError(op, key, error)
|
|
883
|
+
return STORE_FAILED
|
|
884
|
+
} finally {
|
|
885
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// A store without purge order, or one whose fence failed, stores the render unfenced.
|
|
889
|
+
const fenceFor = async (key: string): Promise<number | undefined> => {
|
|
890
|
+
const { fence } = store
|
|
891
|
+
if (fence === undefined) return undefined
|
|
892
|
+
const taken = await attempt("fence", key, async () => fence.call(store))
|
|
893
|
+
return taken === STORE_FAILED ? undefined : taken
|
|
894
|
+
}
|
|
676
895
|
const keyOf = options.key ?? requestKeyOf(urlKeyOf(options.query))
|
|
677
896
|
const draftSecret = options.draftSecret
|
|
897
|
+
const anonymousCookies = new Set(options.anonymousCookies ?? [])
|
|
898
|
+
// A request whose cookies are all anonymous is judged for storage as if it carried none.
|
|
899
|
+
const judged = (req: Request): Request => {
|
|
900
|
+
if (
|
|
901
|
+
anonymousCookies.size === 0 ||
|
|
902
|
+
req.headers.has("authorization") ||
|
|
903
|
+
isAuthenticatedRequest(req)
|
|
904
|
+
)
|
|
905
|
+
return req
|
|
906
|
+
const cookie = req.headers.get("cookie")
|
|
907
|
+
if (cookie === null || !cookieNamesWithin(cookie, anonymousCookies)) return req
|
|
908
|
+
const headers = new Headers(req.headers)
|
|
909
|
+
headers.delete("cookie")
|
|
910
|
+
return new Request(req.url, { method: req.method, headers })
|
|
911
|
+
}
|
|
678
912
|
const regenerating = new Set<string>()
|
|
679
913
|
openCacheChannel(app)
|
|
680
914
|
// An outer cache layer (`withCdn`) opens this wrapper's own channel: cached responses then carry how
|
|
@@ -727,10 +961,13 @@ export function withISR(
|
|
|
727
961
|
req: Request,
|
|
728
962
|
platform: ISRPlatform | undefined,
|
|
729
963
|
key: string,
|
|
964
|
+
remember: boolean,
|
|
965
|
+
stored?: (entry: CachedResponse) => void,
|
|
730
966
|
): Promise<Response> => {
|
|
967
|
+
const fence = await fenceFor(key)
|
|
731
968
|
const res = await app.fetch(req, platform)
|
|
732
|
-
if (!isCacheablePage(req, res)) {
|
|
733
|
-
rememberUncacheable(key, res)
|
|
969
|
+
if (!isCacheablePage(judged(req), res)) {
|
|
970
|
+
if (remember) rememberUncacheable(key, res)
|
|
734
971
|
return withoutCacheChannel(res)
|
|
735
972
|
}
|
|
736
973
|
uncacheable.delete(key)
|
|
@@ -743,14 +980,49 @@ export function withISR(
|
|
|
743
980
|
storedAt: now(),
|
|
744
981
|
revalidate: ttlMs(res),
|
|
745
982
|
...(tags.length === 0 ? {} : { tags }),
|
|
983
|
+
fence,
|
|
746
984
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
985
|
+
// A failed write still answers with the page, and still hands it to concurrent misses: whether it
|
|
986
|
+
// may be shared was decided above, not by the store.
|
|
987
|
+
await attempt("set", key, () => store.set(key, entry))
|
|
988
|
+
stored?.(entry)
|
|
989
|
+
return fresh(entry)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const fresh = (entry: CachedResponse): Response => {
|
|
993
|
+
const res = new Response(entry.body, {
|
|
994
|
+
status: entry.status,
|
|
750
995
|
headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
|
|
751
996
|
})
|
|
752
|
-
advertise(
|
|
753
|
-
return
|
|
997
|
+
advertise(res.headers, entry.revalidate, entry.tags ?? [])
|
|
998
|
+
return res
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Cold misses in flight per key. Concurrent misses wait for the first one's stored entry instead
|
|
1002
|
+
// of each rendering the page; a render that is not stored (private, failed) sends each waiter to
|
|
1003
|
+
// render for itself, so a waiter never receives a page that was not safe to share.
|
|
1004
|
+
const coldRenders = new Map<string, Promise<CachedResponse | undefined>>()
|
|
1005
|
+
const renderCold = async (
|
|
1006
|
+
req: Request,
|
|
1007
|
+
platform: ISRPlatform | undefined,
|
|
1008
|
+
key: string,
|
|
1009
|
+
): Promise<Response> => {
|
|
1010
|
+
const pending = coldRenders.get(key)
|
|
1011
|
+
if (pending !== undefined) {
|
|
1012
|
+
const entry = await pending
|
|
1013
|
+
return entry === undefined ? render(req, platform, key, true) : fresh(entry)
|
|
1014
|
+
}
|
|
1015
|
+
const shared = Promise.withResolvers<CachedResponse | undefined>()
|
|
1016
|
+
coldRenders.set(key, shared.promise)
|
|
1017
|
+
let entry: CachedResponse | undefined
|
|
1018
|
+
try {
|
|
1019
|
+
return await render(req, platform, key, true, (stored) => {
|
|
1020
|
+
entry = stored
|
|
1021
|
+
})
|
|
1022
|
+
} finally {
|
|
1023
|
+
coldRenders.delete(key)
|
|
1024
|
+
shared.resolve(entry)
|
|
1025
|
+
}
|
|
754
1026
|
}
|
|
755
1027
|
|
|
756
1028
|
// Background regeneration (single-flight per key) - a failed regen keeps the stale entry; the next
|
|
@@ -763,22 +1035,26 @@ export function withISR(
|
|
|
763
1035
|
if (regenerating.has(key)) return
|
|
764
1036
|
regenerating.add(key)
|
|
765
1037
|
try {
|
|
1038
|
+
const fence = await fenceFor(key)
|
|
766
1039
|
const res = await app.fetch(req, platform)
|
|
767
|
-
if (isCacheablePage(req, res)) {
|
|
1040
|
+
if (isCacheablePage(judged(req), res)) {
|
|
768
1041
|
const tags = tagsOf(res)
|
|
769
|
-
|
|
1042
|
+
const entry: CachedResponse = {
|
|
770
1043
|
body: await res.text(),
|
|
771
1044
|
status: res.status,
|
|
772
1045
|
headers: headersOf(res),
|
|
773
1046
|
storedAt: now(),
|
|
774
1047
|
revalidate: ttlMs(res),
|
|
775
1048
|
...(tags.length === 0 ? {} : { tags }),
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
//
|
|
781
|
-
|
|
1049
|
+
fence,
|
|
1050
|
+
}
|
|
1051
|
+
await attempt("set", key, () => store.set(key, entry))
|
|
1052
|
+
} else if (isInvalidationResponse(judged(req), res)) {
|
|
1053
|
+
// A withdrawal is authoritative. Keeping the old entry would make a withdrawn page stay
|
|
1054
|
+
// publicly visible forever, because every stale request would retry the same non-cacheable
|
|
1055
|
+
// response while continuing to serve the old body.
|
|
1056
|
+
await res.body?.cancel()
|
|
1057
|
+
await attempt("delete", key, () => store.delete(key))
|
|
782
1058
|
} else {
|
|
783
1059
|
await res.body?.cancel()
|
|
784
1060
|
}
|
|
@@ -793,7 +1069,15 @@ export function withISR(
|
|
|
793
1069
|
// A data-mode soft-nav GET bypasses the cache entirely: entries are full HTML documents keyed
|
|
794
1070
|
// by URL, and serving one to a loader-data fetch would hand the client HTML where it expects
|
|
795
1071
|
// the loader payload. (The write path already refuses to cache data-mode responses.)
|
|
796
|
-
const
|
|
1072
|
+
const privateRoute: unknown = Reflect.get(app, Symbol.for("nifra.web.private-route"))
|
|
1073
|
+
const ownsPrivate = typeof privateRoute === "function" && privateRoute(req) === true
|
|
1074
|
+
const key =
|
|
1075
|
+
req.method === "GET" &&
|
|
1076
|
+
req.headers.get("x-nifra-data") === null &&
|
|
1077
|
+
!req.headers.has("x-nifra-component") &&
|
|
1078
|
+
!ownsPrivate
|
|
1079
|
+
? keyOf(req)
|
|
1080
|
+
: null
|
|
797
1081
|
if (key === null) return withoutCacheChannel(await app.fetch(req, platform))
|
|
798
1082
|
|
|
799
1083
|
// Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
|
|
@@ -802,13 +1086,18 @@ export function withISR(
|
|
|
802
1086
|
return withoutCacheChannel(await app.fetch(req, platform))
|
|
803
1087
|
}
|
|
804
1088
|
|
|
1089
|
+
// A cookie or Authorization request may be personalized: under the default key it never reads
|
|
1090
|
+
// the store, and its private render never marks the URL uncacheable for everyone else.
|
|
1091
|
+
const personal = options.key === undefined && requestCarriesPrivateState(judged(req))
|
|
1092
|
+
|
|
805
1093
|
const until = uncacheable.get(key)
|
|
806
1094
|
if (until !== undefined) {
|
|
807
|
-
if (now() < until) return render(req, platform, key)
|
|
1095
|
+
if (now() < until) return render(req, platform, key, !personal)
|
|
808
1096
|
uncacheable.delete(key)
|
|
809
1097
|
}
|
|
810
1098
|
|
|
811
|
-
const
|
|
1099
|
+
const read = personal ? undefined : await attempt("get", key, () => store.get(key))
|
|
1100
|
+
const hit = read === STORE_FAILED ? undefined : read
|
|
812
1101
|
if (hit !== undefined) {
|
|
813
1102
|
if (now() - hit.storedAt < hit.revalidate) return served(hit, "hit")
|
|
814
1103
|
// Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
|
|
@@ -817,7 +1106,7 @@ export function withISR(
|
|
|
817
1106
|
else void task.catch(() => {})
|
|
818
1107
|
return served(hit, "stale")
|
|
819
1108
|
}
|
|
820
|
-
return render(req, platform, key)
|
|
1109
|
+
return personal ? render(req, platform, key, false) : renderCold(req, platform, key)
|
|
821
1110
|
}
|
|
822
1111
|
Object.defineProperty(handler, CACHE_CHANNEL, {
|
|
823
1112
|
value: () => {
|
|
@@ -870,6 +1159,13 @@ export interface RevalidateEndpointOptions {
|
|
|
870
1159
|
* the purge, `202` when it is queued (rate limit or retry), and `502` when the CDN refused it.
|
|
871
1160
|
*/
|
|
872
1161
|
readonly cdn?: CdnPurgeTarget
|
|
1162
|
+
/**
|
|
1163
|
+
* Caches below the pages whose entries carry the same tags, such as a `@nifrajs/cache` holding
|
|
1164
|
+
* loader data. A tag purge invalidates each of them first, then the page store, then the CDN, so a
|
|
1165
|
+
* page regenerated mid-purge cannot be rebuilt from stale data. A failure answers `500` before the
|
|
1166
|
+
* page store is touched, so a retry purges everything again. Path purges do not reach them.
|
|
1167
|
+
*/
|
|
1168
|
+
readonly dataCaches?: readonly { invalidateTag(tag: string): unknown }[]
|
|
873
1169
|
}
|
|
874
1170
|
|
|
875
1171
|
const stringList = (value: unknown): string[] | null | undefined => {
|
|
@@ -953,6 +1249,18 @@ export function revalidateEndpoint(
|
|
|
953
1249
|
return jsonError(501, "tag_invalidation_not_supported")
|
|
954
1250
|
}
|
|
955
1251
|
|
|
1252
|
+
if (tags.length > 0 && options.dataCaches !== undefined) {
|
|
1253
|
+
try {
|
|
1254
|
+
for (const cache of options.dataCaches) {
|
|
1255
|
+
for (const candidate of tags) await cache.invalidateTag(candidate)
|
|
1256
|
+
}
|
|
1257
|
+
} catch {
|
|
1258
|
+
return Response.json(
|
|
1259
|
+
{ ok: false, error: "data_cache_invalidation_failed" },
|
|
1260
|
+
{ status: 500 },
|
|
1261
|
+
)
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
956
1264
|
for (const key of keys) await options.store.delete(key)
|
|
957
1265
|
for (const candidate of tags) await invalidateTag?.call(options.store, candidate)
|
|
958
1266
|
const done =
|