@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/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
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`)
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
- interface CacheEntry {
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
- * In-memory ISR cache with stale-while-revalidate semantics.
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
- * Wraps an SSR handler and caches responses per URL path.
15
- * Serves stale content immediately while revalidating in the background.
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
- * Bounded by `config.maxEntries` (default: 1000) with LRU eviction. The
18
- * `Map` preserves insertion order, so re-inserting an entry on every
19
- * serve (touching it) keeps the LRU order correct. Without the cap,
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
- ): (req: Request) => Promise<Response> {
28
- const cache = new Map<string, CacheEntry>()
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
- // Forward the original request shape (headers + method) so the
93
- // re-render sees the same auth context as the user's read.
94
- const req = new Request(url.href, {
95
- method: 'GET',
96
- headers: originalReq.headers,
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
- return async (req: Request): Promise<Response> => {
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
- // `touch` moves the entry to the newest LRU position on read so
135
- // hot paths survive eviction even when the cap is small. `get`
136
- // wouldn't update ordering.
137
- const entry = touch(key)
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(options: { handler: Handler; cacheTtl?: number; ... }): Handler',
224
+ 'function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): ISRHandler',
225
225
  summary:
226
- "Runtime ISR — on-demand SSR caching with TTL. Wraps an SSR handler so pages are rendered on the FIRST request, cached for `cacheTtl` ms (default 60s), and served stale until expiry. 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.",
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 isrHandler = createISRHandler({
232
- handler: ssrHandler,
233
- cacheTtl: 60_000, // serve cached HTML for 60s
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
- export default isrHandler`,
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
- 'Setting `cacheTtl: 0` and expecting "never cache"pass-through is the explicit handler call (no `createISRHandler` wrapper). `cacheTtl: 0` is a degenerate state',
239
- "Sharing the ISR handler across server instances without external cacheeach server's in-memory cache diverges. For multi-instance deploys, swap to a shared cache layer (Redis adapter not built in; user-side concern)",
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 { createISRHandler } from "./isr";
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
- setTimeout(() => reject(new Error(`Prerender timeout for "${p}" (30s)`)), 30_000),
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
  }