@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/css-modules.d.ts +34 -0
- package/dist/build.d.ts +110 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +238 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +204 -0
- package/dist/client.js.map +1 -0
- package/dist/deferred.d.ts +90 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +310 -0
- package/dist/deferred.js.map +1 -0
- package/dist/dev-error.d.ts +17 -0
- package/dist/dev-error.d.ts.map +1 -0
- package/dist/dev-error.js +59 -0
- package/dist/dev-error.js.map +1 -0
- package/dist/dev.d.ts +25 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +83 -0
- package/dist/dev.js.map +1 -0
- package/dist/draft.d.ts +58 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +55 -0
- package/dist/draft.js.map +1 -0
- package/dist/fonts-google.d.ts +109 -0
- package/dist/fonts-google.d.ts.map +1 -0
- package/dist/fonts-google.js +291 -0
- package/dist/fonts-google.js.map +1 -0
- package/dist/fonts.d.ts +67 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +103 -0
- package/dist/fonts.js.map +1 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +24 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +784 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +37 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +107 -0
- package/dist/islands.js.map +1 -0
- package/dist/isr.d.ts +154 -0
- package/dist/isr.d.ts.map +1 -0
- package/dist/isr.js +331 -0
- package/dist/isr.js.map +1 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +213 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugins/css-modules.d.ts +40 -0
- package/dist/plugins/css-modules.d.ts.map +1 -0
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -0
- package/dist/plugins/kit.d.ts +44 -0
- package/dist/plugins/kit.d.ts.map +1 -0
- package/dist/plugins/kit.js +105 -0
- package/dist/plugins/kit.js.map +1 -0
- package/dist/plugins/scss.d.ts +27 -0
- package/dist/plugins/scss.d.ts.map +1 -0
- package/dist/plugins/scss.js +78 -0
- package/dist/plugins/scss.js.map +1 -0
- package/dist/prerender.d.ts +77 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +139 -0
- package/dist/prerender.js.map +1 -0
- package/dist/query.d.ts +62 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +184 -0
- package/dist/query.js.map +1 -0
- package/dist/router.d.ts +169 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +531 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +49 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +144 -0
- package/dist/vite.js.map +1 -0
- package/package.json +127 -0
- package/src/build.ts +375 -0
- package/src/client.ts +218 -0
- package/src/deferred.ts +339 -0
- package/src/dev-error.ts +62 -0
- package/src/dev.ts +108 -0
- package/src/draft.ts +82 -0
- package/src/fonts-google.ts +397 -0
- package/src/fonts.ts +147 -0
- package/src/fs.ts +26 -0
- package/src/index.ts +1202 -0
- package/src/islands.ts +121 -0
- package/src/isr.ts +461 -0
- package/src/manifest.ts +366 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +123 -0
- package/src/plugins/scss.ts +108 -0
- package/src/prerender.ts +209 -0
- package/src/query.ts +250 -0
- package/src/router.ts +676 -0
- 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
|
+
}
|