@pyreon/zero 0.15.0 → 0.16.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 +275 -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 +634 -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 +575 -72
  22. package/lib/vite-plugin-xjWZwudX.js +2454 -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 +301 -10
  50. package/src/vercel-revalidate-handler.ts +204 -0
  51. package/src/vite-plugin.ts +108 -30
  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,175 @@ 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>
81
257
  }
82
258
 
83
259
  /** ISR config — only used when mode is "isr". */
84
260
  isr?: ISRConfig
85
261
 
86
- /** Deploy adapter. Default: "node" */
87
- adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify'
262
+ /**
263
+ * Deploy adapter. Default: `"node"`.
264
+ *
265
+ * Accepts either a built-in adapter name (string) OR a constructed
266
+ * `Adapter` instance (e.g. `vercelAdapter()`). The scaffolded templates
267
+ * emit the instance form (`adapter: vercelAdapter()`) by convention.
268
+ * `resolveAdapter` (see `adapters/index.ts`) accepts both shapes —
269
+ * strings go through a switch lookup, instances pass through
270
+ * unchanged.
271
+ */
272
+ adapter?: 'node' | 'bun' | 'static' | 'vercel' | 'cloudflare' | 'netlify' | Adapter
88
273
 
89
274
  /** Base URL path. Default: "/" */
90
275
  base?: string
91
276
 
277
+ /**
278
+ * i18n routing — locale-prefixed route variants generated at build time
279
+ * (PR H of the SSG roadmap). When set, every `FileRoute` is fanned into
280
+ * per-locale duplicates by `expandRoutesForLocales` from
281
+ * `@pyreon/zero`. Independent from the `i18nRouting()` Vite plugin
282
+ * (which only handles request-time locale detection); both can be used
283
+ * together. See `expandRoutesForLocales` JSDoc for strategy semantics.
284
+ */
285
+ i18n?: I18nRoutingConfig
286
+
92
287
  /** App-level middleware applied to all routes. */
93
288
  middleware?: Middleware[]
94
289
 
@@ -135,6 +330,25 @@ export interface RouteFileExports {
135
330
  * must validate session on every navigation rather than serve stale data.
136
331
  */
137
332
  hasGcTime: boolean
333
+ /**
334
+ * Has `export function getStaticPaths` or `export const getStaticPaths`.
335
+ * Used at SSG build time to enumerate concrete values for dynamic routes
336
+ * (`/posts/[id].tsx` → `[/posts/1, /posts/2, …]`). The function returns
337
+ * `Array<{ params: Record<string, string> }>`. Mirrors Astro's per-route
338
+ * convention. Without it, dynamic routes are silently skipped during SSG
339
+ * auto-detect — the user must hand-list every value in `ssg.paths`.
340
+ */
341
+ hasGetStaticPaths: boolean
342
+ /**
343
+ * Has `export const revalidate` (number, in seconds, or `false` for
344
+ * never-revalidate). PR I — build-time ISR. The SSG plugin emits a
345
+ * `dist/_pyreon-revalidate.json` manifest mapping `{ path: revalidate }`
346
+ * which the deploy adapter (Vercel / Cloudflare / Netlify) consumes
347
+ * to wire platform-specific ISR rebuild-on-stale. The route generator
348
+ * does NOT inline `revalidate` onto the route record — it's a
349
+ * build-time-only concern that never reaches the runtime router.
350
+ */
351
+ hasRevalidate: boolean
138
352
  /**
139
353
  * Raw text of the `export const meta = …` initializer, captured as a
140
354
  * literal expression. When present, the route generator inlines this
@@ -154,6 +368,22 @@ export interface RouteFileExports {
154
368
  * as a literal expression. Same inlining strategy as `metaLiteral`.
155
369
  */
156
370
  renderModeLiteral?: string
371
+ /**
372
+ * Raw text of the `export const revalidate = …` initializer (e.g.
373
+ * `'60'`, `'false'`, `'3600'`). Captured at scan time so the SSG
374
+ * plugin can read the value to emit the build-time ISR manifest
375
+ * WITHOUT loading the route module — which is critical because the
376
+ * manifest is emitted from the synthetic SSR build's outer plugin
377
+ * context, where evaluating route modules would re-trigger the
378
+ * recursive sub-build env-flag guard.
379
+ *
380
+ * Only set when the revalidate export is a top-level
381
+ * `export const revalidate = <numeric|boolean literal>` that passes
382
+ * `isPureLiteral`. Anything else (function calls, references to
383
+ * other declarations) leaves this undefined and the manifest falls
384
+ * back to omitting the entry.
385
+ */
386
+ revalidateLiteral?: string
157
387
  }
