@pyreon/zero 0.24.5 → 0.24.6

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 (54) hide show
  1. package/package.json +10 -39
  2. package/src/actions.ts +0 -196
  3. package/src/adapters/bun.ts +0 -114
  4. package/src/adapters/cloudflare.ts +0 -166
  5. package/src/adapters/index.ts +0 -61
  6. package/src/adapters/netlify.ts +0 -154
  7. package/src/adapters/node.ts +0 -163
  8. package/src/adapters/static.ts +0 -42
  9. package/src/adapters/validate.ts +0 -23
  10. package/src/adapters/vercel.ts +0 -182
  11. package/src/adapters/warn-missing-env.ts +0 -49
  12. package/src/ai.ts +0 -623
  13. package/src/api-routes.ts +0 -219
  14. package/src/app.ts +0 -92
  15. package/src/cache.ts +0 -136
  16. package/src/client.ts +0 -143
  17. package/src/compression.ts +0 -116
  18. package/src/config.ts +0 -35
  19. package/src/cors.ts +0 -94
  20. package/src/csp.ts +0 -226
  21. package/src/entry-server.ts +0 -224
  22. package/src/env.ts +0 -344
  23. package/src/error-overlay.ts +0 -118
  24. package/src/favicon.ts +0 -841
  25. package/src/font.ts +0 -511
  26. package/src/fs-router.ts +0 -1519
  27. package/src/i18n-routing.ts +0 -533
  28. package/src/icon.tsx +0 -182
  29. package/src/icons-plugin.ts +0 -296
  30. package/src/image-plugin.ts +0 -751
  31. package/src/image-types.ts +0 -60
  32. package/src/image.tsx +0 -340
  33. package/src/index.ts +0 -92
  34. package/src/isr.ts +0 -394
  35. package/src/link.tsx +0 -304
  36. package/src/logger.ts +0 -144
  37. package/src/manifest.ts +0 -787
  38. package/src/meta.tsx +0 -354
  39. package/src/middleware.ts +0 -65
  40. package/src/not-found.ts +0 -44
  41. package/src/og-image.ts +0 -378
  42. package/src/rate-limit.ts +0 -140
  43. package/src/script.tsx +0 -260
  44. package/src/seo.ts +0 -617
  45. package/src/server.ts +0 -89
  46. package/src/sharp.d.ts +0 -22
  47. package/src/ssg-plugin.ts +0 -1582
  48. package/src/testing.ts +0 -146
  49. package/src/theme.tsx +0 -257
  50. package/src/types.ts +0 -624
  51. package/src/utils/use-intersection-observer.ts +0 -36
  52. package/src/utils/with-headers.ts +0 -13
  53. package/src/vercel-revalidate-handler.ts +0 -204
  54. package/src/vite-plugin.ts +0 -848
