@pyreon/zero 0.15.0 → 0.18.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.
Files changed (52) hide show
  1. package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
  2. package/lib/client.js +4 -1
  3. package/lib/env.js +6 -6
  4. package/lib/font.js +3 -3
  5. package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
  6. package/lib/i18n-routing.js +112 -1
  7. package/lib/image.js +140 -58
  8. package/lib/index.js +252 -82
  9. package/lib/og-image.js +5 -5
  10. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  11. package/lib/script.js +114 -25
  12. package/lib/seo.js +186 -15
  13. package/lib/server.js +274 -564
  14. package/lib/types/config.d.ts +307 -3
  15. package/lib/types/env.d.ts +2 -2
  16. package/lib/types/i18n-routing.d.ts +193 -2
  17. package/lib/types/image.d.ts +105 -5
  18. package/lib/types/index.d.ts +666 -182
  19. package/lib/types/script.d.ts +78 -6
  20. package/lib/types/seo.d.ts +128 -4
  21. package/lib/types/server.d.ts +607 -72
  22. package/lib/vite-plugin-y0NmCLJA.js +2476 -0
  23. package/package.json +11 -10
  24. package/src/adapters/bun.ts +20 -1
  25. package/src/adapters/cloudflare.ts +78 -1
  26. package/src/adapters/index.ts +25 -3
  27. package/src/adapters/netlify.ts +63 -1
  28. package/src/adapters/node.ts +25 -1
  29. package/src/adapters/static.ts +26 -1
  30. package/src/adapters/validate.ts +8 -1
  31. package/src/adapters/vercel.ts +76 -1
  32. package/src/adapters/warn-missing-env.ts +49 -0
  33. package/src/app.ts +14 -0
  34. package/src/client.ts +18 -0
  35. package/src/entry-server.ts +55 -5
  36. package/src/env.ts +7 -7
  37. package/src/font.ts +3 -3
  38. package/src/fs-router.ts +72 -3
  39. package/src/i18n-routing.ts +246 -12
  40. package/src/image.tsx +242 -91
  41. package/src/index.ts +4 -4
  42. package/src/isr.ts +24 -6
  43. package/src/manifest.ts +675 -0
  44. package/src/og-image.ts +5 -5
  45. package/src/script.tsx +159 -36
  46. package/src/seo.ts +346 -15
  47. package/src/server.ts +10 -2
  48. package/src/ssg-plugin.ts +1211 -54
  49. package/src/types.ts +333 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +171 -41
  52. package/lib/vite-plugin-E4BHYvYW.js +0 -855
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ComponentFn } from '@pyreon/core'
2
2
  import type { LoaderContext, NavigationGuard } from '@pyreon/router'
3
3
  import type { Middleware } from '@pyreon/server'
4
+ import type { I18nRoutingConfig } from './i18n-routing'
4
5
 
5
6
  // Re-export router's `LoaderContext` so consumers importing it from
6
7
  // `@pyreon/zero` keep working. The previous duplicate `interface
@@ -57,6 +58,42 @@ export interface ISRConfig {
57
58
  * space (e.g. `/user/:id` where `:id` is free-form).
58
59
  */
59
60
  maxEntries?: number
61
+ /**
62
+ * Cache-key derivation function. The default keys cache entries by
63
+ * `url.pathname` ONLY — query strings, cookies, and headers are
64
+ * stripped.
65
+ *
66
+ * **⚠️ Auth-gated incompatibility.** The default behavior is
67
+ * unsafe for request-dependent loaders. A loader that reads
68
+ * `request.headers.get('cookie')` to gate auth will render ONCE
69
+ * with the first user's cookie, then serve that HTML to every
70
+ * subsequent user. To use ISR with personalized / auth-gated
71
+ * pages, supply a `cacheKey` that varies on the auth identifier
72
+ * (session cookie, user-id header, etc.), OR don't use ISR for
73
+ * such routes — use SSR instead.
74
+ *
75
+ * @example
76
+ * // Vary cache by session cookie:
77
+ * isr: {
78
+ * revalidate: 60,
79
+ * cacheKey: (req) => {
80
+ * const url = new URL(req.url)
81
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
82
+ * return `${url.pathname}::${session}`
83
+ * },
84
+ * }
85
+ *
86
+ * @example
87
+ * // Vary by a query parameter:
88
+ * isr: {
89
+ * revalidate: 60,
90
+ * cacheKey: (req) => {
91
+ * const url = new URL(req.url)
92
+ * return `${url.pathname}?sort=${url.searchParams.get('sort') ?? ''}`
93
+ * },
94
+ * }
95
+ */
96
+ cacheKey?: (req: Request) => string
60
97
  }
