@nifrajs/web 0.1.0-alpha.1

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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
package/src/islands.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @nifrajs/web/islands — the framework-agnostic islands client runtime.
3
+ *
4
+ * An "island" is a server-rendered `<nifra-island>` element whose interactivity is mounted by a small
5
+ * **plain-DOM** enhancer instead of a framework runtime. On a route that opts out of full-document
6
+ * hydration (`export const hydrate = false`), the page ships **zero** framework JS; only the island
7
+ * bundle (this runtime + your enhancers) loads. This variant ships ~99% less client JS than full
8
+ * React hydration on a mostly-static page — the framework runtime is the cost, and islands skip it
9
+ * entirely here.
10
+ *
11
+ * Server side: render the marker with `@nifrajs/web-react`'s `<Island>` (or any framework's host element
12
+ * `<nifra-island data-id data-strategy data-props>`). Props are JSON-encoded inline in `data-props`
13
+ * (attribute-escaped by the framework) — no central registry, so concurrent SSR renders never share
14
+ * state. Client side: bundle one entry that calls `mountIslands({ <id>: enhancer })` and load it via
15
+ * the route's `islandScripts`.
16
+ */
17
+
18
+ /** When an island's enhancer runs. Default `load`. */
19
+ export type IslandStrategy = "load" | "idle" | "visible"
20
+
21
+ /** Optional teardown an enhancer returns (remove listeners/observers); run on `dispose()`. */
22
+ export type IslandCleanup = () => void
23
+
24
+ /**
25
+ * Enhances one island element with its (typed) props. Return a cleanup function to tear down on
26
+ * `dispose()` (listeners, observers) — optional; an enhancer with nothing to clean up returns nothing.
27
+ * The `void` member is the no-cleanup case, the same shape as React's `EffectCallback`.
28
+ */
29
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void` = "no cleanup returned", like React's EffectCallback — cleanup is optional.
30
+ export type IslandEnhancer<P = unknown> = (el: HTMLElement, props: P) => IslandCleanup | void
31
+
32
+ const NOOP = (): void => {}
33
+
34
+ /** Parse an island's inline `data-props` JSON; malformed/absent → `undefined` (never throws). */
35
+ function readProps(el: HTMLElement): unknown {
36
+ const raw = el.dataset.props
37
+ if (raw === undefined || raw === "") return undefined
38
+ try {
39
+ return JSON.parse(raw)
40
+ } catch {
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ /** Run `run` per the island's strategy; returns a disposer that cancels a not-yet-fired trigger. */
46
+ function whenStrategy(el: HTMLElement, strategy: IslandStrategy, run: () => void): () => void {
47
+ if (strategy === "idle") {
48
+ const ric = globalThis.requestIdleCallback
49
+ if (ric === undefined) {
50
+ const t = setTimeout(run, 1)
51
+ return () => clearTimeout(t)
52
+ }
53
+ const handle = ric(run)
54
+ return () => globalThis.cancelIdleCallback?.(handle)
55
+ }
56
+ if (strategy === "visible") {
57
+ // No IntersectionObserver (old engine / SSR smoke) → degrade to immediate, never skip the island.
58
+ if (typeof IntersectionObserver === "undefined") {
59
+ run()
60
+ return NOOP
61
+ }
62
+ const io = new IntersectionObserver((entries, obs) => {
63
+ for (const entry of entries) {
64
+ if (entry.isIntersecting) {
65
+ obs.disconnect()
66
+ run()
67
+ return
68
+ }
69
+ }
70
+ })
71
+ io.observe(el)
72
+ return () => io.disconnect()
73
+ }
74
+ // "load" (and any unknown value) → run immediately on mount.
75
+ run()
76
+ return NOOP
77
+ }
78
+
79
+ /**
80
+ * Find every `<nifra-island data-id>` under `root` (default `document`) and enhance each with the
81
+ * matching enhancer, honoring its `data-strategy`. An island whose `id` has no enhancer is left as
82
+ * inert SSR HTML (forward-compatible). An enhancer that throws is isolated — it never blocks the
83
+ * others (each island is independent). Returns a disposer that cancels pending triggers and runs every
84
+ * enhancer's cleanup (call it on soft-nav teardown; harmless if your app never navigates).
85
+ */
86
+ export function mountIslands(
87
+ enhancers: Readonly<Record<string, IslandEnhancer>>,
88
+ options: { readonly root?: ParentNode } = {},
89
+ ): () => void {
90
+ const root = options.root ?? document
91
+ const disposers: Array<() => void> = []
92
+ for (const el of root.querySelectorAll<HTMLElement>("nifra-island[data-id]")) {
93
+ const id = el.dataset.id
94
+ if (id === undefined) continue
95
+ const enhancer = enhancers[id]
96
+ if (enhancer === undefined) continue // no enhancer for this id → leave it as inert SSR markup
97
+ const props = readProps(el)
98
+ const strategyAttr = el.dataset.strategy
99
+ const strategy: IslandStrategy =
100
+ strategyAttr === "idle" || strategyAttr === "visible" ? strategyAttr : "load"
101
+ const run = (): void => {
102
+ try {
103
+ const cleanup = enhancer(el, props)
104
+ if (typeof cleanup === "function") disposers.push(cleanup)
105
+ } catch (err) {
106
+ // One island's failure must not take down the others; surface it without throwing.
107
+ console.error(`[nifra/islands] enhancer "${id}" failed:`, err)
108
+ }
109
+ }
110
+ disposers.push(whenStrategy(el, strategy, run))
111
+ }
112
+ return () => {
113
+ for (const dispose of disposers) {
114
+ try {
115
+ dispose()
116
+ } catch {
117
+ // best-effort teardown — a failing cleanup must not block the rest
118
+ }
119
+ }
120
+ }
121
+ }
package/src/isr.ts ADDED
@@ -0,0 +1,461 @@
1
+ /**
2
+ * ISR (Incremental Static Regeneration). A caching layer that sits ABOVE the agnostic SSR
3
+ * handler: it stores a rendered `Response` keyed by URL and serves it with stale-while-revalidate
4
+ * freshness, so dynamic pages get static-like speed and revalidate in the background. Agnostic to the
5
+ * UI framework (it caches bytes), coupled to the runtime only through this pluggable {@link CacheStore}.
6
+ *
7
+ * This module is the store primitive; the SWR wrapper (`withISR`) builds on it next.
8
+ */
9
+ import { isDraftEnabled } from "./draft.ts"
10
+
11
+ /** A cached SSR response — the bytes + metadata a {@link CacheStore} persists. */
12
+ export interface CachedResponse {
13
+ /** The rendered document (UTF-8 HTML, fully buffered). */
14
+ readonly body: string
15
+ readonly status: number
16
+ /** Response headers to replay (e.g. `content-type`). */
17
+ readonly headers: Readonly<Record<string, string>>
18
+ /** When this entry was stored, via the injected clock (ms epoch). */
19
+ readonly storedAt: number
20
+ /** Freshness window (ms): `now - storedAt >= revalidate` ⇒ stale (serve it, regenerate behind it). */
21
+ readonly revalidate: number
22
+ }
23
+
24
+ /**
25
+ * Pluggable ISR cache backend. **Production deploys MUST use a shared/durable store** (Workers KV,
26
+ * Redis, the platform Cache API) so cached pages *and* revalidation hold across instances;
27
+ * {@link MemoryCacheStore} is dev / single-instance only. Implementations are async so a network store
28
+ * (KV/Redis) fits the same interface.
29
+ */
30
+ export interface CacheStore {
31
+ /** The cached entry for `key`, or `undefined` on a miss. */
32
+ get(key: string): Promise<CachedResponse | undefined>
33
+ /** Store (or overwrite) the entry for `key`. */
34
+ set(key: string, value: CachedResponse): Promise<void>
35
+ /** Drop `key` (on-demand revalidation / purge). A no-op if absent. */
36
+ delete(key: string): Promise<void>
37
+ }
38
+
39
+ export interface MemoryCacheStoreOptions {
40
+ /** Allow the in-memory store in production. Off by default — per-instance caching means revalidation
41
+ * won't propagate across instances and each instance caches separately. */
42
+ readonly allowInProduction?: boolean
43
+ /** Hard cap on entries; the least-recently-used is evicted past it (default 500). */
44
+ readonly max?: number
45
+ }
46
+
47
+ /**
48
+ * In-process ISR cache. Refuses to run in production unless explicitly allowed (mirrors the
49
+ * rate-limit `MemoryStore` — a per-instance cache is unsafe across instances). Bounded **LRU**: a
50
+ * read or write bumps the entry, so the least-recently-used evicts past `max` (a hot, frequently-read
51
+ * page survives a burst of new pages).
52
+ */
53
+ export class MemoryCacheStore implements CacheStore {
54
+ private readonly cache = new Map<string, CachedResponse>()
55
+ private readonly max: number
56
+
57
+ constructor(options: MemoryCacheStoreOptions = {}) {
58
+ const isProd = typeof process !== "undefined" && process.env?.NODE_ENV === "production"
59
+ if (options.allowInProduction !== true && isProd) {
60
+ throw new Error(
61
+ "[nifra/web] MemoryCacheStore is per-instance and unsafe in production (each instance caches " +
62
+ "separately, and on-demand revalidation won't propagate). Use a shared store (Workers KV, " +
63
+ "Redis, the Cache API), or pass { allowInProduction: true } for a single-instance deploy.",
64
+ )
65
+ }
66
+ this.max = options.max ?? 500
67
+ }
68
+
69
+ get(key: string): Promise<CachedResponse | undefined> {
70
+ const value = this.cache.get(key)
71
+ // LRU: a read bumps the entry to the tail so the bounded evict drops the LEAST-RECENTLY-USED,
72
+ // not the oldest-written — otherwise a burst of new pages would evict a hot, frequently-read one.
73
+ if (value !== undefined) {
74
+ this.cache.delete(key)
75
+ this.cache.set(key, value)
76
+ }
77
+ return Promise.resolve(value)
78
+ }
79
+
80
+ set(key: string, value: CachedResponse): Promise<void> {
81
+ this.cache.delete(key) // re-insert at the tail so Map order tracks recency (with get) for the LRU evict
82
+ this.cache.set(key, value)
83
+ while (this.cache.size > this.max) {
84
+ const oldest = this.cache.keys().next().value
85
+ if (oldest === undefined) break
86
+ this.cache.delete(oldest)
87
+ }
88
+ return Promise.resolve()
89
+ }
90
+
91
+ delete(key: string): Promise<void> {
92
+ this.cache.delete(key)
93
+ return Promise.resolve()
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Minimal structural shape of a Cloudflare Workers **KV namespace** binding — just the three methods
99
+ * {@link KVCacheStore} uses. Structural (not a dependency on `@cloudflare/workers-types`) so any
100
+ * KV-like binding satisfies it and tests can pass an in-memory double.
101
+ */
102
+ export interface KVNamespaceLike {
103
+ /** Read a stored string value, or `null` on a miss (Cloudflare's `KVNamespace.get(key)` default). */
104
+ get(key: string): Promise<string | null>
105
+ /** Write a string value, optionally with a TTL (**seconds**). */
106
+ put(key: string, value: string, options?: { readonly expirationTtl?: number }): Promise<void>
107
+ /** Delete a key (a no-op if absent). */
108
+ delete(key: string): Promise<void>
109
+ }
110
+
111
+ export interface KVCacheStoreOptions {
112
+ /**
113
+ * GC backstop (**seconds**) written as the KV entry's `expirationTtl`, so abandoned entries
114
+ * eventually evict. MUST exceed your longest `revalidate` window — otherwise KV expiry turns a
115
+ * stale-while-revalidate into a *blocking* miss (the entry vanishes instead of being served stale
116
+ * while it regenerates) — and be ≥ 60 (Cloudflare KV's minimum). Omit ⇒ entries persist until
117
+ * overwritten on regeneration or purged via `revalidateEndpoint`.
118
+ */
119
+ readonly expirationTtl?: number
120
+ }
121
+
122
+ /**
123
+ * Structural validation of a KV-read value before it's trusted as a {@link CachedResponse}. The store
124
+ * is a trust boundary (version skew across a deploy, corruption, tampering), so a malformed entry is
125
+ * treated as a miss (the page re-renders + overwrites it) rather than served as a broken response.
126
+ */
127
+ const isCachedResponse = (value: unknown): value is CachedResponse => {
128
+ if (typeof value !== "object" || value === null) return false
129
+ const v = value as Record<string, unknown>
130
+ if (
131
+ typeof v.body !== "string" ||
132
+ typeof v.status !== "number" ||
133
+ typeof v.storedAt !== "number" ||
134
+ typeof v.revalidate !== "number" ||
135
+ typeof v.headers !== "object" ||
136
+ v.headers === null
137
+ ) {
138
+ return false
139
+ }
140
+ for (const headerValue of Object.values(v.headers as Record<string, unknown>)) {
141
+ if (typeof headerValue !== "string") return false
142
+ }
143
+ return true
144
+ }
145
+
146
+ /**
147
+ * A {@link CacheStore} backed by a **Cloudflare Workers KV** namespace (or any {@link KVNamespaceLike}
148
+ * binding) — the production-grade shared/durable store ISR wants: cached pages and on-demand purges
149
+ * hold *across* worker instances (unlike the per-instance {@link MemoryCacheStore}). Entries serialize
150
+ * to JSON; every read is validated before it's trusted (a malformed/version-skewed entry is treated as
151
+ * a miss). Construct it in your Workers `fetch` from the binding: `new KVCacheStore(env.ISR_CACHE)`.
152
+ */
153
+ export class KVCacheStore implements CacheStore {
154
+ private readonly kv: KVNamespaceLike
155
+ private readonly putOptions: { readonly expirationTtl?: number } | undefined
156
+
157
+ constructor(kv: KVNamespaceLike, options: KVCacheStoreOptions = {}) {
158
+ if (options.expirationTtl !== undefined && options.expirationTtl < 60) {
159
+ throw new Error(
160
+ "[nifra/web] KVCacheStore expirationTtl must be >= 60 (Cloudflare KV's minimum), and should " +
161
+ "exceed your longest `revalidate` window so stale-while-revalidate isn't turned into a " +
162
+ "blocking miss by KV expiry.",
163
+ )
164
+ }
165
+ this.kv = kv
166
+ // Precompute the put options object once: omit it entirely when no TTL (KV then never expires).
167
+ this.putOptions =
168
+ options.expirationTtl !== undefined ? { expirationTtl: options.expirationTtl } : undefined
169
+ }
170
+
171
+ async get(key: string): Promise<CachedResponse | undefined> {
172
+ const raw = await this.kv.get(key)
173
+ if (raw === null) return undefined
174
+ let parsed: unknown
175
+ try {
176
+ parsed = JSON.parse(raw)
177
+ } catch {
178
+ // A corrupt (non-JSON) entry: treat as a miss so the page re-renders; the next set overwrites it.
179
+ return undefined
180
+ }
181
+ return isCachedResponse(parsed) ? parsed : undefined
182
+ }
183
+
184
+ async set(key: string, value: CachedResponse): Promise<void> {
185
+ await this.kv.put(key, JSON.stringify(value), this.putOptions)
186
+ }
187
+
188
+ async delete(key: string): Promise<void> {
189
+ await this.kv.delete(key)
190
+ }
191
+ }
192
+
193
+ /** Minimal platform shape `withISR` needs — just `waitUntil` (edge runtimes extend the response
194
+ * lifetime so background regeneration finishes). Off-edge it's absent and regen runs fire-and-forget. */
195
+ export interface ISRPlatform {
196
+ readonly waitUntil?: (promise: Promise<unknown>) => void
197
+ }
198
+
199
+ /** The app `withISR` wraps — anything with a `fetch(req, platform?)` (a `createWebApp` result). */
200
+ export interface ISRApp {
201
+ fetch(req: Request, platform?: ISRPlatform): Response | Promise<Response>
202
+ }
203
+
204
+ /** Response header marking how an ISR response was served: a cache `hit` (fresh), `stale` (served +
205
+ * regenerating behind it), or `miss` (rendered now + stored). Useful for debugging + tests. */
206
+ export const ISR_STATUS_HEADER = "x-nifra-isr"
207
+
208
+ /**
209
+ * Response header a route uses to advertise its ISR freshness (**seconds**) to a {@link withISR}
210
+ * wrapper — `createWebApp` emits it from a route's `export const revalidate`. Deliberately distinct
211
+ * from the action-revalidation `x-nifra-revalidate` header (a CSV path list the *client* parses to
212
+ * refetch): this one is an integer TTL the *wrapper* reads, so the two channels never alias.
213
+ */
214
+ export const ISR_REVALIDATE_HEADER = "x-nifra-isr-revalidate"
215
+
216
+ export interface ISROptions {
217
+ readonly store: CacheStore
218
+ /** Default freshness window (**seconds**) for a cached page; older ⇒ stale (served, regenerated
219
+ * behind). A route overrides it per-page via `export const revalidate` (surfaced as the
220
+ * `x-nifra-isr-revalidate` response header). */
221
+ readonly revalidate: number
222
+ /** Monotonic clock (ms) — injected for testability; production passes `() => Date.now()`. */
223
+ readonly now: () => number
224
+ /** Cache key for a request. Default: `origin + pathname + search` so host-routed apps do not share
225
+ * entries across tenants. Return `null` to bypass the cache for this request (it goes straight to the
226
+ * app, uncached). */
227
+ readonly key?: (req: Request) => string | null
228
+ /** Draft/preview secret (the same one given to `createWebApp({ draftSecret })` + `enableDraft`). When
229
+ * set, a request carrying a valid signed draft cookie **bypasses the cache** — editors always render
230
+ * fresh, and a draft render is never written to the store (it can't poison the public cache). */
231
+ readonly draftSecret?: string
232
+ }
233
+
234
+ const defaultKey = (req: Request): string => {
235
+ const url = new URL(req.url)
236
+ return url.origin + url.pathname + url.search
237
+ }
238
+
239
+ /** ISR caches a full-document SSR response: a `GET` (not a data-mode soft-nav fetch), `200`, `text/html`.
240
+ * Assets, data-mode GETs, redirects, and errors pass through uncached. */
241
+ const cacheControlHas = (headers: Headers, names: readonly string[]): boolean => {
242
+ const value = headers.get("cache-control")
243
+ if (value === null) return false
244
+ const directives = value
245
+ .toLowerCase()
246
+ .split(",")
247
+ .map((part) => part.trim().split("=", 1)[0])
248
+ return names.some((name) => directives.includes(name))
249
+ }
250
+
251
+ const hasSetCookie = (headers: Headers): boolean =>
252
+ headers.has("set-cookie") || (headers.getSetCookie?.().length ?? 0) > 0
253
+
254
+ const requestCarriesPrivateState = (req: Request): boolean =>
255
+ req.headers.has("authorization") || req.headers.has("cookie")
256
+
257
+ const responseIsExplicitlyPublic = (res: Response): boolean =>
258
+ cacheControlHas(res.headers, ["public"])
259
+
260
+ const isCacheablePage = (req: Request, res: Response): boolean => {
261
+ if (req.method !== "GET") return false
262
+ if (req.headers.get("x-nifra-data") !== null) return false
263
+ if (res.status !== 200) return false
264
+ if (!(res.headers.get("content-type") ?? "").includes("text/html")) return false
265
+ if (hasSetCookie(res.headers)) return false
266
+ if (cacheControlHas(res.headers, ["private", "no-store"])) return false
267
+ // Cookie/Authorization requests often personalize HTML without setting a new cookie. Cache them only
268
+ // when the route explicitly declares the response public.
269
+ if (requestCarriesPrivateState(req) && !responseIsExplicitlyPublic(res)) return false
270
+ return true
271
+ }
272
+
273
+ const CACHEABLE_RESPONSE_HEADERS = new Set([
274
+ "cache-control",
275
+ "content-language",
276
+ "content-type",
277
+ "etag",
278
+ "last-modified",
279
+ "link",
280
+ ])
281
+
282
+ const headersOf = (res: Response): Record<string, string> => {
283
+ const out: Record<string, string> = {}
284
+ res.headers.forEach((value, key) => {
285
+ if (CACHEABLE_RESPONSE_HEADERS.has(key)) out[key] = value
286
+ })
287
+ return out
288
+ }
289
+
290
+ const responseFrom = (entry: CachedResponse, status: "hit" | "stale"): Response =>
291
+ new Response(entry.body, {
292
+ status: entry.status,
293
+ headers: { ...sanitizeCachedHeaders(entry.headers), [ISR_STATUS_HEADER]: status },
294
+ })
295
+
296
+ function sanitizeCachedHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
297
+ const out: Record<string, string> = {}
298
+ for (const [key, value] of Object.entries(headers)) {
299
+ const normalized = key.toLowerCase()
300
+ if (CACHEABLE_RESPONSE_HEADERS.has(normalized)) out[normalized] = value
301
+ }
302
+ return out
303
+ }
304
+
305
+ /**
306
+ * Wrap a nifra app with **Incremental Static Regeneration**: a cacheable page is served from
307
+ * {@link CacheStore} when fresh, served **stale while a fresh copy regenerates in the background**
308
+ * (`platform.waitUntil` on edge), or rendered + stored on a miss. Framework-agnostic (it caches the
309
+ * rendered bytes). Returns a `fetch(req, platform?)` handler — hand it to `Bun.serve`/the Workers
310
+ * `export default`, etc. Regeneration is single-flight per key (no stampede on a hot stale page).
311
+ *
312
+ * Each route's freshness comes from the `revalidate` header the app sets (per-route
313
+ * `export const revalidate`), falling back to `options.revalidate`. Only full-document `text/html`
314
+ * GET 200s are cached; everything else (assets, data-mode GETs, redirects, errors) passes through.
315
+ */
316
+ export function withISR(
317
+ app: ISRApp,
318
+ options: ISROptions,
319
+ ): (req: Request, platform?: ISRPlatform) => Promise<Response> {
320
+ const { store, now } = options
321
+ const keyOf = options.key ?? defaultKey
322
+ const draftSecret = options.draftSecret
323
+ const regenerating = new Set<string>()
324
+
325
+ // Per-page TTL (ms): the app's `x-nifra-isr-revalidate` header (seconds) if present, else the default.
326
+ const ttlMs = (res: Response): number => {
327
+ const header = res.headers.get(ISR_REVALIDATE_HEADER)
328
+ const seconds = header === null ? options.revalidate : Number(header)
329
+ return (Number.isFinite(seconds) ? seconds : options.revalidate) * 1000
330
+ }
331
+
332
+ const render = async (
333
+ req: Request,
334
+ platform: ISRPlatform | undefined,
335
+ key: string,
336
+ ): Promise<Response> => {
337
+ const res = await app.fetch(req, platform)
338
+ if (!isCacheablePage(req, res)) return res
339
+ const body = await res.text()
340
+ const entry: CachedResponse = {
341
+ body,
342
+ status: res.status,
343
+ headers: headersOf(res),
344
+ storedAt: now(),
345
+ revalidate: ttlMs(res),
346
+ }
347
+ await store.set(key, entry)
348
+ return new Response(body, {
349
+ status: res.status,
350
+ headers: { ...entry.headers, [ISR_STATUS_HEADER]: "miss" },
351
+ })
352
+ }
353
+
354
+ // Background regeneration (single-flight per key) — a failed regen keeps the stale entry; the next
355
+ // stale hit retries. Never throws (it runs detached / under waitUntil).
356
+ const regenerate = async (
357
+ req: Request,
358
+ platform: ISRPlatform | undefined,
359
+ key: string,
360
+ ): Promise<void> => {
361
+ if (regenerating.has(key)) return
362
+ regenerating.add(key)
363
+ try {
364
+ const res = await app.fetch(req, platform)
365
+ if (isCacheablePage(req, res)) {
366
+ await store.set(key, {
367
+ body: await res.text(),
368
+ status: res.status,
369
+ headers: headersOf(res),
370
+ storedAt: now(),
371
+ revalidate: ttlMs(res),
372
+ })
373
+ } else {
374
+ await res.body?.cancel()
375
+ }
376
+ } catch {
377
+ // keep the stale entry; a later request retries the regeneration
378
+ } finally {
379
+ regenerating.delete(key)
380
+ }
381
+ }
382
+
383
+ return async (req, platform) => {
384
+ const key = req.method === "GET" ? keyOf(req) : null
385
+ if (key === null) return app.fetch(req, platform)
386
+
387
+ // Draft/preview: an editor (valid signed cookie) always renders fresh and is never cached, so
388
+ // unpublished content can't leak into the public cache (and the editor isn't served a stale page).
389
+ if (draftSecret !== undefined && (await isDraftEnabled(req, draftSecret))) {
390
+ return app.fetch(req, platform)
391
+ }
392
+
393
+ const hit = await store.get(key)
394
+ if (hit !== undefined) {
395
+ if (now() - hit.storedAt < hit.revalidate) return responseFrom(hit, "hit")
396
+ // Stale: serve it now, regenerate behind it (waitUntil keeps the edge worker alive for the regen).
397
+ const task = regenerate(req, platform, key)
398
+ if (typeof platform?.waitUntil === "function") platform.waitUntil(task)
399
+ else void task.catch(() => {})
400
+ return responseFrom(hit, "stale")
401
+ }
402
+ return render(req, platform, key)
403
+ }
404
+ }
405
+
406
+ const jsonError = (status: number, error: string): Response =>
407
+ Response.json({ ok: false, error }, { status })
408
+
409
+ /** Constant-time string equality (XOR-accumulate, no early exit). A length mismatch returns `false`
410
+ * up front — the length isn't the secret, and different-length buffers can't be compared in constant
411
+ * time anyway. Used to check the on-demand revalidation token without leaking it via timing. */
412
+ function timingSafeEqual(a: string, b: string): boolean {
413
+ const enc = new TextEncoder()
414
+ const ba = enc.encode(a)
415
+ const bb = enc.encode(b)
416
+ if (ba.length !== bb.length) return false
417
+ let diff = 0
418
+ for (let i = 0; i < ba.length; i++) diff |= (ba[i] as number) ^ (bb[i] as number)
419
+ return diff === 0
420
+ }
421
+
422
+ export interface RevalidateEndpointOptions {
423
+ readonly store: CacheStore
424
+ /** Shared secret; the request's token must match it (constant-time). */
425
+ readonly secret: string
426
+ /** Header carrying the secret. Default `x-nifra-revalidate-token`. */
427
+ readonly tokenHeader?: string
428
+ /** Map a to-purge path → its cache key — MUST match the `withISR` `key` fn. The default uses the
429
+ * revalidation request's origin plus the purged path, matching `withISR`'s default host-aware key. */
430
+ readonly key?: (path: string, req: Request) => string
431
+ }
432
+
433
+ /**
434
+ * An **on-demand revalidation** (purge) endpoint — a `fetch` handler that drops a path's cached entry
435
+ * so the next request re-renders. `POST` with the secret in the token header and the path as `?path=`
436
+ * or a JSON `{ "path": "/blog/x" }` body. The token is checked in **constant time** (wrong/missing →
437
+ * `401`); a missing/relative path → `400`; non-POST → `405`. Mount it on a nifra route, e.g.
438
+ * `app.post("/__nifra/revalidate", (c) => handler(c.req))`.
439
+ */
440
+ export function revalidateEndpoint(
441
+ options: RevalidateEndpointOptions,
442
+ ): (req: Request) => Promise<Response> {
443
+ const tokenHeader = options.tokenHeader ?? "x-nifra-revalidate-token"
444
+ const keyOf = options.key ?? ((path: string, req: Request) => new URL(req.url).origin + path)
445
+ return async (req) => {
446
+ if (req.method !== "POST") return jsonError(405, "method_not_allowed")
447
+ if (!timingSafeEqual(req.headers.get(tokenHeader) ?? "", options.secret)) {
448
+ return jsonError(401, "unauthorized")
449
+ }
450
+ let path = new URL(req.url).searchParams.get("path")
451
+ if (path === null) {
452
+ const body: unknown = await req.json().catch(() => null)
453
+ const candidate =
454
+ typeof body === "object" && body !== null ? (body as { path?: unknown }).path : undefined
455
+ path = typeof candidate === "string" ? candidate : null
456
+ }
457
+ if (path === null || !path.startsWith("/")) return jsonError(400, "invalid_path")
458
+ await options.store.delete(keyOf(path, req))
459
+ return Response.json({ revalidated: path })
460
+ }
461
+ }