@pyreon/zero 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -57
- package/lib/_chunks/app-BbPT0Y5M.js +36 -0
- package/lib/{fs-router-Bacdhsq-.js → _chunks/fs-router-DvBlRzmP.js} +21 -5
- package/lib/_chunks/use-intersection-observer-C6opeplh.js +29 -0
- package/lib/actions.js +24 -3
- package/lib/ai.js +1 -102
- package/lib/client.js +3 -33
- package/lib/csp.js +12 -9
- package/lib/favicon.js +1 -1
- package/lib/font.js +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image.js +3 -27
- package/lib/index.js +8 -1085
- package/lib/link.js +3 -27
- package/lib/meta.js +1 -25
- package/lib/script.js +2 -26
- package/lib/seo.js +4 -4
- package/lib/server.js +275 -2129
- package/lib/testing.js +1 -69
- package/lib/theme.js +52 -22
- package/lib/types/config.d.ts +115 -0
- package/lib/types/csp.d.ts +9 -1
- package/lib/types/index.d.ts +120 -1
- package/lib/types/server.d.ts +192 -17
- package/lib/types/theme.d.ts +11 -2
- package/package.json +10 -10
- package/src/actions.ts +43 -5
- package/src/adapters/bun.ts +35 -7
- package/src/adapters/cloudflare.ts +17 -12
- package/src/adapters/netlify.ts +7 -1
- package/src/adapters/node.ts +33 -6
- package/src/adapters/vercel.ts +25 -4
- package/src/csp.ts +10 -7
- package/src/fs-router.ts +2 -1
- package/src/isr.ts +256 -51
- package/src/manifest.ts +23 -10
- package/src/server.ts +2 -1
- package/src/ssg-plugin.ts +27 -7
- package/src/theme.tsx +94 -38
- package/src/types.ts +76 -0
- package/lib/api-routes-CMsLztoj.js +0 -148
- package/lib/fs-router-3xzp-4Wj.js +0 -32
- package/lib/rolldown-runtime-CjeV3_4I.js +0 -18
package/src/fs-router.ts
CHANGED
|
@@ -1107,7 +1107,8 @@ export function generateRouteModuleFromRoutes(
|
|
|
1107
1107
|
// so `_hmrId` is dead metadata once built.
|
|
1108
1108
|
opts.push(`hmrId: ${JSON.stringify(fullPath)}`)
|
|
1109
1109
|
const optsStr = `, { ${opts.join(', ')} }`
|
|
1110
|
-
|
|
1110
|
+
// JSON.stringify for safe-embed — matches the `hmrId` line above.
|
|
1111
|
+
imports.push(`const ${name} = lazy(() => import(${JSON.stringify(fullPath)})${optsStr})`)
|
|
1111
1112
|
return name
|
|
1112
1113
|
}
|
|
1113
1114
|
|
package/src/isr.ts
CHANGED
|
@@ -1,31 +1,176 @@
|
|
|
1
1
|
import type { ISRConfig } from './types'
|
|
2
2
|
|
|
3
|
+
// Dev-mode counter sink — see packages/internals/perf-harness for contract.
|
|
4
|
+
// Zero cost in prod (gate folds to `false`, optional-chain short-circuits).
|
|
5
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
6
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
7
|
+
|
|
3
8
|
// ─── ISR Cache ───────────────────────────────────────────────────────────────
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
/** Serialized SSR response cached by the ISR layer (one per cache key). */
|
|
11
|
+
export interface ISRCacheEntry {
|
|
6
12
|
html: string
|
|
7
13
|
headers: Record<string, string>
|
|
8
14
|
timestamp: number
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
12
|
-
*
|
|
18
|
+
* Pluggable backing store for the ISR cache. The default in-memory
|
|
19
|
+
* implementation (`createMemoryStore`) is per-process — fine for
|
|
20
|
+
* single-instance deploys, but multi-instance / horizontally-scaled
|
|
21
|
+
* apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
|
|
22
|
+
* etc.) so revalidation in one instance is visible to all instances.
|
|
23
|
+
*
|
|
24
|
+
* The interface accepts BOTH sync and async returns: the in-memory
|
|
25
|
+
* default stays cheap (sync `Map` ops, no `Promise` allocation per
|
|
26
|
+
* request), while external stores return promises naturally. The
|
|
27
|
+
* handler awaits the result either way (`await` on a non-promise just
|
|
28
|
+
* returns the value).
|
|
29
|
+
*
|
|
30
|
+
* `get` is responsible for any LRU bookkeeping — external stores
|
|
31
|
+
* typically rely on their own TTL/eviction policy and can `return
|
|
32
|
+
* this.map.get(key)` directly; the in-memory store does a
|
|
33
|
+
* delete + re-insert to keep the Map's insertion-order LRU correct.
|
|
34
|
+
*
|
|
35
|
+
* `delete` is optional. It's only used when a future on-demand
|
|
36
|
+
* revalidation pathway needs to evict a specific key (the current
|
|
37
|
+
* stale-while-revalidate flow does not call it). External stores that
|
|
38
|
+
* don't support per-key invalidation can omit it.
|
|
39
|
+
*/
|
|
40
|
+
export interface ISRStore<E = ISRCacheEntry> {
|
|
41
|
+
get(key: string): Promise<E | undefined> | E | undefined
|
|
42
|
+
set(key: string, entry: E): Promise<void> | void
|
|
43
|
+
delete?(key: string): Promise<void> | void
|
|
44
|
+
/**
|
|
45
|
+
* Drop EVERY entry. Optional — external stores (Redis with TTL-only,
|
|
46
|
+
* Vercel KV with per-key invalidation, etc.) may not support a
|
|
47
|
+
* blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
|
|
48
|
+
* clear error pointing at the missing method. The default
|
|
49
|
+
* `createMemoryStore` implements it.
|
|
50
|
+
*/
|
|
51
|
+
clear?(): Promise<void> | void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The default in-memory ISR store: a `Map` with insertion-order LRU
|
|
56
|
+
* eviction, capped at `maxEntries` (default `1000`). Drop in as
|
|
57
|
+
* `config.store` if you want to tweak the cap or wrap the store with
|
|
58
|
+
* instrumentation; pass a different `ISRStore` impl for Redis / KV /
|
|
59
|
+
* etc. backings.
|
|
60
|
+
*
|
|
61
|
+
* `get` does the LRU bump (re-inserts the touched entry at the
|
|
62
|
+
* newest position) so hot paths survive eviction even when the cap is
|
|
63
|
+
* small. Without that, `Map.get(...)` wouldn't update ordering and
|
|
64
|
+
* frequently-read entries could be evicted by occasional writes.
|
|
65
|
+
*/
|
|
66
|
+
export function createMemoryStore<E = ISRCacheEntry>(opts: {
|
|
67
|
+
maxEntries?: number
|
|
68
|
+
} = {}): ISRStore<E> {
|
|
69
|
+
const cache = new Map<string, E>()
|
|
70
|
+
const maxEntries = Math.max(1, opts.maxEntries ?? 1000)
|
|
71
|
+
return {
|
|
72
|
+
get(key) {
|
|
73
|
+
const entry = cache.get(key)
|
|
74
|
+
if (entry !== undefined) {
|
|
75
|
+
cache.delete(key)
|
|
76
|
+
cache.set(key, entry)
|
|
77
|
+
}
|
|
78
|
+
return entry
|
|
79
|
+
},
|
|
80
|
+
set(key, entry) {
|
|
81
|
+
if (cache.has(key)) cache.delete(key)
|
|
82
|
+
cache.set(key, entry)
|
|
83
|
+
while (cache.size > maxEntries) {
|
|
84
|
+
const oldest = cache.keys().next().value
|
|
85
|
+
if (oldest === undefined) break
|
|
86
|
+
cache.delete(oldest)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
delete(key) {
|
|
90
|
+
cache.delete(key)
|
|
91
|
+
},
|
|
92
|
+
clear() {
|
|
93
|
+
cache.clear()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Internal alias for the default-shape entry — keeps function bodies tidy
|
|
99
|
+
// while staying source-compatible with the public `ISRCacheEntry` export.
|
|
100
|
+
type CacheEntry = ISRCacheEntry
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ISR handler with stale-while-revalidate semantics.
|
|
104
|
+
*
|
|
105
|
+
* Wraps an SSR handler and caches responses per URL path. Serves stale
|
|
106
|
+
* content immediately while revalidating in the background. The cache
|
|
107
|
+
* backing is **pluggable** via `config.store` (default:
|
|
108
|
+
* `createMemoryStore({ maxEntries: 1000 })`) — pass a custom `ISRStore`
|
|
109
|
+
* implementation backed by Redis / Vercel KV / Cloudflare KV / Upstash
|
|
110
|
+
* etc. to share state across horizontally-scaled instances. Without an
|
|
111
|
+
* external store, each instance has its own per-process cache (which
|
|
112
|
+
* is fine for single-instance deploys; behaviour-identical to the
|
|
113
|
+
* pre-pluggable-store implementation).
|
|
13
114
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
115
|
+
* Default in-memory store: bounded by `config.maxEntries` (default
|
|
116
|
+
* `1000`) with insertion-order LRU eviction. Without the cap, unbounded
|
|
117
|
+
* URL spaces like `/user/:id` would grow cache memory without limit
|
|
118
|
+
* over the server's lifetime — a real leak in long-running deployments.
|
|
119
|
+
* `config.maxEntries` is ignored when a custom `config.store` is supplied
|
|
120
|
+
* (the custom store owns its own eviction policy).
|
|
121
|
+
*/
|
|
122
|
+
/**
|
|
123
|
+
* The fetch handler `createISRHandler` returns is also a callable
|
|
124
|
+
* carrying imperative invalidation methods. Webhooks, CMS notifications,
|
|
125
|
+
* admin endpoints etc. call these methods to drop one or all cached
|
|
126
|
+
* entries on demand — strictly more responsive than waiting for the
|
|
127
|
+
* TTL-based stale-while-revalidate cycle.
|
|
16
128
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* unbounded URL spaces like `/user/:id` would grow cache memory without
|
|
21
|
-
* limit over the server's lifetime — a real leak in long-running
|
|
22
|
-
* deployments.
|
|
129
|
+
* The shape is `(req) => Promise<Response>` PLUS the methods, so
|
|
130
|
+
* existing consumers (`Bun.serve({ fetch: handler })`) keep working
|
|
131
|
+
* byte-identically.
|
|
23
132
|
*/
|
|
133
|
+
export interface ISRHandler {
|
|
134
|
+
(req: Request): Promise<Response>
|
|
135
|
+
/**
|
|
136
|
+
* Drop the cache entry for a single path (or `cacheKey`-derived key).
|
|
137
|
+
* The next request for that key will MISS and re-render fresh. Returns
|
|
138
|
+
* `{ dropped: true }` if an entry was found and deleted, `{ dropped: false }`
|
|
139
|
+
* if no entry existed.
|
|
140
|
+
*
|
|
141
|
+
* Useful for webhook-driven invalidation: a CMS notifies that a post
|
|
142
|
+
* was updated, the webhook handler calls `isrHandler.revalidateNow('/posts/123')`,
|
|
143
|
+
* and the very next visitor gets the fresh content — no stale window.
|
|
144
|
+
*
|
|
145
|
+
* Idempotent. Safe to call against keys that don't exist (returns
|
|
146
|
+
* `dropped: false` cleanly).
|
|
147
|
+
*/
|
|
148
|
+
revalidateNow(key: string): Promise<{ dropped: boolean }>
|
|
149
|
+
/**
|
|
150
|
+
* Drop ALL cached entries. Useful for "purge cache" admin actions or
|
|
151
|
+
* deploy-completion hooks that want a clean slate.
|
|
152
|
+
*
|
|
153
|
+
* The default in-memory store supports this via repeated `delete`
|
|
154
|
+
* calls under the hood; custom stores that omit `delete` (Redis with
|
|
155
|
+
* TTL-only, etc.) throw a clear error pointing at the missing method
|
|
156
|
+
* so the caller knows their store implementation can't honor the call.
|
|
157
|
+
*/
|
|
158
|
+
revalidateAll(): Promise<void>
|
|
159
|
+
}
|
|
160
|
+
|
|
24
161
|
export function createISRHandler(
|
|
25
162
|
handler: (req: Request) => Promise<Response>,
|
|
26
163
|
config: ISRConfig,
|
|
27
|
-
):
|
|
28
|
-
|
|
164
|
+
): ISRHandler {
|
|
165
|
+
// Pluggable backing store. Default keeps the prior in-memory LRU
|
|
166
|
+
// behaviour byte-identical (same `maxEntries` default, same Map +
|
|
167
|
+
// delete+re-insert LRU bump) so existing callers see no behavioural
|
|
168
|
+
// change.
|
|
169
|
+
const store: ISRStore<CacheEntry>
|
|
170
|
+
= config.store
|
|
171
|
+
?? createMemoryStore<CacheEntry>(
|
|
172
|
+
config.maxEntries !== undefined ? { maxEntries: config.maxEntries } : {},
|
|
173
|
+
)
|
|
29
174
|
const revalidating = new Set<string>()
|
|
30
175
|
const revalidateMs = config.revalidate * 1000
|
|
31
176
|
// Bounded background-revalidation timeout. Without it, a handler that
|
|
@@ -47,7 +192,6 @@ export function createISRHandler(
|
|
|
47
192
|
function isCacheable(res: Response): boolean {
|
|
48
193
|
return res.status >= 200 && res.status < 300 && !res.headers.has('set-cookie')
|
|
49
194
|
}
|
|
50
|
-
const maxEntries = Math.max(1, config.maxEntries ?? 1000)
|
|
51
195
|
// M1.1 — cache-key derivation. Default keys by pathname only (the
|
|
52
196
|
// pre-M1 behaviour). User-supplied `cacheKey` opts in to varying
|
|
53
197
|
// by cookies / query / headers — required for auth-gated pages.
|
|
@@ -57,27 +201,6 @@ export function createISRHandler(
|
|
|
57
201
|
? (req, _url) => (config.cacheKey as (r: Request) => string)(req)
|
|
58
202
|
: (_req, url) => url.pathname
|
|
59
203
|
|
|
60
|
-
function set(key: string, entry: CacheEntry): void {
|
|
61
|
-
// LRU: re-inserting moves the key to the newest position. Then if we're
|
|
62
|
-
// over the cap, drop the oldest (first in iteration order).
|
|
63
|
-
if (cache.has(key)) cache.delete(key)
|
|
64
|
-
cache.set(key, entry)
|
|
65
|
-
while (cache.size > maxEntries) {
|
|
66
|
-
const oldest = cache.keys().next().value
|
|
67
|
-
if (oldest === undefined) break
|
|
68
|
-
cache.delete(oldest)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function touch(key: string): CacheEntry | undefined {
|
|
73
|
-
const entry = cache.get(key)
|
|
74
|
-
if (entry !== undefined) {
|
|
75
|
-
cache.delete(key)
|
|
76
|
-
cache.set(key, entry)
|
|
77
|
-
}
|
|
78
|
-
return entry
|
|
79
|
-
}
|
|
80
|
-
|
|
81
204
|
async function revalidate(url: URL, originalReq: Request) {
|
|
82
205
|
// Re-derive key from the ORIGINAL request so cookies / headers /
|
|
83
206
|
// query that varied the cache entry are preserved across revalidation.
|
|
@@ -88,23 +211,46 @@ export function createISRHandler(
|
|
|
88
211
|
if (revalidating.has(key)) return
|
|
89
212
|
revalidating.add(key)
|
|
90
213
|
|
|
214
|
+
// Hold the timer id outside try/Promise.race so the finally block
|
|
215
|
+
// can `clearTimeout` it on the SUCCESS path. Pre-fix the rejection
|
|
216
|
+
// setTimeout was left pending until REVALIDATE_TIMEOUT_MS (default
|
|
217
|
+
// 30s) every time `handler(req)` won the race — i.e. every
|
|
218
|
+
// successful revalidation. Under sustained traffic this accumulates
|
|
219
|
+
// hundreds of pending timer closures + rejection callbacks before
|
|
220
|
+
// they self-clear.
|
|
221
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
91
222
|
try {
|
|
92
|
-
//
|
|
93
|
-
// re-render sees the same auth context as the user's read.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
223
|
+
// Default: forward the original request shape (headers + method)
|
|
224
|
+
// so the re-render sees the same auth context as the user's read.
|
|
225
|
+
// Opt-in `revalidateRequest` hook lets auth-gated callers scope
|
|
226
|
+
// the revalidation explicitly — e.g. return `null` to skip
|
|
227
|
+
// revalidation for authenticated entries (stale stays stale until
|
|
228
|
+
// the next live request), or return an anonymous Request for
|
|
229
|
+
// non-personalized entries.
|
|
230
|
+
let req: Request
|
|
231
|
+
if (typeof config.revalidateRequest === 'function') {
|
|
232
|
+
const custom = config.revalidateRequest(originalReq)
|
|
233
|
+
if (custom === null) {
|
|
234
|
+
revalidating.delete(key)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
req = custom
|
|
238
|
+
} else {
|
|
239
|
+
req = new Request(url.href, {
|
|
240
|
+
method: 'GET',
|
|
241
|
+
headers: originalReq.headers,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
98
244
|
// Bound the revalidation so a hung handler can't pin `key` in
|
|
99
245
|
// `revalidating` forever (which would freeze the entry stale).
|
|
100
246
|
const res = await Promise.race([
|
|
101
247
|
handler(req),
|
|
102
|
-
new Promise<never>((_, reject) =>
|
|
103
|
-
setTimeout(
|
|
248
|
+
new Promise<never>((_, reject) => {
|
|
249
|
+
timeoutId = setTimeout(
|
|
104
250
|
() => reject(new Error('[Pyreon ISR] revalidation timeout')),
|
|
105
251
|
REVALIDATE_TIMEOUT_MS,
|
|
106
|
-
)
|
|
107
|
-
),
|
|
252
|
+
)
|
|
253
|
+
}),
|
|
108
254
|
])
|
|
109
255
|
// Never overwrite a good stale entry with a bad re-render
|
|
110
256
|
// (5xx/3xx) or poison it with a Set-Cookie response.
|
|
@@ -114,16 +260,24 @@ export function createISRHandler(
|
|
|
114
260
|
res.headers.forEach((v, k) => {
|
|
115
261
|
headers[k] = v
|
|
116
262
|
})
|
|
117
|
-
set(key, { html, headers, timestamp: Date.now() })
|
|
263
|
+
await store.set(key, { html, headers, timestamp: Date.now() })
|
|
118
264
|
}
|
|
119
265
|
} catch {
|
|
120
266
|
// Revalidation failed / timed out — stale cache entry remains valid
|
|
121
267
|
} finally {
|
|
268
|
+
if (timeoutId !== undefined) {
|
|
269
|
+
clearTimeout(timeoutId)
|
|
270
|
+
// Leak-class I diagnostic — emit one per cleared timer. Healthy
|
|
271
|
+
// count = revalidation-attempts (every attempt should clear its
|
|
272
|
+
// timer in finally). A LOWER count than revalidations means
|
|
273
|
+
// clearTimeout failed to fire — the orphan-timer leak is back.
|
|
274
|
+
if (__DEV__) _countSink.__pyreon_count__?.('isr.revalidate.timerClear')
|
|
275
|
+
}
|
|
122
276
|
revalidating.delete(key)
|
|
123
277
|
}
|
|
124
278
|
}
|
|
125
279
|
|
|
126
|
-
|
|
280
|
+
const fetch = async (req: Request): Promise<Response> => {
|
|
127
281
|
// Only cache GET requests
|
|
128
282
|
if (req.method !== 'GET') {
|
|
129
283
|
return handler(req)
|
|
@@ -131,10 +285,11 @@ export function createISRHandler(
|
|
|
131
285
|
|
|
132
286
|
const url = new URL(req.url)
|
|
133
287
|
const key = deriveKey(req, url)
|
|
134
|
-
// `
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
288
|
+
// The store's `get` is responsible for any LRU bookkeeping — the
|
|
289
|
+
// default in-memory store does delete + re-insert so hot paths
|
|
290
|
+
// survive eviction. External stores (Redis/KV) rely on their own
|
|
291
|
+
// TTL/eviction policy and typically just read directly.
|
|
292
|
+
const entry = await store.get(key)
|
|
138
293
|
|
|
139
294
|
if (entry) {
|
|
140
295
|
const age = Date.now() - entry.timestamp
|
|
@@ -175,7 +330,7 @@ export function createISRHandler(
|
|
|
175
330
|
})
|
|
176
331
|
}
|
|
177
332
|
|
|
178
|
-
set(key, { html, headers, timestamp: Date.now() })
|
|
333
|
+
await store.set(key, { html, headers, timestamp: Date.now() })
|
|
179
334
|
|
|
180
335
|
return new Response(html, {
|
|
181
336
|
status: 200,
|
|
@@ -186,4 +341,54 @@ export function createISRHandler(
|
|
|
186
341
|
},
|
|
187
342
|
})
|
|
188
343
|
}
|
|
344
|
+
|
|
345
|
+
// Attach the imperative invalidation methods. The result is a
|
|
346
|
+
// callable (still works with `Bun.serve({ fetch: handler })`) plus
|
|
347
|
+
// `.revalidateNow(key)` and `.revalidateAll()` for webhook-driven
|
|
348
|
+
// cache busting.
|
|
349
|
+
const isrHandler = fetch as ISRHandler
|
|
350
|
+
|
|
351
|
+
isrHandler.revalidateNow = async (key: string) => {
|
|
352
|
+
// Get-then-delete so we can return an accurate `dropped` flag. The
|
|
353
|
+
// store's `delete` doesn't return whether anything existed, so the
|
|
354
|
+
// precheck is the only way to distinguish "actually dropped" from
|
|
355
|
+
// "no-op against missing key". For the default in-memory store the
|
|
356
|
+
// get is O(1); external stores pay one extra round-trip (acceptable
|
|
357
|
+
// for an invalidation API that fires on CMS webhooks, not on the
|
|
358
|
+
// hot path).
|
|
359
|
+
//
|
|
360
|
+
// `dropped: true` is only returned when (a) the entry existed AND
|
|
361
|
+
// (b) the store actually supported delete. A store without
|
|
362
|
+
// `delete?` returns `dropped: false` even if the entry existed —
|
|
363
|
+
// the caller's intent (make this key MISS next time) wasn't
|
|
364
|
+
// honored, so the honest answer is "no". Such stores rely on TTL
|
|
365
|
+
// for eviction.
|
|
366
|
+
const existed = (await store.get(key)) !== undefined
|
|
367
|
+
let dropped = false
|
|
368
|
+
if (existed && store.delete) {
|
|
369
|
+
await store.delete(key)
|
|
370
|
+
dropped = true
|
|
371
|
+
}
|
|
372
|
+
// Always clear the in-flight revalidation flag so the next request
|
|
373
|
+
// re-renders fresh rather than short-circuiting on the
|
|
374
|
+
// `revalidating.has(key)` guard — regardless of whether we
|
|
375
|
+
// physically dropped the entry.
|
|
376
|
+
revalidating.delete(key)
|
|
377
|
+
return { dropped }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
isrHandler.revalidateAll = async () => {
|
|
381
|
+
if (!store.clear) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
'[Pyreon ISR] revalidateAll() called against a store that does not implement `clear()`. The default in-memory store supports this; external stores (Redis/KV/etc.) must opt in by implementing `ISRStore.clear()`.',
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
await store.clear()
|
|
387
|
+
// Drop every in-flight revalidation flag — fresh requests should
|
|
388
|
+
// re-render rather than waiting on stale resolve callbacks pointing
|
|
389
|
+
// at entries we just purged.
|
|
390
|
+
revalidating.clear()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return isrHandler
|
|
189
394
|
}
|
package/src/manifest.ts
CHANGED
|
@@ -221,24 +221,37 @@ await vercelAdapter().revalidate?.('/posts/123')
|
|
|
221
221
|
name: 'createISRHandler',
|
|
222
222
|
kind: 'function',
|
|
223
223
|
signature:
|
|
224
|
-
'function createISRHandler(
|
|
224
|
+
'function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): ISRHandler',
|
|
225
225
|
summary:
|
|
226
|
-
"Runtime ISR — on-demand SSR caching with
|
|
226
|
+
"Runtime ISR — on-demand SSR caching with stale-while-revalidate. Wraps an SSR handler so pages are rendered on the FIRST request, cached per-URL (or per-`cacheKey`-derived key), and served stale until expiry while a background revalidate fires. The returned `ISRHandler` is still a callable `(req) => Promise<Response>` for `Bun.serve({ fetch: ... })`, but ALSO exposes imperative invalidation: `.revalidateNow(key)` drops one entry (returns `{ dropped: boolean }`), `.revalidateAll()` drops everything (when the store implements `clear()`). Pair with webhooks for CMS-driven cache busting — no stale window between content update and propagation. Distinct from build-time ISR (per-route `revalidate` export + `Adapter.revalidate`): runtime ISR caches at request time; build-time ISR triggers platform rebuilds. They can coexist: a `mode: 'isr'` app with per-route `revalidate` exports gets BOTH.",
|
|
227
227
|
example: `import { createISRHandler, createServer } from '@pyreon/zero/server'
|
|
228
228
|
|
|
229
|
-
// Wrap createServer's handler with ISR cache
|
|
230
229
|
const ssrHandler = createServer({ routes })
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
const isr = createISRHandler(ssrHandler, { revalidate: 60 })
|
|
231
|
+
|
|
232
|
+
// Use as the request handler
|
|
233
|
+
Bun.serve({ fetch: isr })
|
|
234
|
+
|
|
235
|
+
// CMS webhook: drop one entry
|
|
236
|
+
app.post('/api/webhooks/post-updated', async (req) => {
|
|
237
|
+
const { postId } = await req.json()
|
|
238
|
+
const result = await isr.revalidateNow(\`/posts/\${postId}\`)
|
|
239
|
+
return Response.json(result) // { dropped: true | false }
|
|
234
240
|
})
|
|
235
241
|
|
|
236
|
-
|
|
242
|
+
// Admin "purge cache" endpoint
|
|
243
|
+
app.post('/admin/purge', async () => {
|
|
244
|
+
await isr.revalidateAll()
|
|
245
|
+
return new Response('ok')
|
|
246
|
+
})`,
|
|
237
247
|
mistakes: [
|
|
238
|
-
'
|
|
239
|
-
|
|
248
|
+
'Treating the returned handler as a plain function — it ALSO carries `.revalidateNow(key)` and `.revalidateAll()` methods. Webhook-driven invalidation is the canonical way to bust the cache; waiting for the TTL is the fallback',
|
|
249
|
+
'Calling `.revalidateAll()` against a store that does not implement `clear()` — throws a clear error. External stores (Redis with TTL-only) must opt in by implementing the method',
|
|
250
|
+
'Expecting `revalidateNow(key)` against a store without `delete?()` to physically drop the entry — returns `{ dropped: false }` honestly; such stores rely on TTL for eviction',
|
|
251
|
+
'Sharing the ISR handler across server instances without external cache — each server\'s in-memory cache diverges. For multi-instance deploys, swap `config.store` to a shared cache layer (Redis / Vercel KV / Cloudflare KV)',
|
|
252
|
+
'Setting `revalidate: 0` and expecting "never cache" — pass-through is the explicit handler call (no `createISRHandler` wrapper). Use `revalidate: Number.MAX_SAFE_INTEGER` for "cache forever, invalidate only via `revalidateNow`"',
|
|
240
253
|
],
|
|
241
|
-
seeAlso: ['zero', 'Adapter'],
|
|
254
|
+
seeAlso: ['zero', 'Adapter', 'ISRStore', 'createMemoryStore'],
|
|
242
255
|
},
|
|
243
256
|
{
|
|
244
257
|
name: 'vercelAdapter',
|
package/src/server.ts
CHANGED
|
@@ -35,7 +35,8 @@ export {
|
|
|
35
35
|
|
|
36
36
|
// ─── ISR ────────────────────────────────────────────────────────────────────
|
|
37
37
|
|
|
38
|
-
export {
|
|
38
|
+
export type { ISRCacheEntry, ISRStore } from "./isr";
|
|
39
|
+
export { createISRHandler, createMemoryStore } from "./isr";
|
|
39
40
|
|
|
40
41
|
// ─── Vercel revalidate handler (M3.1) ───────────────────────────────────────
|
|
41
42
|
|
package/src/ssg-plugin.ts
CHANGED
|
@@ -1152,12 +1152,23 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1152
1152
|
// `ssg.pathWrite` / `ssg.pathRedirect` / `ssg.pathError` to see
|
|
1153
1153
|
// per-path settle distribution.
|
|
1154
1154
|
if (__DEV__) _countSink.__pyreon_count__?.('ssg.pathRender')
|
|
1155
|
+
// Hold the timer id outside try/Promise.race so the finally
|
|
1156
|
+
// block can `clearTimeout` it on the success path. Pre-fix the
|
|
1157
|
+
// rejection setTimeout was left pending until 30s every time
|
|
1158
|
+
// `renderPath(p)` won the race (i.e. every successful render).
|
|
1159
|
+
// Concurrent worker pool × N paths under default `concurrency: 4`
|
|
1160
|
+
// → up to N pending timer closures across the whole build,
|
|
1161
|
+
// each pinning a rejection callback.
|
|
1162
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
1155
1163
|
try {
|
|
1156
1164
|
const result = await Promise.race([
|
|
1157
1165
|
renderPath(p),
|
|
1158
|
-
new Promise<never>((_, reject) =>
|
|
1159
|
-
|
|
1160
|
-
|
|
1166
|
+
new Promise<never>((_, reject) => {
|
|
1167
|
+
timeoutId = setTimeout(
|
|
1168
|
+
() => reject(new Error(`Prerender timeout for "${p}" (30s)`)),
|
|
1169
|
+
30_000,
|
|
1170
|
+
)
|
|
1171
|
+
}),
|
|
1161
1172
|
])
|
|
1162
1173
|
|
|
1163
1174
|
if (result.kind === 'redirect') {
|
|
@@ -1225,6 +1236,8 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1225
1236
|
errors.push({ path: `${p} (onPathError)`, error: callbackError })
|
|
1226
1237
|
}
|
|
1227
1238
|
}
|
|
1239
|
+
} finally {
|
|
1240
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
|
1228
1241
|
}
|
|
1229
1242
|
}
|
|
1230
1243
|
|
|
@@ -1395,11 +1408,16 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1395
1408
|
: []
|
|
1396
1409
|
|
|
1397
1410
|
for (const locale of localeEntries) {
|
|
1411
|
+
// Hold the timer id outside try/Promise.race so the finally
|
|
1412
|
+
// block can `clearTimeout` it on the success path. Same shape
|
|
1413
|
+
// as the per-path render timeout above — every successful
|
|
1414
|
+
// 404 render leaked a 30s pending timer pre-fix.
|
|
1415
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
1398
1416
|
try {
|
|
1399
1417
|
const result = await Promise.race([
|
|
1400
1418
|
handlerMod.__renderNotFound(locale),
|
|
1401
|
-
new Promise<never>((_, reject) =>
|
|
1402
|
-
setTimeout(
|
|
1419
|
+
new Promise<never>((_, reject) => {
|
|
1420
|
+
timeoutId = setTimeout(
|
|
1403
1421
|
() =>
|
|
1404
1422
|
reject(
|
|
1405
1423
|
new Error(
|
|
@@ -1407,8 +1425,8 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1407
1425
|
),
|
|
1408
1426
|
),
|
|
1409
1427
|
30_000,
|
|
1410
|
-
)
|
|
1411
|
-
),
|
|
1428
|
+
)
|
|
1429
|
+
}),
|
|
1412
1430
|
])
|
|
1413
1431
|
if (result) {
|
|
1414
1432
|
const html = injectIntoTemplate(template, result)
|
|
@@ -1433,6 +1451,8 @@ export function ssgPlugin(userConfig: ZeroConfig = {}): Plugin {
|
|
|
1433
1451
|
path: locale == null ? '404.html' : `${locale}/404.html`,
|
|
1434
1452
|
error,
|
|
1435
1453
|
})
|
|
1454
|
+
} finally {
|
|
1455
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
|
1436
1456
|
}
|
|
1437
1457
|
}
|
|
1438
1458
|
}
|