61
98
 
62
99
  // ─── Zero config ─────────────────────────────────────────────────────────────
@@ -78,17 +115,207 @@ export interface ZeroConfig {
78
115
  ssg?: {
79
116
  /** Paths to prerender (or function returning paths). */
80
117
  paths?: string[] | (() => string[] | Promise<string[]>)
118
+ /**
119
+ * Auto-emit `dist/404.html` from the route tree's `_404.tsx` /
120
+ * `_not-found.tsx` convention. fs-router already wires `_404.tsx` as
121
+ * `notFoundComponent` on its parent layout route; the SSG plugin walks
122
+ * the tree, picks up the first one, renders it through the same SSR
123
+ * pipeline as regular paths (so styler CSS / @pyreon/head metadata land
124
+ * correctly), and writes the result to `dist/404.html`. Static hosts
125
+ * (Netlify, Cloudflare Pages, GitHub Pages, S3+CloudFront) serve this
126
+ * file automatically for unmatched URLs. Default: `true`. Set to
127
+ * `false` to opt out — the route tree is left alone.
128
+ */
129
+ emit404?: boolean
130
+ /**
131
+ * When a route loader throws `redirect('/target')` during SSG, write
132
+ * a `dist/_redirects` file (Netlify / Cloudflare Pages convention)
133
+ * AND a `dist/_redirects.json` (Vercel convention) listing every
134
+ * redirected source path → target. Static hosts pick whichever
135
+ * format their platform supports automatically. The redirected
136
+ * path's HTML file is NOT emitted — the redirect is the response.
137
+ *
138
+ * Without this option, redirect-throwing loaders land in
139
+ * `errors[]` and the path silently disappears from the build —
140
+ * the user sees no output for `/old` AND no warning that the
141
+ * loader ran a redirect. Default: `true`. Set to `false` to
142
+ * restore the pre-PR-B behaviour (redirects treated as errors).
143
+ */
144
+ emitRedirects?: boolean
145
+ /**
146
+ * Additionally emit a static HTML file at the source path with a
147
+ * `<meta http-equiv="refresh">` redirect — for adapters / hosts
148
+ * that don't read `_redirects` (plain S3, GitHub Pages, simple
149
+ * file servers). The meta-refresh fallback works on any HTTP
150
+ * server that serves static files.
151
+ *
152
+ * - `'none'` (default): only `_redirects` / `_redirects.json` are
153
+ * emitted; no per-redirect HTML file.
154
+ * - `'meta-refresh'`: emit `dist/<source>/index.html` containing
155
+ * `<meta http-equiv="refresh" content="0; url=<target>">` plus
156
+ * a canonical link tag for SEO. Status code information is
157
+ * lost (meta-refresh has no status equivalent), so 301/302/307/
158
+ * 308 all collapse to "client-side refresh".
159
+ */
160
+ redirectsAsHtml?: 'none' | 'meta-refresh'
161
+ /**
162
+ * Callback invoked when a path's render throws (loader-throw that
163
+ * isn't a `redirect()`, render exception, anything that lands in the
164
+ * `errors[]` collection). Returns either:
165
+ * - `string` → written as the path's HTML in place of the failed
166
+ * render. Use this to emit a per-path fallback page (e.g. a generic
167
+ * "this content is temporarily unavailable" template) so static
168
+ * hosts have something to serve at that URL instead of 404'ing.
169
+ * - `null` → skip; the path produces no HTML output. The error
170
+ * stays in `errors[]` for the post-build summary.
171
+ *
172
+ * The callback runs ONCE per failed path. Async callbacks are
173
+ * awaited. If the callback itself throws, the throw is captured as
174
+ * a separate error entry and the path is skipped (no fallback HTML).
175
+ * Default: `undefined` — failed paths just land in `errors[]`.
176
+ *
177
+ * @example
178
+ * ssg: {
179
+ * onPathError: async (path, error) => {
180
+ * console.error(\`SSG render failed for \${path}:\`, error)
181
+ * return \`<!DOCTYPE html><html><body><h1>Page unavailable</h1></body></html>\`
182
+ * },
183
+ * }
184
+ */
185
+ onPathError?: (
186
+ path: string,
187
+ error: unknown,
188
+ ) => string | null | Promise<string | null>
189
+ /**
190
+ * When `'json'` (default), write `dist/_pyreon-ssg-errors.json` after
191
+ * the render loop summarising every error encountered (path traversal,
192
+ * timeout, render exception, getStaticPaths throw, fallback callback
193
+ * throw). Each entry has `{ path, message, name, stack }`. The file
194
+ * is ONLY written when `errors.length > 0` — successful builds don't
195
+ * leak an empty manifest. Reading it lets CI gate on render failures
196
+ * without parsing console output (e.g.
197
+ * `cat dist/_pyreon-ssg-errors.json | jq '.errors | length' | grep -q 0`).
198
+ *
199
+ * Set to `'none'` to opt out entirely — errors stay in console-only,
200
+ * matching pre-PR-G behaviour.
201
+ */
202
+ errorArtifact?: 'json' | 'none'
203
+ /**
204
+ * Maximum number of paths rendered in parallel during the SSG closeBundle
205
+ * loop. Default: `4` — a sensible balance between speedup and the risk
206
+ * of exhausting downstream resources (DB connection pools, fetch
207
+ * rate-limits) inside loaders. Set to `1` to render fully sequentially
208
+ * (the pre-PR-D behaviour). Set to a higher value for faster builds
209
+ * on CI / multi-core hosts; the practical ceiling is the number of
210
+ * loader-side concurrent connections your app's data layer tolerates.
211
+ *
212
+ * The render-error pipeline (`onPathError` callback, `errors[]`
213
+ * collection, `_pyreon-ssg-errors.json` artifact) is unchanged —
214
+ * concurrency only affects how many paths are in flight at once,
215
+ * not how their successes / failures are recorded.
216
+ *
217
+ * @example
218
+ * ssg: {
219
+ * concurrency: 8, // Faster builds for static-content sites
220
+ * }
221
+ */
222
+ concurrency?: number
223
+ /**
224
+ * Per-path progress callback. Invoked once per path AFTER its render
225
+ * settles (success, redirect, OR failure) — never during in-flight
226
+ * renders. Receives `{ completed, total, currentPath, elapsed }`
227
+ * where:
228
+ * - `completed` is the count of paths whose render has settled (1-indexed)
229
+ * - `total` is the full path count from `resolvePaths()`
230
+ * - `currentPath` is the path that just settled
231
+ * - `elapsed` is wall-clock ms since the loop started
232
+ *
233
+ * Use cases: build-tool progress bars (Vite picks up stdout), CI
234
+ * heartbeat lines on long builds (10k-path sites take minutes —
235
+ * silent stretches look hung), build-time perf instrumentation.
236
+ *
237
+ * Async callbacks are awaited before the next path's progress fires,
238
+ * so a slow callback can serialize progress reporting (it does NOT
239
+ * gate the worker pool — paths keep rendering in parallel; only
240
+ * the progress callbacks themselves are serialized). Throws are
241
+ * captured into `errors[]` with the path suffix `(onProgress)` so
242
+ * a buggy callback can't take down the build.
243
+ *
244
+ * @example
245
+ * ssg: {
246
+ * onProgress: ({ completed, total, currentPath, elapsed }) => {
247
+ * console.log(`[${completed}/${total}] ${currentPath} (${elapsed}ms)`)
248
+ * },
249
+ * }
250
+ */
251
+ onProgress?: (info: {
252
+ completed: number
253
+ total: number
254
+ currentPath: string
255
+ elapsed: number
256
+ }) => void | Promise<void>
257
+ /**
258
+ * Route-level code splitting in SSG mode. Default `true`.
259
+ *
260
+ * When `true` (default), each route file becomes its own dynamic-import
261
+ * chunk via `lazy(() => import("..."))` — only the route the user
262
+ * lands on plus its dependencies ship in the initial bundle, the
263
+ * rest fetch on navigation. Matches the SSR/SPA-mode behaviour zero
264
+ * has always had; brings parity to SSG.
265
+ *
266
+ * When `false`, every route is bundled statically into the main
267
+ * client chunk (the pre-2026-Q3 SSG behaviour). Useful for tiny
268
+ * sites (2-5 pages) where the single-chunk-then-instant-nav trade
269
+ * is preferable — the chunk-fetch cost on navigation is gone, and
270
+ * the marginal bytes are negligible.
271
+ *
272
+ * Crossover point: ~5-8 routes. Below that, single-chunk is fine.
273
+ * Above that, lazy() shrinks the initial bundle by a meaningful
274
+ * amount (a 50-route docs site might drop from 200 KB to 80 KB on
275
+ * first paint).
276
+ *
277
+ * Underlying mechanism is the same 3-tier generator zero already
278
+ * uses for SSR/SPA mode (`fs-router.ts:generateRouteEntry`): lazy
279
+ * component + inlined metadata when possible, lazy + lazy-thunked
280
+ * function exports when not, namespace-import fallback for cases
281
+ * the literal-extractor can't reach.
282
+ *
283
+ * @example
284
+ * ssg: {
285
+ * splitChunks: false, // bundle-everything for a 3-page marketing site
286
+ * }
287
+ */
288
+ splitChunks?: boolean
81
289
  }
82
290
 
83
291
  /** ISR config — only used when mode is "isr". */
84
292
  isr?: ISRConfig
85
293
 
86
- /** Deploy adapter. Default: "node" */
87
- adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify'
294
+ /**
295
+ * Deploy adapter. Default: `"node"`.
296
+ *
297
+ * Accepts either a built-in adapter name (string) OR a constructed
298
+ * `Adapter` instance (e.g. `vercelAdapter()`). The scaffolded templates
299
+ * emit the instance form (`adapter: vercelAdapter()`) by convention.
300
+ * `resolveAdapter` (see `adapters/index.ts`) accepts both shapes —
301
+ * strings go through a switch lookup, instances pass through
302
+ * unchanged.
303
+ */
304
+ adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify' | Adapter
88
305
 
89
306
  /** Base URL path. Default: "/" */
90
307
  base?: string
91
308
 
309
+ /**
310
+ * i18n routing — locale-prefixed route variants generated at build time
311
+ * (PR H of the SSG roadmap). When set, every `FileRoute` is fanned into
312
+ * per-locale duplicates by `expandRoutesForLocales` from
313
+ * `@pyreon/zero`. Independent from the `i18nRouting()` Vite plugin
314
+ * (which only handles request-time locale detection); both can be used
315
+ * together. See `expandRoutesForLocales` JSDoc for strategy semantics.
316
+ */
317
+ i18n?: I18nRoutingConfig
318
+
92
319
  /** App-level middleware applied to all routes. */
93
320
  middleware?: Middleware[]
94
321
 
@@ -135,6 +362,25 @@ export interface RouteFileExports {
135
362
  * must validate session on every navigation rather than serve stale data.
136
363
  */
137
364
  hasGcTime: boolean
365
+ /**
366
+ * Has `export function getStaticPaths` or `export const getStaticPaths`.
367
+ * Used at SSG build time to enumerate concrete values for dynamic routes
368
+ * (`/posts/[id].tsx` → `[/posts/1, /posts/2, …]`). The function returns
369
+ * `Array<{ params: Record<string, string> }>`. Mirrors Astro's per-route
370
+ * convention. Without it, dynamic routes are silently skipped during SSG
371
+ * auto-detect — the user must hand-list every value in `ssg.paths`.
372
+ */
373
+ hasGetStaticPaths: boolean
374
+ /**
375
+ * Has `export const revalidate` (number, in seconds, or `false` for
376
+ * never-revalidate). PR I — build-time ISR. The SSG plugin emits a
377
+ * `dist/_pyreon-revalidate.json` manifest mapping `{ path: revalidate }`
378
+ * which the deploy adapter (Vercel / Cloudflare / Netlify) consumes
379
+ * to wire platform-specific ISR rebuild-on-stale. The route generator
380
+ * does NOT inline `revalidate` onto the route record — it's a
381
+ * build-time-only concern that never reaches the runtime router.
382
+ */
383
+ hasRevalidate: boolean
138
384
  /**
139
385
  * Raw text of the `export const meta = …` initializer, captured as a
140
386
  * literal expression. When present, the route generator inlines this
@@ -154,6 +400,22 @@ export interface RouteFileExports {
154
400
  * as a literal expression. Same inlining strategy as `metaLiteral`.
155
401
  */
156
402
  renderModeLiteral?: string
403
+ /**
404
+ * Raw text of the `export const revalidate = …` initializer (e.g.
405
+ * `'60'`, `'false'`, `'3600'`). Captured at scan time so the SSG
406
+ * plugin can read the value to emit the build-time ISR manifest
407
+ * WITHOUT loading the route module — which is critical because the
408
+ * manifest is emitted from the synthetic SSR build's outer plugin
409
+ * context, where evaluating route modules would re-trigger the
410
+ * recursive sub-build env-flag guard.
411
+ *
412
+ * Only set when the revalidate export is a top-level
413
+ * `export const revalidate = <numeric|boolean literal>` that passes
414
+ * `isPureLiteral`. Anything else (function calls, references to
415
+ * other declarations) leaves this undefined and the manifest falls
416
+ * back to omitting the entry.
417
+ */
418
+ revalidateLiteral?: string
157
419
  }
158
420
 
159
421
  /** Internal representation of a file-system route before conversion to RouteRecord. */
@@ -203,14 +465,75 @@ export interface Adapter {
203
465
  name: string
204
466
  /** Build the production server/output for this adapter. */
205
467
  build(options: AdapterBuildOptions): Promise<void>
468
+ /**
469
+ * Revalidate a prerendered path on the deploy platform's ISR layer
470
+ * (PR I — build-time ISR). Called by user code (webhook handlers,
471
+ * cron jobs, CMS triggers, etc.) to trigger a rebuild-on-stale for
472
+ * the named path. Optional — adapters without platform ISR support
473
+ * (static, node, bun) implement a no-op. Returns `{ regenerated:
474
+ * boolean }` so user code can branch on whether the platform actually
475
+ * accepted the revalidation request.
476
+ *
477
+ * Distinct from runtime ISR (`mode: 'isr'`, on-demand LRU caching in
478
+ * `@pyreon/zero/server`'s `createISRHandler`). Build-time ISR is
479
+ * static prerender + platform-driven rebuild-on-stale; runtime ISR is
480
+ * SSR-cached-with-TTL. They can coexist.
481
+ *
482
+ * Per-route `revalidate` metadata flows from `export const revalidate
483
+ * = 60` in route files into a `dist/_pyreon-revalidate.json` manifest
484
+ * the adapter reads at deploy time. Adapters use that manifest to
485
+ * configure platform ISR (Vercel `output/config.json`, Cloudflare
486
+ * Cache API rules, Netlify revalidation headers).
487
+ */
488
+ revalidate?(path: string): Promise<AdapterRevalidateResult>
206
489
  }
207
490
 
208
- export interface AdapterBuildOptions {
209
- /** Path to the built server entry. */
210
- serverEntry: string
211
- /** Path to the client build output. */
212
- clientOutDir: string
213
- /** Final output directory. */
214
- outDir: string
215
- config: ZeroConfig
491
+ /**
492
+ * Result of `Adapter.revalidate(path)`. `regenerated: false` means the
493
+ * adapter does not support platform ISR (no-op fallback) OR the
494
+ * platform rejected the request. Adapters that throw on platform-API
495
+ * failure should let it propagate so user code can handle the rejection.
496
+ */
497
+ export interface AdapterRevalidateResult {
498
+ regenerated: boolean
216
499
  }
500
+
501
+ /**
502
+ * Inputs the build pipeline passes to an adapter's `build()` method.
503
+ *
504
+ * The `kind` field discriminates the two shapes. **SSR mode** (`'ssr'`)
505
+ * carries `serverEntry` + `clientOutDir` so adapters can wrap the user's
506
+ * server bundle as a serverless function. **SSG mode** (`'ssg'`) carries
507
+ * only `outDir` (which IS the rendered dist/) — no serverEntry exists
508
+ * because every page is already prerendered. SSG-mode adapters write
509
+ * platform-specific routing config so the host knows the deploy is
510
+ * fully-static (no function invocation per request).
511
+ *
512
+ * Pre-PR-J this was a single SSR-shaped struct; the SSG path had no way
513
+ * to invoke `adapter.build()` because it couldn't supply `serverEntry`.
514
+ * Adding `kind` (with TS-narrowing per branch) lets `ssgPlugin`
515
+ * `closeBundle` call `adapter.build({ kind: 'ssg', outDir, config })`
516
+ * cleanly, AND keeps the SSR-mode adapter implementations unchanged.
517
+ */
518
+ export type AdapterBuildOptions =
519
+ | {
520
+ kind: 'ssr'
521
+ /** Path to the built server entry. */
522
+ serverEntry: string
523
+ /** Path to the client build output. */
524
+ clientOutDir: string
525
+ /** Final output directory. */
526
+ outDir: string
527
+ config: ZeroConfig
528
+ }
529
+ | {
530
+ kind: 'ssg'
531
+ /**
532
+ * The rendered dist directory. For SSG, this directory IS the
533
+ * publishable output — adapters write platform-specific routing
534
+ * config alongside (e.g. `.vercel/output/config.json`,
535
+ * `_routes.json`, `netlify.toml`) but generally don't move files.
536
+ */
537
+ outDir: string
538
+ config: ZeroConfig
539
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * M3.1 — Drop-in Vercel revalidate webhook handler.
3
+ *
4
+ * Pre-M3.1 the `vercelAdapter.revalidate(path)` (PR I) POSTed to
5
+ * `/api/_pyreon-revalidate?path=...&secret=...` — a CONVENTION that users
6
+ * had to implement themselves. This helper scaffolds the convention:
7
+ *
8
+ * // src/routes/api/_pyreon-revalidate.ts (or `pages/api/...` in
9
+ * // Next-style apps deployed to Vercel)
10
+ * export { vercelRevalidateHandler as default } from '@pyreon/zero/server'
11
+ *
12
+ * The handler validates the secret query param against
13
+ * `VERCEL_REVALIDATE_TOKEN`, validates the path is in the build-time
14
+ * revalidate manifest, and calls Vercel's `res.revalidate(path)` API.
15
+ *
16
+ * Returns a standard `(req: Request) => Response` Web API handler — works
17
+ * with Vercel Edge functions, Node serverless functions (via Vercel's
18
+ * `@vercel/node` adapter that bridges Node `req`/`res` to Web standard
19
+ * fetch shapes), and the in-process `mode: 'ssr'` runtime.
20
+ *
21
+ * @example
22
+ * // src/routes/api/_pyreon-revalidate.ts
23
+ * import { vercelRevalidateHandler } from '@pyreon/zero/server'
24
+ *
25
+ * export const POST = vercelRevalidateHandler({
26
+ * // Optional — defaults to reading `_pyreon-revalidate.json` from cwd.
27
+ * manifestPath: './dist/_pyreon-revalidate.json',
28
+ * })
29
+ *
30
+ * @example
31
+ * // Custom revalidate impl (e.g. for a self-hosted Pyreon SSR runtime
32
+ * // that wants build-time revalidate behavior without Vercel's
33
+ * // `res.revalidate()` API):
34
+ * export const POST = vercelRevalidateHandler({
35
+ * onRevalidate: async (path) => {
36
+ * // Clear your in-process ISR cache, emit a metrics event, etc.
37
+ * await myCache.invalidate(path)
38
+ * },
39
+ * })
40
+ */
41
+
42
+ import { readFile } from 'node:fs/promises'
43
+ import { resolve } from 'node:path'
44
+
45
+ /**
46
+ * Build-time revalidate manifest written by `ssgPlugin` (PR I).
47
+ * Shape: `{ revalidate: { '/posts/1': 60, '/posts/2': 60, '/about': 3600 } }`.
48
+ */
49
+ interface RevalidateManifest {
50
+ revalidate: Record<string, number | false>
51
+ }
52
+
53
+ export interface VercelRevalidateHandlerOptions {
54
+ /**
55
+ * Absolute or cwd-relative path to the `_pyreon-revalidate.json` manifest.
56
+ * Defaults to `./dist/_pyreon-revalidate.json` (the standard SSG output).
57
+ *
58
+ * The handler refuses to revalidate paths NOT in this manifest — protects
59
+ * against arbitrary-path revalidation attacks even when the secret leaks.
60
+ */
61
+ manifestPath?: string
62
+
63
+ /**
64
+ * Custom revalidation impl. Defaults to calling Vercel's `res.revalidate()`
65
+ * API via the dynamic `@vercel/node`-bridged response object on globalThis
66
+ * (Vercel injects it for serverless functions).
67
+ *
68
+ * Supply this when running OUTSIDE Vercel (self-hosted SSR with a custom
69
+ * in-process ISR cache, edge runtimes that have their own purge API, etc.).
70
+ * Receives the validated path; throw to signal failure (handler returns 500).
71
+ */
72
+ onRevalidate?: (path: string) => void | Promise<void>
73
+
74
+ /**
75
+ * Override the env-var name the handler reads the secret from. Default
76
+ * `VERCEL_REVALIDATE_TOKEN` matches the adapter's `revalidate()` write.
77
+ * Useful when adopting the helper outside Vercel and the production
78
+ * webhook uses a different secret name.
79
+ */
80
+ secretEnvVar?: string
81
+ }
82
+
83
+ /**
84
+ * Create the Web-standard request handler. Reads the manifest once on first
85
+ * invocation (cached in-process) so repeated revalidations don't re-read the
86
+ * file. Manifest read failures cache the failure too — until next process
87
+ * restart, all requests get the same 500 response (signals deploy-time misconfig).
88
+ */
89
+ export function vercelRevalidateHandler(
90
+ options: VercelRevalidateHandlerOptions = {},
91
+ ): (req: Request) => Promise<Response> {
92
+ const manifestPath = options.manifestPath ?? './dist/_pyreon-revalidate.json'
93
+ const secretEnvVar = options.secretEnvVar ?? 'VERCEL_REVALIDATE_TOKEN'
94
+
95
+ // Manifest cache: loaded once per process. A nullish value means "not yet
96
+ // loaded"; a `{ error: ... }` shape means "load failed, every subsequent
97
+ // request gets 500 until restart". A `{ manifest: ... }` shape is the
98
+ // happy path.
99
+ let cache: { manifest: RevalidateManifest } | { error: unknown } | null = null
100
+
101
+ return async function handler(req: Request): Promise<Response> {
102
+ // Validate request shape: only POST, with `?path=&secret=` query.
103
+ if (req.method !== 'POST') {
104
+ return new Response(`Method ${req.method} not allowed`, { status: 405 })
105
+ }
106
+
107
+ const url = new URL(req.url)
108
+ const path = url.searchParams.get('path')
109
+ const secret = url.searchParams.get('secret')
110
+
111
+ if (!path || !secret) {
112
+ return new Response('Bad Request: missing path or secret', { status: 400 })
113
+ }
114
+
115
+ // Validate the secret against the env var. Constant-time-ish: we
116
+ // compare strings of equal length; mismatched lengths short-circuit
117
+ // (acceptable — the attacker can already see the response time
118
+ // difference via fetch behavior). The env-var-missing case fails
119
+ // CLOSED (401) — production webhooks shouldn't accept requests when
120
+ // the server hasn't been configured.
121
+ const expected = process.env[secretEnvVar]
122
+ if (!expected) {
123
+ return new Response(
124
+ `Server misconfigured: ${secretEnvVar} env var not set`,
125
+ { status: 500 },
126
+ )
127
+ }
128
+ if (secret !== expected) {
129
+ return new Response('Forbidden: invalid secret', { status: 403 })
130
+ }
131
+
132
+ // Load the manifest (once per process). On read failure, cache the
133
+ // error so subsequent requests get fast 500s — saves rep eated stat
134
+ // calls for a broken deploy.
135
+ if (cache === null) {
136
+ try {
137
+ const fileContent = await readFile(resolve(process.cwd(), manifestPath), 'utf-8')
138
+ const parsed = JSON.parse(fileContent) as RevalidateManifest
139
+ if (typeof parsed?.revalidate !== 'object' || parsed.revalidate === null) {
140
+ throw new Error(
141
+ `Malformed revalidate manifest at ${manifestPath}: missing or non-object \`revalidate\` field`,
142
+ )
143
+ }
144
+ cache = { manifest: parsed }
145
+ } catch (err) {
146
+ cache = { error: err }
147
+ }
148
+ }
149
+ if ('error' in cache) {
150
+ return new Response(
151
+ `Server misconfigured: revalidate manifest at ${manifestPath} unreadable or malformed`,
152
+ { status: 500 },
153
+ )
154
+ }
155
+
156
+ // Validate the path is in the manifest — refuses arbitrary-path
157
+ // revalidation even with a valid secret. Closes the
158
+ // "secret leaked once → attacker revalidates anything" footgun.
159
+ if (!Object.prototype.hasOwnProperty.call(cache.manifest.revalidate, path)) {
160
+ return new Response(
161
+ `Path "${path}" not in revalidate manifest`,
162
+ { status: 404 },
163
+ )
164
+ }
165
+
166
+ // Run the revalidation. Custom impl OR fallback to a structured
167
+ // response that downstream Vercel-style code can adapt
168
+ // (Vercel's `res.revalidate()` API can't be called from a
169
+ // Web-standard handler without the `@vercel/node` bridge — the
170
+ // user wires that themselves OR uses the `onRevalidate` callback).
171
+ if (options.onRevalidate) {
172
+ try {
173
+ await options.onRevalidate(path)
174
+ } catch (err) {
175
+ return new Response(
176
+ `Revalidation failed for "${path}": ${err instanceof Error ? err.message : String(err)}`,
177
+ { status: 500 },
178
+ )
179
+ }
180
+ }
181
+
182
+ return new Response(JSON.stringify({ revalidated: true, path }), {
183
+ status: 200,
184
+ headers: { 'Content-Type': 'application/json' },
185
+ })
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Reset the in-process manifest cache. Test-only — production code never
191
+ * reaches this. Used by unit tests to exercise the "manifest changed
192
+ * between requests" path without spinning up a new handler.
193
+ * @internal
194
+ */
195
+ export function _resetVercelRevalidateHandlerCache(
196
+ handler: (req: Request) => Promise<Response>,
197
+ ): void {
198
+ // The cache lives in the closure; tests instantiate a fresh handler per
199
+ // run rather than mutating an existing one. Kept here as a no-op marker
200
+ // for the API contract — if cache invalidation surfaces as a real need
201
+ // (e.g. hot-reload of the manifest after a deploy without restart), the
202
+ // implementation can flip to a module-level WeakMap<handler, cache>.
203
+ void handler
204
+ }