@nifrajs/web 2.9.0 → 2.10.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/dev.d.ts.map +1 -1
- package/dist/dev.js +4 -1
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/isr.d.ts +32 -3
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +181 -16
- package/dist/isr.js.map +1 -1
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +71 -4
- package/dist/public-dir.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -1
- package/dist/route-manifest.d.ts.map +1 -1
- package/dist/route-manifest.js +3 -0
- package/dist/route-manifest.js.map +1 -1
- package/package.json +2 -2
- package/src/dev.ts +4 -1
- package/src/index.ts +8 -1
- package/src/isr.ts +216 -19
- package/src/manifest.ts +2 -0
- package/src/public-dir.ts +74 -4
- package/src/route-manifest.ts +10 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-manifest.d.ts","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAA;AAEjD,uEAAuE;AACvE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,cAAc,CAAA;AAEvD,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,iGAAiG;IACjG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7C,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C;AAED,wGAAwG;AACxG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAA;IACpC,sFAAsF;IACtF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAC9C,QAAQ,CAAC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;IACpD,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;CACtD;AAKD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,IAAI,
|
|
1
|
+
{"version":3,"file":"route-manifest.d.ts","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1D;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAA;AAEjD,uEAAuE;AACvE,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,cAAc,CAAA;AAEvD,sCAAsC;AACtC,MAAM,WAAW,kBAAkB;IACjC,sEAAsE;IACtE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,iGAAiG;IACjG,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,8DAA8D;IAC9D,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7C,iEAAiE;IACjE,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C;AAED,wGAAwG;AACxG,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,UAAU,EAAE,eAAe,CAAA;IACpC,sFAAsF;IACtF,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,2FAA2F;AAC3F,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,kBAAkB,EAAE,CAAA;IAC9C,QAAQ,CAAC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;IACpD,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC,CAAA;CACtD;AAKD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,IAAI,CACV,WAAW,EACX,WAAW,GAAG,gBAAgB,GAAG,YAAY,GAAG,gBAAgB,GAAG,SAAS,CAC7E,EACD,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,GACnC,kBAAkB,CA6BpB;AA0BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,QAAQ,EAClB,OAAO,GAAE;IACP,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAClE,mGAAmG;IACnG,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAA;CAC9C,GACL,OAAO,CAAC,aAAa,CAAC,CAuCxB;AAED,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CA2CnE"}
|
package/dist/route-manifest.js
CHANGED
|
@@ -24,6 +24,9 @@ export function deriveRouteEntry(id, pattern, module, prerenderedPaths) {
|
|
|
24
24
|
const entry = { id, pattern, mode, hydrate: module.hydrate !== false, requires };
|
|
25
25
|
if (mode === "isr" && module.revalidate !== undefined)
|
|
26
26
|
entry.revalidate = module.revalidate;
|
|
27
|
+
if (mode === "isr" && module.revalidateTags !== undefined) {
|
|
28
|
+
entry.revalidateTags = module.revalidateTags;
|
|
29
|
+
}
|
|
27
30
|
if (prerenderedPaths !== undefined)
|
|
28
31
|
entry.prerenderedPaths = prerenderedPaths;
|
|
29
32
|
return entry;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-manifest.js","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"route-manifest.js","sourceRoot":"","sources":["../src/route-manifest.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
"access": "public"
|
|
172
172
|
},
|
|
173
173
|
"dependencies": {
|
|
174
|
-
"@nifrajs/core": "2.
|
|
174
|
+
"@nifrajs/core": "2.10.0"
|
|
175
175
|
},
|
|
176
176
|
"peerDependencies": {
|
|
177
177
|
"sass": "^1.0.0",
|
package/src/dev.ts
CHANGED
|
@@ -367,7 +367,10 @@ export async function createDevServer(options: DevServerOptions): Promise<DevSer
|
|
|
367
367
|
return new Response(body, { headers })
|
|
368
368
|
}
|
|
369
369
|
// Static probe before routing; a miss returns undefined and falls through, so no route is
|
|
370
|
-
// shadowed
|
|
370
|
+
// shadowed. Every GET/HEAD pays the probe, including page renders and API routes: the handler
|
|
371
|
+
// serves extension-less files too (an ACME challenge token is the reason), so there is no
|
|
372
|
+
// cheap prefilter to skip. Dev only - production serves `public/` from the CDN, and a caller
|
|
373
|
+
// mounting this handler itself can pass `files` to answer a miss without touching the disk.
|
|
371
374
|
const staticFile = await servePublic(req)
|
|
372
375
|
if (staticFile !== undefined) return staticFile
|
|
373
376
|
try {
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { isDraftEnabled } from "./draft.ts"
|
|
|
27
27
|
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
28
28
|
import { jsStringLiteral } from "./internal/js-string.ts"
|
|
29
29
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
30
|
-
import { ISR_REVALIDATE_HEADER } from "./isr.ts"
|
|
30
|
+
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
|
|
31
31
|
import { generateLlmsTxt } from "./llms-txt.ts"
|
|
32
32
|
import type {
|
|
33
33
|
LayoutEntry,
|
|
@@ -83,6 +83,7 @@ export {
|
|
|
83
83
|
type CachedResponse,
|
|
84
84
|
type CacheStore,
|
|
85
85
|
ISR_REVALIDATE_HEADER,
|
|
86
|
+
ISR_REVALIDATE_TAGS_HEADER,
|
|
86
87
|
ISR_STATUS_HEADER,
|
|
87
88
|
type ISRApp,
|
|
88
89
|
type ISROptions,
|
|
@@ -427,6 +428,8 @@ export interface RenderPageOptions {
|
|
|
427
428
|
/** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
|
|
428
429
|
* wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
|
|
429
430
|
readonly revalidate?: number
|
|
431
|
+
/** ISR invalidation tags emitted as a bounded `x-nifra-isr-tags` header. */
|
|
432
|
+
readonly revalidateTags?: readonly string[]
|
|
430
433
|
/** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
|
|
431
434
|
readonly routeId?: string
|
|
432
435
|
/** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
|
|
@@ -541,6 +544,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
541
544
|
styles = [],
|
|
542
545
|
prerenderedPaths = [],
|
|
543
546
|
revalidate,
|
|
547
|
+
revalidateTags = [],
|
|
544
548
|
routeId,
|
|
545
549
|
status = 200,
|
|
546
550
|
title = "nifra",
|
|
@@ -695,6 +699,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
695
699
|
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
696
700
|
// the client's path-list channel.
|
|
697
701
|
if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
|
|
702
|
+
const serializedTags = serializeISRTags(revalidateTags)
|
|
703
|
+
if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
|
|
698
704
|
const renderProps: RenderProps = {
|
|
699
705
|
data: forComponent,
|
|
700
706
|
actionData: actionSplit?.forComponent,
|
|
@@ -2225,6 +2231,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2225
2231
|
...stylesOf(route.id),
|
|
2226
2232
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
2227
2233
|
...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
|
|
2234
|
+
...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
|
|
2228
2235
|
...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
|
|
2229
2236
|
...titleOption,
|
|
2230
2237
|
...assemblyCacheFor(mod, chainLayoutModules),
|
package/src/isr.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface CachedResponse {
|
|
|
20
20
|
readonly storedAt: number
|
|
21
21
|
/** Freshness window (ms): `now - storedAt >= revalidate` ⇒ stale (serve it, regenerate behind it). */
|
|
22
22
|
readonly revalidate: number
|
|
23
|
+
/** Public, bounded invalidation labels. A tag purge makes matching entries miss immediately. */
|
|
24
|
+
readonly tags?: readonly string[]
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -35,6 +37,45 @@ export interface CacheStore {
|
|
|
35
37
|
set(key: string, value: CachedResponse): Promise<void>
|
|
36
38
|
/** Drop `key` (on-demand revalidation / purge). A no-op if absent. */
|
|
37
39
|
delete(key: string): Promise<void>
|
|
40
|
+
/** Drop every cached response carrying `tag`, when the backend supports tag invalidation. */
|
|
41
|
+
readonly invalidateTag?: (tag: string) => Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ISR_TAG_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
|
|
45
|
+
const MAX_ISR_TAGS = 32
|
|
46
|
+
|
|
47
|
+
function normalizeTag(tag: string): string {
|
|
48
|
+
if (typeof tag !== "string" || !ISR_TAG_PATTERN.test(tag)) {
|
|
49
|
+
throw new TypeError("[nifra/web] ISR tag must be a bounded token")
|
|
50
|
+
}
|
|
51
|
+
return tag
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
|
|
55
|
+
if (tags === undefined) return []
|
|
56
|
+
if (!Array.isArray(tags) || tags.length > MAX_ISR_TAGS) {
|
|
57
|
+
throw new TypeError("[nifra/web] ISR tags must contain at most 32 bounded tokens")
|
|
58
|
+
}
|
|
59
|
+
const unique = new Set<string>()
|
|
60
|
+
for (const tag of tags) unique.add(normalizeTag(tag))
|
|
61
|
+
return Object.freeze([...unique])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tagsFromHeader(value: string | null): readonly string[] {
|
|
65
|
+
if (value === null || value.trim() === "") return []
|
|
66
|
+
try {
|
|
67
|
+
return normalizeTags(value.split(",").map((tag) => tag.trim()))
|
|
68
|
+
} catch {
|
|
69
|
+
// A malformed response-controlled header must never accidentally create an unpurgeable cache
|
|
70
|
+
// entry. Treat it as untagged; route declarations are validated before they reach this path.
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Validate and serialize route-declared ISR tags for the internal response header. */
|
|
76
|
+
export function serializeISRTags(tags: readonly string[]): string | undefined {
|
|
77
|
+
const normalized = normalizeTags(tags)
|
|
78
|
+
return normalized.length === 0 ? undefined : normalized.join(",")
|
|
38
79
|
}
|
|
39
80
|
|
|
40
81
|
export interface MemoryCacheStoreOptions {
|
|
@@ -52,8 +93,12 @@ export interface MemoryCacheStoreOptions {
|
|
|
52
93
|
* page survives a burst of new pages).
|
|
53
94
|
*/
|
|
54
95
|
export class MemoryCacheStore implements CacheStore {
|
|
55
|
-
private readonly cache = new Map<
|
|
96
|
+
private readonly cache = new Map<
|
|
97
|
+
string,
|
|
98
|
+
{ readonly value: CachedResponse; readonly tagEpochs: Readonly<Record<string, string>> }
|
|
99
|
+
>()
|
|
56
100
|
private readonly max: number
|
|
101
|
+
private readonly tagEpoch = new Map<string, string>()
|
|
57
102
|
|
|
58
103
|
constructor(options: MemoryCacheStoreOptions = {}) {
|
|
59
104
|
const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
|
|
@@ -71,31 +116,75 @@ export class MemoryCacheStore implements CacheStore {
|
|
|
71
116
|
}
|
|
72
117
|
|
|
73
118
|
get(key: string): Promise<CachedResponse | undefined> {
|
|
74
|
-
const
|
|
119
|
+
const row = this.cache.get(key)
|
|
120
|
+
if (row !== undefined) {
|
|
121
|
+
for (const [tag, epoch] of Object.entries(row.tagEpochs)) {
|
|
122
|
+
if ((this.tagEpoch.get(tag) ?? "0") !== epoch) {
|
|
123
|
+
this.cache.delete(key)
|
|
124
|
+
this.pruneTagEpochs()
|
|
125
|
+
return Promise.resolve(undefined)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const value = row?.value
|
|
75
130
|
// LRU: a read bumps the entry to the tail so the bounded evict drops the LEAST-RECENTLY-USED,
|
|
76
131
|
// not the oldest-written - otherwise a burst of new pages would evict a hot, frequently-read one.
|
|
77
132
|
if (value !== undefined) {
|
|
78
133
|
this.cache.delete(key)
|
|
79
|
-
this.cache.set(
|
|
134
|
+
this.cache.set(
|
|
135
|
+
key,
|
|
136
|
+
row as {
|
|
137
|
+
readonly value: CachedResponse
|
|
138
|
+
readonly tagEpochs: Readonly<Record<string, string>>
|
|
139
|
+
},
|
|
140
|
+
)
|
|
80
141
|
}
|
|
81
142
|
return Promise.resolve(value)
|
|
82
143
|
}
|
|
83
144
|
|
|
84
145
|
set(key: string, value: CachedResponse): Promise<void> {
|
|
146
|
+
const tags = normalizeTags(value.tags)
|
|
147
|
+
const tagEpochs: Record<string, string> = {}
|
|
148
|
+
for (const tag of tags) tagEpochs[tag] = this.tagEpoch.get(tag) ?? "0"
|
|
85
149
|
this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
|
|
86
|
-
this.cache.set(key, value)
|
|
150
|
+
this.cache.set(key, { value: tags.length === 0 ? value : { ...value, tags }, tagEpochs })
|
|
87
151
|
while (this.cache.size > this.max) {
|
|
88
152
|
const oldest = this.cache.keys().next().value
|
|
89
153
|
if (oldest === undefined) break
|
|
90
154
|
this.cache.delete(oldest)
|
|
91
155
|
}
|
|
156
|
+
this.pruneTagEpochs()
|
|
92
157
|
return Promise.resolve()
|
|
93
158
|
}
|
|
94
159
|
|
|
95
160
|
delete(key: string): Promise<void> {
|
|
96
161
|
this.cache.delete(key)
|
|
162
|
+
this.pruneTagEpochs()
|
|
163
|
+
return Promise.resolve()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
invalidateTag(tag: string): Promise<void> {
|
|
167
|
+
normalizeTag(tag)
|
|
168
|
+
this.tagEpoch.set(tag, crypto.randomUUID())
|
|
169
|
+
// This store is bounded and local, so eagerly remove matching rows. It keeps invalidation
|
|
170
|
+
// immediate and lets us discard epoch keys that no retained entry references anymore.
|
|
171
|
+
for (const [key, row] of this.cache) {
|
|
172
|
+
if (Object.hasOwn(row.tagEpochs, tag)) this.cache.delete(key)
|
|
173
|
+
}
|
|
174
|
+
this.pruneTagEpochs()
|
|
97
175
|
return Promise.resolve()
|
|
98
176
|
}
|
|
177
|
+
|
|
178
|
+
private pruneTagEpochs(): void {
|
|
179
|
+
if (this.tagEpoch.size === 0) return
|
|
180
|
+
const used = new Set<string>()
|
|
181
|
+
for (const row of this.cache.values()) {
|
|
182
|
+
for (const tag of Object.keys(row.tagEpochs)) used.add(tag)
|
|
183
|
+
}
|
|
184
|
+
for (const tag of this.tagEpoch.keys()) {
|
|
185
|
+
if (!used.has(tag)) this.tagEpoch.delete(tag)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
99
188
|
}
|
|
100
189
|
|
|
101
190
|
/**
|
|
@@ -121,6 +210,16 @@ export interface KVCacheStoreOptions {
|
|
|
121
210
|
* overwritten on regeneration or purged via `revalidateEndpoint`.
|
|
122
211
|
*/
|
|
123
212
|
readonly expirationTtl?: number
|
|
213
|
+
/**
|
|
214
|
+
* Smallest `expirationTtl` this binding accepts (**seconds**). Defaults to 60, which is Cloudflare
|
|
215
|
+
* KV's floor - the common case, and worth rejecting at construction because Cloudflare fails the
|
|
216
|
+
* `put` at runtime instead, one deploy later.
|
|
217
|
+
*
|
|
218
|
+
* The floor belongs to the *binding*, not to this class. {@link KVNamespaceLike} is structural, so
|
|
219
|
+
* a Redis, Deno KV, Upstash, or in-memory binding satisfies it too, and those accept far shorter
|
|
220
|
+
* TTLs. Pass their real minimum, or `0` for a backend with none.
|
|
221
|
+
*/
|
|
222
|
+
readonly minExpirationTtl?: number
|
|
124
223
|
}
|
|
125
224
|
|
|
126
225
|
/**
|
|
@@ -141,31 +240,64 @@ const isCachedResponse = (value: unknown): value is CachedResponse => {
|
|
|
141
240
|
) {
|
|
142
241
|
return false
|
|
143
242
|
}
|
|
243
|
+
if (v.tags !== undefined) {
|
|
244
|
+
if (!Array.isArray(v.tags)) return false
|
|
245
|
+
try {
|
|
246
|
+
normalizeTags(v.tags as string[])
|
|
247
|
+
} catch {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
}
|
|
144
251
|
for (const headerValue of Object.values(v.headers as Record<string, unknown>)) {
|
|
145
252
|
if (typeof headerValue !== "string") return false
|
|
146
253
|
}
|
|
147
254
|
return true
|
|
148
255
|
}
|
|
149
256
|
|
|
257
|
+
const KV_TAG_EPOCH_PREFIX = "__nifra_isr_tag_v1:"
|
|
258
|
+
|
|
259
|
+
type StoredTaggedResponse = CachedResponse & {
|
|
260
|
+
readonly __nifraTagEpochs?: Readonly<Record<string, string>>
|
|
261
|
+
}
|
|
262
|
+
|
|
150
263
|
/**
|
|
151
264
|
* A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
|
|
152
265
|
* binding) - the production-grade shared/durable store ISR wants: cached pages and on-demand purges
|
|
153
266
|
* hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
|
|
154
267
|
* to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
|
|
155
268
|
* a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
|
|
269
|
+
*
|
|
270
|
+
* Cloudflare is the default, not a requirement: {@link KVNamespaceLike} is three structural methods,
|
|
271
|
+
* so a Redis, Deno KV, or Upstash binding satisfies it (pass `minExpirationTtl` for their TTL floor).
|
|
272
|
+
* A backend that *can* enumerate keys deserves its own {@link CacheStore} rather than this one - the
|
|
273
|
+
* epoch indirection in {@link KVCacheStore.invalidateTag} exists only because KV cannot list by tag.
|
|
156
274
|
*/
|
|
157
275
|
export class KVCacheStore implements CacheStore {
|
|
158
276
|
private readonly kv: KVNamespaceLike
|
|
159
277
|
private readonly putOptions: { readonly expirationTtl?: number } | undefined
|
|
160
278
|
|
|
161
279
|
constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"blocking miss by KV expiry.",
|
|
280
|
+
const floor = options.minExpirationTtl ?? 60
|
|
281
|
+
if (!Number.isSafeInteger(floor) || floor < 0) {
|
|
282
|
+
throw new RangeError(
|
|
283
|
+
"[nifra/web] KVCacheStore minExpirationTtl must be a non-negative integer",
|
|
167
284
|
)
|
|
168
285
|
}
|
|
286
|
+
if (options.expirationTtl !== undefined) {
|
|
287
|
+
if (!Number.isSafeInteger(options.expirationTtl) || options.expirationTtl < 0) {
|
|
288
|
+
throw new RangeError(
|
|
289
|
+
"[nifra/web] KVCacheStore expirationTtl must be a non-negative integer (seconds)",
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
if (options.expirationTtl < floor) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`[nifra/web] KVCacheStore expirationTtl must be >= ${floor}, this binding's minimum. 60 is ` +
|
|
295
|
+
"Cloudflare KV's floor and the default; pass `minExpirationTtl` for a backend with a " +
|
|
296
|
+
"lower one. Whatever the floor, the TTL should exceed your longest `revalidate` window, " +
|
|
297
|
+
"or expiry turns a stale-while-revalidate into a blocking miss.",
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
169
301
|
this.kv = kv
|
|
170
302
|
// Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
|
|
171
303
|
this.putOptions =
|
|
@@ -182,16 +314,53 @@ export class KVCacheStore implements CacheStore {
|
|
|
182
314
|
// A corrupt (non-JSON) entry: treat as a miss so the page re-renders; the next set overwrites it.
|
|
183
315
|
return undefined
|
|
184
316
|
}
|
|
185
|
-
|
|
317
|
+
if (!isCachedResponse(parsed)) return undefined
|
|
318
|
+
const tags = normalizeTags(parsed.tags)
|
|
319
|
+
if (tags.length === 0) return parsed
|
|
320
|
+
const epochs = (parsed as StoredTaggedResponse).__nifraTagEpochs
|
|
321
|
+
if (epochs === undefined) return undefined
|
|
322
|
+
const current = await Promise.all(
|
|
323
|
+
tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
|
|
324
|
+
)
|
|
325
|
+
if (current.some(([tag, epoch]) => epochs[tag] !== epoch)) return undefined
|
|
326
|
+
const { __nifraTagEpochs: _ignored, ...value } = parsed as StoredTaggedResponse
|
|
327
|
+
return value
|
|
186
328
|
}
|
|
187
329
|
|
|
188
330
|
async set(key: string, value: CachedResponse): Promise<void> {
|
|
189
|
-
|
|
331
|
+
const tags = normalizeTags(value.tags)
|
|
332
|
+
if (tags.length === 0) {
|
|
333
|
+
await this.kv.put(key, JSON.stringify(value), this.putOptions)
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
const epochs = Object.fromEntries(
|
|
337
|
+
await Promise.all(
|
|
338
|
+
tags.map(async (tag) => [tag, (await this.kv.get(this.tagKey(tag))) ?? "0"] as const),
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
const stored: StoredTaggedResponse = {
|
|
342
|
+
...value,
|
|
343
|
+
tags,
|
|
344
|
+
__nifraTagEpochs: epochs,
|
|
345
|
+
}
|
|
346
|
+
await this.kv.put(key, JSON.stringify(stored), this.putOptions)
|
|
190
347
|
}
|
|
191
348
|
|
|
192
349
|
async delete(key: string): Promise<void> {
|
|
193
350
|
await this.kv.delete(key)
|
|
194
351
|
}
|
|
352
|
+
|
|
353
|
+
async invalidateTag(tag: string): Promise<void> {
|
|
354
|
+
normalizeTag(tag)
|
|
355
|
+
// Epoch invalidation avoids KV key enumeration and remains bounded for a tag with millions of
|
|
356
|
+
// pages. Epoch keys intentionally have no expiration: an expired epoch would resurrect entries
|
|
357
|
+
// written before the invalidation when their page key still exists.
|
|
358
|
+
await this.kv.put(this.tagKey(tag), crypto.randomUUID())
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private tagKey(tag: string): string {
|
|
362
|
+
return `${KV_TAG_EPOCH_PREFIX}${encodeURIComponent(tag)}`
|
|
363
|
+
}
|
|
195
364
|
}
|
|
196
365
|
|
|
197
366
|
/** Minimal platform shape `withISR` needs - just `waitUntil` (edge runtimes extend the response
|
|
@@ -209,6 +378,9 @@ export interface ISRApp {
|
|
|
209
378
|
* regenerating behind it), or `miss` (rendered now + stored). Useful for debugging + tests. */
|
|
210
379
|
export const ISR_STATUS_HEADER = "x-nifra-isr"
|
|
211
380
|
|
|
381
|
+
/** Response header carrying bounded route tags into {@link withISR}. */
|
|
382
|
+
export const ISR_REVALIDATE_TAGS_HEADER = "x-nifra-isr-tags"
|
|
383
|
+
|
|
212
384
|
/**
|
|
213
385
|
* Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
|
|
214
386
|
* wrapper - `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
|
|
@@ -289,6 +461,9 @@ const CACHEABLE_RESPONSE_HEADERS = new Set([
|
|
|
289
461
|
"link",
|
|
290
462
|
])
|
|
291
463
|
|
|
464
|
+
const tagsOf = (res: Response): readonly string[] =>
|
|
465
|
+
tagsFromHeader(res.headers.get(ISR_REVALIDATE_TAGS_HEADER))
|
|
466
|
+
|
|
292
467
|
const headersOf = (res: Response): Record<string, string> => {
|
|
293
468
|
const out: Record<string, string> = {}
|
|
294
469
|
res.headers.forEach((value, key) => {
|
|
@@ -349,12 +524,14 @@ export function withISR(
|
|
|
349
524
|
const res = await app.fetch(req, platform)
|
|
350
525
|
if (!isCacheablePage(req, res)) return res
|
|
351
526
|
const body = await res.text()
|
|
527
|
+
const tags = tagsOf(res)
|
|
352
528
|
const entry: CachedResponse = {
|
|
353
529
|
body,
|
|
354
530
|
status: res.status,
|
|
355
531
|
headers: headersOf(res),
|
|
356
532
|
storedAt: now(),
|
|
357
533
|
revalidate: ttlMs(res),
|
|
534
|
+
...(tags.length === 0 ? {} : { tags }),
|
|
358
535
|
}
|
|
359
536
|
await store.set(key, entry)
|
|
360
537
|
return new Response(body, {
|
|
@@ -375,12 +552,14 @@ export function withISR(
|
|
|
375
552
|
try {
|
|
376
553
|
const res = await app.fetch(req, platform)
|
|
377
554
|
if (isCacheablePage(req, res)) {
|
|
555
|
+
const tags = tagsOf(res)
|
|
378
556
|
await store.set(key, {
|
|
379
557
|
body: await res.text(),
|
|
380
558
|
status: res.status,
|
|
381
559
|
headers: headersOf(res),
|
|
382
560
|
storedAt: now(),
|
|
383
561
|
revalidate: ttlMs(res),
|
|
562
|
+
...(tags.length === 0 ? {} : { tags }),
|
|
384
563
|
})
|
|
385
564
|
} else {
|
|
386
565
|
await res.body?.cancel()
|
|
@@ -431,9 +610,10 @@ export interface RevalidateEndpointOptions {
|
|
|
431
610
|
|
|
432
611
|
/**
|
|
433
612
|
* An **on-demand revalidation** (purge) endpoint - a `fetch` handler that drops a path's cached entry
|
|
434
|
-
*
|
|
435
|
-
* or a JSON `{ "path": "/blog/x" }`
|
|
436
|
-
*
|
|
613
|
+
* or invalidates every entry carrying a tag. `POST` with the secret in the token header and either
|
|
614
|
+
* `?path=/blog/x`, `?tag=products`, or a JSON `{ "path": "/blog/x" }` / `{ "tag": "products" }` body.
|
|
615
|
+
* The token is checked in **constant time** (wrong/missing → `401`); malformed targets → `400`;
|
|
616
|
+
* non-POST → `405`. A store without tag support returns `501` for tag requests. Mount it on a nifra route, e.g.
|
|
437
617
|
* `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
|
|
438
618
|
*/
|
|
439
619
|
export function revalidateEndpoint(
|
|
@@ -446,12 +626,29 @@ export function revalidateEndpoint(
|
|
|
446
626
|
if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
|
|
447
627
|
return jsonError(401, "unauthorized")
|
|
448
628
|
}
|
|
449
|
-
|
|
450
|
-
|
|
629
|
+
const url = new URL(req.url)
|
|
630
|
+
let path = url.searchParams.get("path")
|
|
631
|
+
let tag = url.searchParams.get("tag")
|
|
632
|
+
if (path === null && tag === null) {
|
|
451
633
|
const body: unknown = await req.json().catch(() => null)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
634
|
+
if (typeof body === "object" && body !== null) {
|
|
635
|
+
const candidate = body as { path?: unknown; tag?: unknown }
|
|
636
|
+
path = typeof candidate.path === "string" ? candidate.path : null
|
|
637
|
+
tag = typeof candidate.tag === "string" ? candidate.tag : null
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (path !== null && tag !== null) return jsonError(400, "choose_path_or_tag")
|
|
641
|
+
if (tag !== null) {
|
|
642
|
+
try {
|
|
643
|
+
normalizeTag(tag)
|
|
644
|
+
} catch {
|
|
645
|
+
return jsonError(400, "invalid_tag")
|
|
646
|
+
}
|
|
647
|
+
if (options.store.invalidateTag === undefined) {
|
|
648
|
+
return jsonError(501, "tag_invalidation_not_supported")
|
|
649
|
+
}
|
|
650
|
+
await options.store.invalidateTag(tag)
|
|
651
|
+
return Response.json({ revalidatedTag: tag })
|
|
455
652
|
}
|
|
456
653
|
if (path === null || !path.startsWith("/")) return jsonError(400, "invalid_path")
|
|
457
654
|
await options.store.delete(keyOf(path, req))
|
package/src/manifest.ts
CHANGED
|
@@ -227,6 +227,8 @@ export interface RouteModule {
|
|
|
227
227
|
* default). Older-than-`revalidate` cached pages are served stale while regenerating.
|
|
228
228
|
*/
|
|
229
229
|
readonly revalidate?: number
|
|
230
|
+
/** Optional bounded tags used by ISR on-demand invalidation (`?tag=...`). */
|
|
231
|
+
readonly revalidateTags?: readonly string[]
|
|
230
232
|
/**
|
|
231
233
|
* No-framework island bundles (`@nifrajs/web/islands`) to load on this route, as `<script
|
|
232
234
|
* type="module">` in the document tail. Loaded **regardless of `hydrate`** - pair with
|
package/src/public-dir.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { realpath } from "node:fs/promises"
|
|
16
16
|
import { normalize, resolve, sep } from "node:path"
|
|
17
|
+
import { parseByteRange } from "@nifrajs/core/range"
|
|
17
18
|
import { pathnameOf } from "@nifrajs/core/server"
|
|
18
19
|
|
|
19
20
|
/** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
|
|
@@ -38,6 +39,35 @@ export interface ServePublicDirOptions {
|
|
|
38
39
|
const IMMUTABLE = "public, max-age=31536000, immutable"
|
|
39
40
|
const ONE_DAY = "public, max-age=86400"
|
|
40
41
|
|
|
42
|
+
/** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
|
|
43
|
+
function seconds(time: number): number {
|
|
44
|
+
return Math.floor(time / 1000)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `If-Range` decides whether a range request is still safe to answer partially. The only validator
|
|
49
|
+
* this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
|
|
50
|
+
* representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
|
|
51
|
+
* comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
|
|
52
|
+
*/
|
|
53
|
+
function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
|
|
54
|
+
if (value === null) return true
|
|
55
|
+
if (lastModified === undefined) return false
|
|
56
|
+
const item = value.trim()
|
|
57
|
+
if (item.startsWith('"') || item.startsWith("W/")) return false
|
|
58
|
+
const parsed = Date.parse(item)
|
|
59
|
+
return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
|
|
63
|
+
function isNotModified(request: Request, lastModified: number | undefined): boolean {
|
|
64
|
+
if (lastModified === undefined) return false
|
|
65
|
+
const since = request.headers.get("if-modified-since")
|
|
66
|
+
if (since === null) return false
|
|
67
|
+
const parsed = Date.parse(since)
|
|
68
|
+
return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
|
|
69
|
+
}
|
|
70
|
+
|
|
41
71
|
/**
|
|
42
72
|
* Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
|
|
43
73
|
*
|
|
@@ -101,12 +131,52 @@ export function servePublicDir(
|
|
|
101
131
|
}
|
|
102
132
|
const file = Bun.file(resolvedFile)
|
|
103
133
|
if (!(await file.exists())) return undefined
|
|
134
|
+
const size = file.size
|
|
104
135
|
const headers = new Headers({
|
|
105
136
|
"cache-control": pathname.startsWith(hashedPrefix) ? hashed : assets,
|
|
137
|
+
// Advertised unconditionally: a client that never sees `accept-ranges` will not attempt a seek,
|
|
138
|
+
// so a video or audio file under `public/` is scrubbable only once this header is present.
|
|
139
|
+
"accept-ranges": "bytes",
|
|
106
140
|
})
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
141
|
+
// `new Response(file)` used to infer the media type, which meant HEAD - built from a null body -
|
|
142
|
+
// silently lost it. Setting it here is what makes the two methods agree.
|
|
143
|
+
if (file.type !== "") headers.set("content-type", file.type)
|
|
144
|
+
const lastModified =
|
|
145
|
+
Number.isFinite(file.lastModified) && file.lastModified > 0 ? file.lastModified : undefined
|
|
146
|
+
if (lastModified !== undefined) {
|
|
147
|
+
headers.set("last-modified", new Date(lastModified).toUTCString())
|
|
148
|
+
}
|
|
149
|
+
const head = request.method === "HEAD"
|
|
150
|
+
|
|
151
|
+
if (isNotModified(request, lastModified)) {
|
|
152
|
+
headers.delete("content-type")
|
|
153
|
+
return new Response(null, { status: 304, headers })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rangeHeader = request.headers.get("range")
|
|
157
|
+
const range =
|
|
158
|
+
rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
|
|
159
|
+
? parseByteRange(rangeHeader, size)
|
|
160
|
+
: ({ kind: "none" } as const)
|
|
161
|
+
|
|
162
|
+
if (range.kind === "unsatisfiable") {
|
|
163
|
+
headers.delete("content-type")
|
|
164
|
+
headers.set("content-range", `bytes */${size}`)
|
|
165
|
+
return new Response(null, { status: 416, headers })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (range.kind === "satisfiable" && range.ranges.length === 1) {
|
|
169
|
+
const { start, end } = range.ranges[0]!
|
|
170
|
+
headers.set("content-range", `bytes ${start}-${end}/${size}`)
|
|
171
|
+
headers.set("content-length", String(end - start + 1))
|
|
172
|
+
// `slice` keeps the read lazy: only the selected window is ever pulled off disk.
|
|
173
|
+
return new Response(head ? null : file.slice(start, end + 1), { status: 206, headers })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Multiple ranges would require assembling `multipart/byteranges`, and doing that for a file on
|
|
177
|
+
// disk means buffering the whole representation to serve a request that asked for less of it.
|
|
178
|
+
// RFC 9110 lets a server ignore Range entirely, so the full body is the conformant answer here.
|
|
179
|
+
if (head) headers.set("content-length", String(size))
|
|
180
|
+
return new Response(head ? null : file, { headers })
|
|
111
181
|
}
|
|
112
182
|
}
|
package/src/route-manifest.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface RouteManifestEntry {
|
|
|
41
41
|
readonly hydrate: boolean
|
|
42
42
|
/** Revalidation window in seconds. Present only for `isr`. */
|
|
43
43
|
readonly revalidate?: number
|
|
44
|
+
/** Bounded tags that can purge this route from a shared ISR store. */
|
|
45
|
+
readonly revalidateTags?: readonly string[]
|
|
44
46
|
/**
|
|
45
47
|
* Concrete paths prerendered at build. Present for `static` routes: a single path for a static route,
|
|
46
48
|
* one per `getStaticPaths` entry for a dynamic one. Absent means "no path was enumerated", which for a
|
|
@@ -84,7 +86,10 @@ const isDynamic = (pattern: string): boolean => /[:*]/.test(pattern)
|
|
|
84
86
|
export function deriveRouteEntry(
|
|
85
87
|
id: string,
|
|
86
88
|
pattern: string,
|
|
87
|
-
module: Pick<
|
|
89
|
+
module: Pick<
|
|
90
|
+
RouteModule,
|
|
91
|
+
"prerender" | "getStaticPaths" | "revalidate" | "revalidateTags" | "hydrate"
|
|
92
|
+
>,
|
|
88
93
|
prerenderedPaths?: readonly string[],
|
|
89
94
|
): RouteManifestEntry {
|
|
90
95
|
const wantsPrerender = module.prerender === true || module.getStaticPaths !== undefined
|
|
@@ -105,10 +110,14 @@ export function deriveRouteEntry(
|
|
|
105
110
|
mode: RenderMode
|
|
106
111
|
hydrate: boolean
|
|
107
112
|
revalidate?: number
|
|
113
|
+
revalidateTags?: readonly string[]
|
|
108
114
|
prerenderedPaths?: readonly string[]
|
|
109
115
|
requires: readonly RouteCapability[]
|
|
110
116
|
} = { id, pattern, mode, hydrate: module.hydrate !== false, requires }
|
|
111
117
|
if (mode === "isr" && module.revalidate !== undefined) entry.revalidate = module.revalidate
|
|
118
|
+
if (mode === "isr" && module.revalidateTags !== undefined) {
|
|
119
|
+
entry.revalidateTags = module.revalidateTags
|
|
120
|
+
}
|
|
112
121
|
if (prerenderedPaths !== undefined) entry.prerenderedPaths = prerenderedPaths
|
|
113
122
|
return entry as RouteManifestEntry
|
|
114
123
|
}
|