158
388
 
159
389
  /** Internal representation of a file-system route before conversion to RouteRecord. */
@@ -203,14 +433,75 @@ export interface Adapter {
203
433
  name: string
204
434
  /** Build the production server/output for this adapter. */
205
435
  build(options: AdapterBuildOptions): Promise<void>
436
+ /**
437
+ * Revalidate a prerendered path on the deploy platform's ISR layer
438
+ * (PR I — build-time ISR). Called by user code (webhook handlers,
439
+ * cron jobs, CMS triggers, etc.) to trigger a rebuild-on-stale for
440
+ * the named path. Optional — adapters without platform ISR support
441
+ * (static, node, bun) implement a no-op. Returns `{ regenerated:
442
+ * boolean }` so user code can branch on whether the platform actually
443
+ * accepted the revalidation request.
444
+ *
445
+ * Distinct from runtime ISR (`mode: 'isr'`, on-demand LRU caching in
446
+ * `@pyreon/zero/server`'s `createISRHandler`). Build-time ISR is
447
+ * static prerender + platform-driven rebuild-on-stale; runtime ISR is
448
+ * SSR-cached-with-TTL. They can coexist.
449
+ *
450
+ * Per-route `revalidate` metadata flows from `export const revalidate
451
+ * = 60` in route files into a `dist/_pyreon-revalidate.json` manifest
452
+ * the adapter reads at deploy time. Adapters use that manifest to
453
+ * configure platform ISR (Vercel `output/config.json`, Cloudflare
454
+ * Cache API rules, Netlify revalidation headers).
455
+ */
456
+ revalidate?(path: string): Promise<AdapterRevalidateResult>
206
457
  }
207
458
 
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
459
+ /**
460
+ * Result of `Adapter.revalidate(path)`. `regenerated: false` means the
461
+ * adapter does not support platform ISR (no-op fallback) OR the
462
+ * platform rejected the request. Adapters that throw on platform-API
463
+ * failure should let it propagate so user code can handle the rejection.
464
+ */
465
+ export interface AdapterRevalidateResult {
466
+ regenerated: boolean
216
467
  }
468
+
469
+ /**
470
+ * Inputs the build pipeline passes to an adapter's `build()` method.
471
+ *
472
+ * The `kind` field discriminates the two shapes. **SSR mode** (`'ssr'`)
473
+ * carries `serverEntry` + `clientOutDir` so adapters can wrap the user's
474
+ * server bundle as a serverless function. **SSG mode** (`'ssg'`) carries
475
+ * only `outDir` (which IS the rendered dist/) — no serverEntry exists
476
+ * because every page is already prerendered. SSG-mode adapters write
477
+ * platform-specific routing config so the host knows the deploy is
478
+ * fully-static (no function invocation per request).
479
+ *
480
+ * Pre-PR-J this was a single SSR-shaped struct; the SSG path had no way
481
+ * to invoke `adapter.build()` because it couldn't supply `serverEntry`.
482
+ * Adding `kind` (with TS-narrowing per branch) lets `ssgPlugin`
483
+ * `closeBundle` call `adapter.build({ kind: 'ssg', outDir, config })`
484
+ * cleanly, AND keeps the SSR-mode adapter implementations unchanged.
485
+ */
486
+ export type AdapterBuildOptions =
487
+ | {
488
+ kind: 'ssr'
489
+ /** Path to the built server entry. */
490
+ serverEntry: string
491
+ /** Path to the client build output. */
492
+ clientOutDir: string
493
+ /** Final output directory. */
494
+ outDir: string
495
+ config: ZeroConfig
496
+ }
497
+ | {
498
+ kind: 'ssg'
499
+ /**
500
+ * The rendered dist directory. For SSG, this directory IS the
501
+ * publishable output — adapters write platform-specific routing
502
+ * config alongside (e.g. `.vercel/output/config.json`,
503
+ * `_routes.json`, `netlify.toml`) but generally don't move files.
504
+ */
505
+ outDir: string
506
+ config: ZeroConfig
507
+ }
@@ -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
+ }