@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.
- package/lib/{api-routes-DANluJic.js → api-routes-Ci0kVmM4.js} +2 -2
- package/lib/client.js +4 -1
- package/lib/env.js +6 -6
- package/lib/font.js +3 -3
- package/lib/{fs-router-ZebyutPa.js → fs-router-MewHc5SB.js} +25 -30
- package/lib/i18n-routing.js +112 -1
- package/lib/image.js +140 -58
- package/lib/index.js +252 -82
- package/lib/og-image.js +5 -5
- package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
- package/lib/script.js +114 -25
- package/lib/seo.js +186 -15
- package/lib/server.js +274 -564
- package/lib/types/config.d.ts +307 -3
- package/lib/types/env.d.ts +2 -2
- package/lib/types/i18n-routing.d.ts +193 -2
- package/lib/types/image.d.ts +105 -5
- package/lib/types/index.d.ts +666 -182
- package/lib/types/script.d.ts +78 -6
- package/lib/types/seo.d.ts +128 -4
- package/lib/types/server.d.ts +607 -72
- package/lib/vite-plugin-y0NmCLJA.js +2476 -0
- package/package.json +11 -10
- package/src/adapters/bun.ts +20 -1
- package/src/adapters/cloudflare.ts +78 -1
- package/src/adapters/index.ts +25 -3
- package/src/adapters/netlify.ts +63 -1
- package/src/adapters/node.ts +25 -1
- package/src/adapters/static.ts +26 -1
- package/src/adapters/validate.ts +8 -1
- package/src/adapters/vercel.ts +76 -1
- package/src/adapters/warn-missing-env.ts +49 -0
- package/src/app.ts +14 -0
- package/src/client.ts +18 -0
- package/src/entry-server.ts +55 -5
- package/src/env.ts +7 -7
- package/src/font.ts +3 -3
- package/src/fs-router.ts +72 -3
- package/src/i18n-routing.ts +246 -12
- package/src/image.tsx +242 -91
- package/src/index.ts +4 -4
- package/src/isr.ts +24 -6
- package/src/manifest.ts +675 -0
- package/src/og-image.ts +5 -5
- package/src/script.tsx +159 -36
- package/src/seo.ts +346 -15
- package/src/server.ts +10 -2
- package/src/ssg-plugin.ts +1211 -54
- package/src/types.ts +333 -10
- package/src/vercel-revalidate-handler.ts +204 -0
- package/src/vite-plugin.ts +171 -41
- 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
|
-
/**
|
|
87
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|