@@ -1,61 +0,0 @@
1
- export { bunAdapter } from './bun'
2
- export { cloudflareAdapter } from './cloudflare'
3
- export { netlifyAdapter } from './netlify'
4
- export { nodeAdapter } from './node'
5
- export { staticAdapter } from './static'
6
- export { vercelAdapter } from './vercel'
7
-
8
- import type { Adapter, ZeroConfig } from '../types'
9
- import { bunAdapter } from './bun'
10
- import { cloudflareAdapter } from './cloudflare'
11
- import { netlifyAdapter } from './netlify'
12
- import { nodeAdapter } from './node'
13
- import { staticAdapter } from './static'
14
- import { vercelAdapter } from './vercel'
15
-
16
- /**
17
- * Resolve the adapter from config.
18
- * Returns a built-in adapter or throws if unknown.
19
- *
20
- * Accepts BOTH forms — the `ZeroConfig.adapter` type advertises string
21
- * names (`'vercel'` / `'cloudflare'` / …) but the scaffolded templates
22
- * historically emit `adapter: vercelAdapter()` (an Adapter instance via
23
- * the named factory). Both work: a string goes through the switch lookup;
24
- * an Adapter object (duck-typed via `name` + `build` fields) passes
25
- * through. Pre-PR-J `resolveAdapter` was never called from production
26
- * code so the string-vs-object mismatch was invisible; PR J wires the
27
- * call into `ssgPlugin.closeBundle`, surfacing the contract divergence.
28
- * The passthrough preserves both shapes without a breaking type change.
29
- */
30
- export function resolveAdapter(config: ZeroConfig): Adapter {
31
- const value = config.adapter ?? 'node'
32
-
33
- // Passthrough for already-constructed Adapter instances. Scaffolded
34
- // templates emit `adapter: vercelAdapter()` — detect by duck-typing
35
- // the two required Adapter fields (`name: string` + `build: function`).
36
- if (
37
- typeof value === 'object'
38
- && value !== null
39
- && typeof (value as Adapter).name === 'string'
40
- && typeof (value as Adapter).build === 'function'
41
- ) {
42
- return value as Adapter
43
- }
44
-
45
- switch (value) {
46
- case 'node':
47
- return nodeAdapter()
48
- case 'bun':
49
- return bunAdapter()
50
- case 'static':
51
- return staticAdapter()
52
- case 'vercel':
53
- return vercelAdapter()
54
- case 'cloudflare':
55
- return cloudflareAdapter()
56
- case 'netlify':
57
- return netlifyAdapter()
58
- default:
59
- throw new Error(`[Pyreon] Unknown adapter: "${String(value)}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`)
60
- }
61
- }
@@ -1,154 +0,0 @@
1
- import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
- import { validateBuildInputs } from './validate'
3
- import { warnMissingEnv } from './warn-missing-env'
4
-
5
- /**
6
- * Netlify adapter — generates output for Netlify Functions (v2).
7
- *
8
- * Produces:
9
- * - Client assets in `publish/` directory
10
- * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
11
- * - `netlify.toml` — routing configuration
12
- *
13
- * @example
14
- * ```ts
15
- * // zero.config.ts
16
- * import { defineConfig } from "@pyreon/zero"
17
- *
18
- * export default defineConfig({
19
- * adapter: "netlify",
20
- * })
21
- * ```
22
- */
23
- export function netlifyAdapter(): Adapter {
24
- return {
25
- name: 'netlify',
26
- async build(options: AdapterBuildOptions) {
27
- if (options.kind === 'ssg') {
28
- // PR J — SSG branch. Emit `netlify.toml` with `publish = "."`
29
- // (the dist root) and asset-cache headers — no functions. Tells
30
- // Netlify "this dist directory IS the publishable output, serve
31
- // it as a static site". Without this file, Netlify falls back
32
- // to whatever the user has at the repo root (might miss the
33
- // dist/ direct-upload shape).
34
- //
35
- // PR B's `dist/_redirects` (loader-redirect manifest) is
36
- // emitted by ssgPlugin BEFORE the adapter runs, so this branch
37
- // doesn't need to write it. The two files coexist cleanly —
38
- // Netlify reads both.
39
- const { writeFile } = await import('node:fs/promises')
40
- const { join } = await import('node:path')
41
- const toml = `[build]
42
- publish = "."
43
-
44
- [[headers]]
45
- for = "/assets/*"
46
- [headers.values]
47
- Cache-Control = "public, max-age=31536000, immutable"
48
- `
49
- await writeFile(join(options.outDir, 'netlify.toml'), toml)
50
- return
51
- }
52
- await validateBuildInputs(options)
53
- const { writeFile, cp, mkdir } = await import('node:fs/promises')
54
- const { join } = await import('node:path')
55
-
56
- const outDir = options.outDir
57
- const publishDir = join(outDir, 'publish')
58
- const functionsDir = join(outDir, 'netlify', 'functions')
59
-
60
- await mkdir(publishDir, { recursive: true })
61
- await mkdir(functionsDir, { recursive: true })
62
-
63
- // Copy client assets to publish/
64
- await cp(options.clientOutDir, publishDir, { recursive: true })
65
-
66
- // Copy server build to functions directory
67
- await cp(join(options.serverEntry, '..'), join(functionsDir, '_server'), {
68
- recursive: true,
69
- })
70
-
71
- // Generate Netlify Function (v2 format — ESM, Web-standard Request/Response).
72
- const funcEntry = `
73
- import handler from "./_server/entry-server.js"
74
-
75
- export default async function(req, context) {
76
- try {
77
- return await handler(req)
78
- } catch (err) {
79
- // Surface the error to Netlify Function logs so production
80
- // crashes give real diagnostic info — pre-fix the catch
81
- // swallowed \`err\` entirely and the operator saw only a
82
- // bare "Internal Server Error". \`console.error\` lands in
83
- // Netlify's function runtime logs panel + \`netlify functions:log\`.
84
- console.error("[Pyreon SSR] handler failed:", err)
85
- return new Response("Internal Server Error", { status: 500 })
86
- }
87
- }
88
-
89
- export const config = {
90
- path: "/*",
91
- preferStatic: true,
92
- }
93
- `.trimStart()
94
-
95
- await writeFile(join(functionsDir, 'ssr.mjs'), funcEntry)
96
-
97
- // Generate netlify.toml
98
- const toml = `
99
- [build]
100
- publish = "publish"
101
- functions = "netlify/functions"
102
-
103
- [[headers]]
104
- for = "/assets/*"
105
- [headers.values]
106
- Cache-Control = "public, max-age=31536000, immutable"
107
-
108
- [[redirects]]
109
- from = "/*"
110
- to = "/.netlify/functions/ssr"
111
- status = 200
112
- conditions = {Role = ["admin", "user", ""]}
113
- `.trimStart()
114
-
115
- await writeFile(join(outDir, 'netlify.toml'), toml)
116
- },
117
- async revalidate(path: string): Promise<AdapterRevalidateResult> {
118
- // Netlify ISR via Build Hook trigger. Reads
119
- // `NETLIFY_BUILD_HOOK_URL` from env (created in Site settings →
120
- // Build hooks). Posting to the hook triggers a partial rebuild
121
- // — Netlify rebuilds only the pages whose source has changed
122
- // since last deploy. The path arg is included as a `trigger_title`
123
- // for audit traceability in the Netlify deploy log; Netlify
124
- // doesn't accept per-path revalidation natively (the hook
125
- // re-runs the full build).
126
- //
127
- // Reference: https://docs.netlify.com/configure-builds/build-hooks/
128
- const hookUrl = process.env.NETLIFY_BUILD_HOOK_URL
129
- if (!hookUrl) {
130
- // M2.4 — warn even in production (dedupe per process). See vercel.ts
131
- // for the rationale.
132
- return warnMissingEnv(
133
- 'netlify',
134
- ['NETLIFY_BUILD_HOOK_URL'],
135
- 'Create a build hook in Site settings → Build & deploy → Build hooks → Add build hook. Note: Netlify Build Hooks trigger a FULL site rebuild — the path arg is recorded as `trigger_title` for audit traceability but Netlify doesn\'t support per-page ISR natively.',
136
- )
137
- }
138
- try {
139
- const triggerTitle = `revalidate:${path}`
140
- const res = await fetch(`${hookUrl}?trigger_title=${encodeURIComponent(triggerTitle)}`, {
141
- method: 'POST',
142
- })
143
- return { regenerated: res.ok }
144
- } catch (err) {
145
- if (process.env.NODE_ENV !== 'production') {
146
- console.warn(
147
- `[Pyreon] netlifyAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`,
148
- )
149
- }
150
- return { regenerated: false }
151
- }
152
- },
153
- }
154
- }
@@ -1,163 +0,0 @@
1
- import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
- import { validateBuildInputs } from './validate'
3
-
4
- /**
5
- * Node.js adapter — generates a standalone server entry using node:http.
6
- *
7
- * **SSG mode (PR J)**: no-op. Node adapter exists for serving the SSR
8
- * runtime; SSG output is already complete static HTML — serve it with
9
- * any static-file server (`bun preview` / nginx / Caddy / `npx serve`).
10
- * Use `staticAdapter()` if you want explicit SSG semantics.
11
- */
12
- export function nodeAdapter(): Adapter {
13
- return {
14
- name: 'node',
15
- async build(options: AdapterBuildOptions) {
16
- if (options.kind === 'ssg') {
17
- // Node runner has nothing to add for prerendered SSG dist.
18
- return
19
- }
20
- await validateBuildInputs(options)
21
- const { writeFile, cp, mkdir } = await import('node:fs/promises')
22
- const { join } = await import('node:path')
23
-
24
- const outDir = options.outDir
25
- await mkdir(outDir, { recursive: true })
26
-
27
- // Copy server and client builds
28
- await cp(options.clientOutDir, join(outDir, 'client'), {
29
- recursive: true,
30
- })
31
- await cp(join(options.serverEntry, '..'), join(outDir, 'server'), {
32
- recursive: true,
33
- })
34
-
35
- // Generate standalone server entry
36
- const port = options.config.port ?? 3000
37
- const serverEntry = `
38
- import { createServer } from "node:http"
39
- import { readFile } from "node:fs/promises"
40
- import { join, extname } from "node:path"
41
- import { fileURLToPath } from "node:url"
42
-
43
- const __dirname = fileURLToPath(new URL(".", import.meta.url))
44
- const handler = (await import("./server/entry-server.js")).default
45
- const clientDir = join(__dirname, "client")
46
-
47
- const MIME_TYPES = {
48
- ".html": "text/html",
49
- ".js": "application/javascript",
50
- ".css": "text/css",
51
- ".json": "application/json",
52
- ".png": "image/png",
53
- ".jpg": "image/jpeg",
54
- ".svg": "image/svg+xml",
55
- ".woff2": "font/woff2",
56
- ".woff": "font/woff",
57
- ".ico": "image/x-icon",
58
- }
59
-
60
- const server = createServer(async (req, res) => {
61
- const url = new URL(req.url ?? "/", "http://localhost")
62
-
63
- // Try to serve static files first (GET only).
64
- if (req.method === "GET") {
65
- try {
66
- const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
67
- // Prevent path traversal — ensure resolved path stays within clientDir.
68
- const { resolve } = await import("node:path")
69
- const resolved = resolve(filePath)
70
- if (!resolved.startsWith(resolve(clientDir))) {
71
- res.writeHead(403)
72
- res.end("Forbidden")
73
- return
74
- }
75
- const ext = extname(filePath)
76
- // Pre-fix shape was \`if (ext && ext !== ".html")\` which made the
77
- // static branch silently refuse to serve .html files — INCLUDING
78
- // the root \`/\` → \`index.html\` mapping the line above explicitly
79
- // sets up. Result: GET / always fell through to SSR, even when an
80
- // \`index.html\` shell existed in clientDir. Matches the bun
81
- // adapter's behavior (which serves index.html at /) and the
82
- // standard static + dynamic deployment pattern.
83
- if (ext) {
84
- const data = await readFile(filePath)
85
- const mime = MIME_TYPES[ext] || "application/octet-stream"
86
- res.writeHead(200, {
87
- "content-type": mime,
88
- "cache-control": ext === ".js" || ext === ".css"
89
- ? "public, max-age=31536000, immutable"
90
- : "public, max-age=3600",
91
- })
92
- res.end(data)
93
- return
94
- }
95
- } catch {}
96
- }
97
-
98
- // Fall through to SSR handler.
99
- const headers = {}
100
- for (const [key, value] of Object.entries(req.headers)) {
101
- if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
102
- }
103
-
104
- const request = new Request(url.href, {
105
- method: req.method,
106
- headers,
107
- })
108
-
109
- const response = await handler(request)
110
-
111
- const responseHeaders = {}
112
- response.headers.forEach((v, k) => { responseHeaders[k] = v })
113
-
114
- res.writeHead(response.status, responseHeaders)
115
-
116
- // Pipe the Response body stream directly to res instead of buffering
117
- // the whole body via response.text(). For mode: 'stream' SSR (Suspense
118
- // out-of-order streaming) the pre-fix \`await response.text()\` drained
119
- // every Suspense chunk server-side and arrived at the client all at
120
- // once at the end — silently defeating streaming. For mode: 'string'
121
- // the body is a single chunk and this loop runs once with identical
122
- // observable behaviour.
123
- if (response.body) {
124
- const reader = response.body.getReader()
125
- try {
126
- while (true) {
127
- const { value, done } = await reader.read()
128
- if (done) break
129
- res.write(value)
130
- }
131
- } finally {
132
- res.end()
133
- }
134
- } else {
135
- res.end()
136
- }
137
- })
138
-
139
- server.listen(${port}, () => {
140
- console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
141
- })
142
- `.trimStart()
143
-
144
- await writeFile(join(outDir, 'index.js'), serverEntry)
145
- await writeFile(join(outDir, 'package.json'), JSON.stringify({ type: 'module' }, null, 2))
146
- },
147
- async revalidate(_path: string): Promise<AdapterRevalidateResult> {
148
- // Self-hosted Node has no platform-driven ISR. Real ISR support
149
- // requires a reverse-proxy cache (nginx/varnish) + your own
150
- // cache-purge wiring, OR mode: 'isr' for runtime LRU caching.
151
- // This no-op preserves the Adapter API contract; user code that
152
- // calls `adapter.revalidate(path)` against a self-hosted Node
153
- // deploy gets the same `regenerated: false` shape as the static
154
- // adapter, so migrating between adapters doesn't surprise.
155
- if (process.env.NODE_ENV !== 'production') {
156
- console.warn(
157
- '[Pyreon] nodeAdapter.revalidate() is a no-op — self-hosted Node has no platform-driven ISR. Use mode: "isr" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.',
158
- )
159
- }
160
- return { regenerated: false }
161
- },
162
- }
163
- }
@@ -1,42 +0,0 @@
1
- import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
-
3
- /**
4
- * Static adapter — just copies the client build output.
5
- * Used with SSG mode where all pages are pre-rendered at build time.
6
- *
7
- * **SSG mode (PR J)**: no-op — `outDir` already IS the dist directory
8
- * the SSG plugin produced. Copying it onto itself would only fail. The
9
- * static adapter is the canonical zero-overhead deploy target for
10
- * pure-static sites.
11
- *
12
- * **SSR mode**: copies clientOutDir → outDir. Calling `static` with SSR
13
- * mode is unusual — the static adapter doesn't support server-side
14
- * execution — but preserved as a "client-only output packager".
15
- */
16
- export function staticAdapter(): Adapter {
17
- return {
18
- name: 'static',
19
- async build(options: AdapterBuildOptions) {
20
- if (options.kind === 'ssg') {
21
- // SSG dist is already at outDir — nothing to copy or rewrite.
22
- return
23
- }
24
- const { cp, mkdir } = await import('node:fs/promises')
25
-
26
- await mkdir(options.outDir, { recursive: true })
27
- await cp(options.clientOutDir, options.outDir, { recursive: true })
28
- },
29
- async revalidate(_path: string): Promise<AdapterRevalidateResult> {
30
- // Static hosts have no platform-driven ISR. Revalidation requires
31
- // a full rebuild + redeploy. Returns `regenerated: false` so user
32
- // code can branch on the no-op shape and degrade gracefully when
33
- // migrating between adapters.
34
- if (process.env.NODE_ENV !== 'production') {
35
- console.warn(
36
- '[Pyreon] staticAdapter.revalidate() is a no-op — static hosts require a full rebuild + redeploy to refresh prerendered pages. Use vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven ISR.',
37
- )
38
- }
39
- return { regenerated: false }
40
- },
41
- }
42
- }
@@ -1,23 +0,0 @@
1
- import type { AdapterBuildOptions } from '../types'
2
-
3
- /**
4
- * Validate that SSR-mode adapter build inputs exist before copying.
5
- * Throws with a clear error message if directories are missing.
6
- *
7
- * SSG-mode passes through unchanged — the SSG branch doesn't need a
8
- * server entry (every page is prerendered) and `outDir` IS the dist
9
- * directory the SSG plugin already populated. Validating it here would
10
- * be redundant.
11
- *
12
- * @internal
13
- */
14
- export async function validateBuildInputs(options: AdapterBuildOptions): Promise<void> {
15
- if (options.kind !== 'ssr') return
16
- const { existsSync } = await import('node:fs')
17
- if (!existsSync(options.clientOutDir)) {
18
- throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`)
19
- }
20
- if (!existsSync(options.serverEntry)) {
21
- throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`)
22
- }
23
- }
@@ -1,182 +0,0 @@
1
- import type { Adapter, AdapterBuildOptions, AdapterRevalidateResult } from '../types'
2
- import { validateBuildInputs } from './validate'
3
- import { warnMissingEnv } from './warn-missing-env'
4
-
5
- /**
6
- * Vercel adapter — generates output for Vercel's Build Output API v3.
7
- *
8
- * Produces a `.vercel/output` directory with:
9
- * - `static/` — client-side assets (JS, CSS, images)
10
- * - `functions/ssr.func/` — serverless function for SSR
11
- * - `config.json` — routing configuration
12
- *
13
- * @example
14
- * ```ts
15
- * // zero.config.ts
16
- * import { defineConfig } from "@pyreon/zero"
17
- *
18
- * export default defineConfig({
19
- * adapter: "vercel",
20
- * })
21
- * ```
22
- */
23
- export function vercelAdapter(): Adapter {
24
- return {
25
- name: 'vercel',
26
- async build(options: AdapterBuildOptions) {
27
- if (options.kind === 'ssg') {
28
- // PR J — SSG branch. Emit Vercel Build Output API v3 STATIC
29
- // variant: `.vercel/output/config.json` listing routes config
30
- // for the prerendered dist; no functions (every page is
31
- // already static). Vercel's deployer reads this config + the
32
- // built dist content as the static asset root — no runtime SSR.
33
- //
34
- // We do NOT copy files into `.vercel/output/static/` — the
35
- // standard Vercel CLI deploy flow detects the dist root
36
- // automatically. Adapters that move files break user-side
37
- // post-build steps (sourcemap upload, perf scripts, custom
38
- // asset handling). Writing config.json alone is the
39
- // minimum-impact signal "this is a prerendered site".
40
- const { writeFile, mkdir } = await import('node:fs/promises')
41
- const { join } = await import('node:path')
42
- const vercelDir = join(options.outDir, '.vercel', 'output')
43
- await mkdir(vercelDir, { recursive: true })
44
- const config = {
45
- version: 3,
46
- routes: [
47
- // Long-cache hashed assets; mirrors the SSR config above.
48
- {
49
- src: '/assets/(.*)',
50
- headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
51
- },
52
- ],
53
- }
54
- await writeFile(join(vercelDir, 'config.json'), JSON.stringify(config, null, 2))
55
- return
56
- }
57
- await validateBuildInputs(options)
58
- const { writeFile, cp, mkdir } = await import('node:fs/promises')
59
- const { join } = await import('node:path')
60
-
61
- const vercelDir = join(options.outDir, '.vercel', 'output')
62
- const staticDir = join(vercelDir, 'static')
63
- const funcDir = join(vercelDir, 'functions', 'ssr.func')
64
-
65
- await mkdir(staticDir, { recursive: true })
66
- await mkdir(funcDir, { recursive: true })
67
-
68
- // Copy client assets to static/
69
- await cp(options.clientOutDir, staticDir, { recursive: true })
70
-
71
- // Copy server build to function directory
72
- await cp(join(options.serverEntry, '..'), funcDir, { recursive: true })
73
-
74
- // Generate serverless function entry.
75
- //
76
- // Pre-fix the handler dynamically imported \`./entry-server.js\` on
77
- // EVERY invocation. Node's module cache makes calls after the
78
- // first one near-free, but the FIRST request on every fresh
79
- // serverless instance (i.e. every cold start) paid the full
80
- // module evaluation cost inside the request budget — observable
81
- // as a TTFB spike on cold starts. Hoisting the import to module
82
- // scope evaluates the SSR module once at function-init time,
83
- // before the first request lands.
84
- //
85
- // Also surface SSR errors to Vercel function logs via
86
- // \`console.error\` (mirrors the cloudflare + netlify fix). Pre-fix
87
- // an unhandled SSR throw propagated to Vercel's launcher (which
88
- // logs it generically); adding our own prefix makes the cause
89
- // trivially greppable in the dashboard log stream.
90
- const funcEntry = `
91
- import handler from "./entry-server.js"
92
-
93
- export default async function vercelHandler(req) {
94
- try {
95
- return await handler(req)
96
- } catch (err) {
97
- console.error("[Pyreon SSR] handler failed:", err)
98
- return new Response("Internal Server Error", { status: 500 })
99
- }
100
- }
101
- `.trimStart()
102
-
103
- await writeFile(join(funcDir, 'index.js'), funcEntry)
104
-
105
- // Function config
106
- await writeFile(
107
- join(funcDir, '.vc-config.json'),
108
- JSON.stringify(
109
- {
110
- runtime: 'nodejs20.x',
111
- handler: 'index.js',
112
- launcherType: 'Nodejs',
113
- },
114
- null,
115
- 2,
116
- ),
117
- )
118
-
119
- // Vercel Build Output config
120
- const config = {
121
- version: 3,
122
- routes: [
123
- // Serve static assets directly
124
- {
125
- src: '/assets/(.*)',
126
- headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
127
- },
128
- // Favicon and manifest
129
- { src: '/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)', dest: '/$1' },
130
- // All other routes → SSR function
131
- { src: '/(.*)', dest: '/ssr' },
132
- ],
133
- }
134
-
135
- await writeFile(join(vercelDir, 'config.json'), JSON.stringify(config, null, 2))
136
- },
137
- async revalidate(path: string): Promise<AdapterRevalidateResult> {
138
- // Vercel ISR API — POST to a deployment-relative
139
- // revalidation endpoint with a secret token. Reads
140
- // `VERCEL_DEPLOYMENT_URL` (auto-injected by Vercel runtime) and
141
- // `VERCEL_REVALIDATE_TOKEN` (user-set in dashboard) from env.
142
- // Mirrors Next.js's `res.revalidate()` shape — a HEAD request
143
- // with the path + token, Vercel rebuilds the page in the
144
- // background and serves stale-while-revalidate to subsequent
145
- // visitors until the rebuild lands.
146
- //
147
- // No `regenerated: true` until Vercel acks 200 — partial-purge
148
- // behaviour (the platform queues the regenerate but doesn't
149
- // confirm it landed) is documented as a "false-positive
150
- // possible" caveat in the Adapter.revalidate JSDoc.
151
- const deploymentUrl = process.env.VERCEL_DEPLOYMENT_URL ?? process.env.VERCEL_URL
152
- const token = process.env.VERCEL_REVALIDATE_TOKEN
153
- if (!deploymentUrl || !token) {
154
- // M2.4 — warn even in production (dedupe per process). Pre-fix the
155
- // warn was DEV-gated, but production is exactly where missing env
156
- // vars surface — CMS triggers revalidate, nothing happens, no
157
- // signal. Now the FIRST call always warns; subsequent calls dedupe.
158
- const missing: string[] = []
159
- if (!deploymentUrl) missing.push('VERCEL_DEPLOYMENT_URL (or VERCEL_URL)')
160
- if (!token) missing.push('VERCEL_REVALIDATE_TOKEN')
161
- return warnMissingEnv(
162
- 'vercel',
163
- missing,
164
- 'Set the token in Vercel project settings → Environment Variables. VERCEL_DEPLOYMENT_URL / VERCEL_URL is auto-injected by the Vercel runtime.',
165
- )
166
- }
167
- const protocol = deploymentUrl.startsWith('http') ? '' : 'https://'
168
- const url = `${protocol}${deploymentUrl}/api/_pyreon-revalidate?path=${encodeURIComponent(path)}&secret=${encodeURIComponent(token)}`
169
- try {
170
- const res = await fetch(url, { method: 'POST' })
171
- return { regenerated: res.ok }
172
- } catch (err) {
173
- if (process.env.NODE_ENV !== 'production') {
174
- console.warn(
175
- `[Pyreon] vercelAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`,
176
- )
177
- }
178
- return { regenerated: false }
179
- }
180
- },
181
- }
182
- }
@@ -1,49 +0,0 @@
1
- import type { AdapterRevalidateResult } from '../types'
2
-
3
- /**
4
- * M2.4 — Loud first-call warning for missing adapter env vars.
5
- *
6
- * Pre-M2.4 the adapter `revalidate()` methods returned `{ regenerated: false }`
7
- * silently in production when required env vars were missing. The dev-mode
8
- * warning was gated on `process.env.NODE_ENV !== 'production'` — exactly the
9
- * env condition that DEPLOYED apps run under, where users would most need
10
- * the signal. Symptom: CMS triggers `adapter.revalidate(path)`, nothing
11
- * happens, no console output, no failure mode reported back to the
12
- * triggering webhook handler. The bug only surfaces when someone notices
13
- * stale content.
14
- *
15
- * Fix: warn ALWAYS (regardless of NODE_ENV) on the FIRST invocation per
16
- * `(adapterName + varSet)` combination. Dedupe via a module-level Set so
17
- * a busy revalidation handler doesn't spam logs — one warn per process
18
- * per missing-env-set is enough to expose the misconfiguration.
19
- *
20
- * Returns the canonical `{ regenerated: false }` so adapters can write
21
- * `return warnMissingEnv(...)` as a one-liner.
22
- *
23
- * @internal Exposed for unit tests via `_internal.warnMissingEnv` (not yet wired) + `_warnedKeys` reset.
24
- */
25
- const _warnedKeys = new Set<string>()
26
-
27
- export function warnMissingEnv(
28
- adapterName: string,
29
- missingVars: readonly string[],
30
- hint: string,
31
- ): AdapterRevalidateResult {
32
- const key = `${adapterName}:${missingVars.join(',')}`
33
- if (!_warnedKeys.has(key)) {
34
- _warnedKeys.add(key)
35
- // oxlint-disable-next-line no-console
36
- console.warn(
37
- `[Pyreon] ${adapterName}Adapter.revalidate() needs ${missingVars.join(' + ')} env var(s). ${hint}`,
38
- )
39
- }
40
- return { regenerated: false }
41
- }
42
-
43
- /**
44
- * Reset the dedup Set. Test-only — production code never reaches this.
45
- * @internal
46
- */
47
- export function _resetWarnedKeys(): void {
48
- _warnedKeys.clear()
49
- }