@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
|
@@ -0,0 +1,2476 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
|
|
2
|
+
import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-Ci0kVmM4.js";
|
|
3
|
+
import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-MewHc5SB.js";
|
|
4
|
+
import { Fragment, createContext, h } from "@pyreon/core";
|
|
5
|
+
import { HeadProvider } from "@pyreon/head";
|
|
6
|
+
import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
|
|
7
|
+
import { createHandler } from "@pyreon/server";
|
|
8
|
+
import { renderToString } from "@pyreon/runtime-server";
|
|
9
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
13
|
+
import { signal } from "@pyreon/reactivity";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
|
|
16
|
+
//#region src/app.ts
|
|
17
|
+
/**
|
|
18
|
+
* Create a full Zero app — assembles router, head provider, and root layout.
|
|
19
|
+
*
|
|
20
|
+
* Used internally by entry-server and entry-client.
|
|
21
|
+
*/
|
|
22
|
+
function createApp(options) {
|
|
23
|
+
const router = createRouter({
|
|
24
|
+
routes: options.routes,
|
|
25
|
+
mode: options.routerMode ?? "history",
|
|
26
|
+
...options.url ? { url: options.url } : {},
|
|
27
|
+
...options.base && options.base !== "/" ? { base: options.base } : {},
|
|
28
|
+
scrollBehavior: "top"
|
|
29
|
+
});
|
|
30
|
+
const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
|
|
31
|
+
if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
|
|
32
|
+
const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
|
|
33
|
+
function App() {
|
|
34
|
+
return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
App,
|
|
38
|
+
router
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function DefaultLayout(props) {
|
|
42
|
+
return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/not-found.ts
|
|
47
|
+
const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
|
|
48
|
+
/**
|
|
49
|
+
* Render a 404 component to a full HTML string.
|
|
50
|
+
* If no component is provided, returns a default 404 page.
|
|
51
|
+
*/
|
|
52
|
+
async function render404Page(component, template) {
|
|
53
|
+
let body;
|
|
54
|
+
if (component) body = await renderToString(h(component, null));
|
|
55
|
+
else body = DEFAULT_404_BODY;
|
|
56
|
+
if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
|
|
57
|
+
return `<!DOCTYPE html>
|
|
58
|
+
<html lang="en">
|
|
59
|
+
<head>
|
|
60
|
+
<meta charset="UTF-8">
|
|
61
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
62
|
+
<title>404 — Not Found</title>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
${body}
|
|
66
|
+
</body>
|
|
67
|
+
</html>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/entry-server.ts
|
|
72
|
+
/**
|
|
73
|
+
* Create a middleware that dispatches per-route middleware based on URL pattern matching.
|
|
74
|
+
*/
|
|
75
|
+
function createRouteMiddlewareDispatcher(entries) {
|
|
76
|
+
return async (ctx) => {
|
|
77
|
+
for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
|
|
78
|
+
const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
|
|
79
|
+
for (const fn of mw) {
|
|
80
|
+
const result = await fn(ctx);
|
|
81
|
+
if (result) return result;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* URL pattern matcher supporting :param and :param* segments.
|
|
88
|
+
*
|
|
89
|
+
* Rules:
|
|
90
|
+
* - Static segments must match exactly
|
|
91
|
+
* - `:param` matches a single path segment
|
|
92
|
+
* - `:param*` matches all remaining segments (must be last, and path must
|
|
93
|
+
* have matched all preceding segments)
|
|
94
|
+
* - Path length must match pattern length (unless catch-all)
|
|
95
|
+
*/
|
|
96
|
+
function matchPattern(pattern, path) {
|
|
97
|
+
const patternParts = pattern.split("/").filter(Boolean);
|
|
98
|
+
const pathParts = path.split("/").filter(Boolean);
|
|
99
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
100
|
+
const pp = patternParts[i];
|
|
101
|
+
if (pp.endsWith("*")) return i <= pathParts.length;
|
|
102
|
+
if (i >= pathParts.length) return false;
|
|
103
|
+
if (pp.startsWith(":")) continue;
|
|
104
|
+
if (pp !== pathParts[i]) return false;
|
|
105
|
+
}
|
|
106
|
+
return patternParts.length === pathParts.length;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create the SSR request handler for production.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* import { routes } from "virtual:zero/routes"
|
|
113
|
+
* import { routeMiddleware } from "virtual:zero/route-middleware"
|
|
114
|
+
* import { createServer } from "@pyreon/zero"
|
|
115
|
+
*
|
|
116
|
+
* export default createServer({ routes, routeMiddleware, apiRoutes })
|
|
117
|
+
*/
|
|
118
|
+
function createServer(options) {
|
|
119
|
+
const config = options.config ?? {};
|
|
120
|
+
const allMiddleware = [];
|
|
121
|
+
if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
|
|
122
|
+
if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
|
|
123
|
+
allMiddleware.push(...config.middleware ?? []);
|
|
124
|
+
allMiddleware.push(...options.middleware ?? []);
|
|
125
|
+
const { App } = createApp({
|
|
126
|
+
routes: options.routes,
|
|
127
|
+
routerMode: "history",
|
|
128
|
+
...config.base && config.base !== "/" ? { base: config.base } : {}
|
|
129
|
+
});
|
|
130
|
+
const handler = createHandler({
|
|
131
|
+
App,
|
|
132
|
+
routes: options.routes,
|
|
133
|
+
middleware: allMiddleware,
|
|
134
|
+
mode: config.ssr?.mode ?? "string",
|
|
135
|
+
...options.template ? { template: options.template } : {},
|
|
136
|
+
...options.clientEntry ? { clientEntry: options.clientEntry } : {}
|
|
137
|
+
});
|
|
138
|
+
if (!options.notFoundComponent) return handler;
|
|
139
|
+
const NotFound = options.notFoundComponent;
|
|
140
|
+
const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
|
|
141
|
+
return async (req) => {
|
|
142
|
+
if (hasRouteTreeNotFound) return handler(req);
|
|
143
|
+
const pathname = new URL(req.url).pathname;
|
|
144
|
+
if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
|
|
145
|
+
const fullHtml = await render404Page(NotFound, options.template);
|
|
146
|
+
return new Response(fullHtml, {
|
|
147
|
+
status: 404,
|
|
148
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return handler(req);
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Walk the route tree looking for any record with a `notFoundComponent`. */
|
|
155
|
+
function routeTreeHasNotFound(routes) {
|
|
156
|
+
for (const r of routes) {
|
|
157
|
+
if (typeof r.notFoundComponent === "function") return true;
|
|
158
|
+
if (r.children && routeTreeHasNotFound(r.children)) return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
/** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
|
|
163
|
+
const _routePatternsCache = /* @__PURE__ */ new WeakMap();
|
|
164
|
+
function routePatternsCache(routes) {
|
|
165
|
+
const cached = _routePatternsCache.get(routes);
|
|
166
|
+
if (cached) return cached;
|
|
167
|
+
const out = flattenRoutePatterns$1(routes);
|
|
168
|
+
_routePatternsCache.set(routes, out);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
/** Extract all URL patterns from a nested route tree. */
|
|
172
|
+
function flattenRoutePatterns$1(routes, prefix = "") {
|
|
173
|
+
const patterns = [];
|
|
174
|
+
for (const route of routes) {
|
|
175
|
+
const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
|
|
176
|
+
patterns.push(fullPath);
|
|
177
|
+
if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
|
|
178
|
+
}
|
|
179
|
+
return patterns;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/config.ts
|
|
184
|
+
/**
|
|
185
|
+
* Define a Zero configuration.
|
|
186
|
+
* Used in `zero.config.ts` at the project root.
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* import { defineConfig } from "@pyreon/zero/config"
|
|
190
|
+
*
|
|
191
|
+
* export default defineConfig({
|
|
192
|
+
* mode: "ssr",
|
|
193
|
+
* ssr: { mode: "stream" },
|
|
194
|
+
* port: 3000,
|
|
195
|
+
* })
|
|
196
|
+
*/
|
|
197
|
+
function defineConfig(config) {
|
|
198
|
+
return config;
|
|
199
|
+
}
|
|
200
|
+
/** Merge user config with defaults. */
|
|
201
|
+
function resolveConfig(userConfig = {}) {
|
|
202
|
+
return {
|
|
203
|
+
mode: "ssr",
|
|
204
|
+
base: "/",
|
|
205
|
+
port: 3e3,
|
|
206
|
+
adapter: "node",
|
|
207
|
+
...userConfig,
|
|
208
|
+
ssr: {
|
|
209
|
+
mode: "string",
|
|
210
|
+
...userConfig.ssr
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/adapters/validate.ts
|
|
217
|
+
/**
|
|
218
|
+
* Validate that SSR-mode adapter build inputs exist before copying.
|
|
219
|
+
* Throws with a clear error message if directories are missing.
|
|
220
|
+
*
|
|
221
|
+
* SSG-mode passes through unchanged — the SSG branch doesn't need a
|
|
222
|
+
* server entry (every page is prerendered) and `outDir` IS the dist
|
|
223
|
+
* directory the SSG plugin already populated. Validating it here would
|
|
224
|
+
* be redundant.
|
|
225
|
+
*
|
|
226
|
+
* @internal
|
|
227
|
+
*/
|
|
228
|
+
async function validateBuildInputs(options) {
|
|
229
|
+
if (options.kind !== "ssr") return;
|
|
230
|
+
const { existsSync } = await import("node:fs");
|
|
231
|
+
if (!existsSync(options.clientOutDir)) throw new Error(`[Pyreon] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
|
|
232
|
+
if (!existsSync(options.serverEntry)) throw new Error(`[Pyreon] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/adapters/bun.ts
|
|
237
|
+
/**
|
|
238
|
+
* Bun adapter — generates a standalone Bun.serve() entry.
|
|
239
|
+
*
|
|
240
|
+
* **SSG mode (PR J)**: no-op. Bun adapter exists for serving the SSR
|
|
241
|
+
* runtime; SSG output is already complete static HTML — serve it with
|
|
242
|
+
* any static-file server (`bun preview` / `bunx serve` / nginx / Caddy).
|
|
243
|
+
* Use `staticAdapter()` if you want explicit SSG semantics.
|
|
244
|
+
*/
|
|
245
|
+
function bunAdapter() {
|
|
246
|
+
return {
|
|
247
|
+
name: "bun",
|
|
248
|
+
async build(options) {
|
|
249
|
+
if (options.kind === "ssg") return;
|
|
250
|
+
await validateBuildInputs(options);
|
|
251
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
252
|
+
const { join } = await import("node:path");
|
|
253
|
+
const outDir = options.outDir;
|
|
254
|
+
await mkdir(outDir, { recursive: true });
|
|
255
|
+
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
256
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
257
|
+
const port = options.config.port ?? 3e3;
|
|
258
|
+
const serverEntry = `
|
|
259
|
+
const handler = (await import("./server/entry-server.js")).default
|
|
260
|
+
const clientDir = new URL("./client/", import.meta.url).pathname
|
|
261
|
+
|
|
262
|
+
Bun.serve({
|
|
263
|
+
port: ${port},
|
|
264
|
+
async fetch(req) {
|
|
265
|
+
const url = new URL(req.url)
|
|
266
|
+
|
|
267
|
+
// Try static files first
|
|
268
|
+
if (req.method === "GET") {
|
|
269
|
+
const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
|
|
270
|
+
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
271
|
+
const resolved = Bun.resolveSync(filePath, ".")
|
|
272
|
+
if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
|
|
273
|
+
return new Response("Forbidden", { status: 403 })
|
|
274
|
+
}
|
|
275
|
+
const file = Bun.file(filePath)
|
|
276
|
+
if (await file.exists()) {
|
|
277
|
+
return new Response(file, {
|
|
278
|
+
headers: {
|
|
279
|
+
"cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
|
|
280
|
+
? "public, max-age=31536000, immutable"
|
|
281
|
+
: "public, max-age=3600",
|
|
282
|
+
},
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Fall through to SSR handler
|
|
288
|
+
return handler(req)
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
293
|
+
`.trimStart();
|
|
294
|
+
await writeFile(join(outDir, "index.ts"), serverEntry);
|
|
295
|
+
},
|
|
296
|
+
async revalidate(_path) {
|
|
297
|
+
if (process.env.NODE_ENV !== "production") console.warn("[Pyreon] bunAdapter.revalidate() is a no-op — self-hosted Bun has no platform-driven ISR. Use mode: \"isr\" for runtime LRU caching, or vercelAdapter / cloudflareAdapter / netlifyAdapter for platform-driven build-time ISR.");
|
|
298
|
+
return { regenerated: false };
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/adapters/warn-missing-env.ts
|
|
305
|
+
/**
|
|
306
|
+
* M2.4 — Loud first-call warning for missing adapter env vars.
|
|
307
|
+
*
|
|
308
|
+
* Pre-M2.4 the adapter `revalidate()` methods returned `{ regenerated: false }`
|
|
309
|
+
* silently in production when required env vars were missing. The dev-mode
|
|
310
|
+
* warning was gated on `process.env.NODE_ENV !== 'production'` — exactly the
|
|
311
|
+
* env condition that DEPLOYED apps run under, where users would most need
|
|
312
|
+
* the signal. Symptom: CMS triggers `adapter.revalidate(path)`, nothing
|
|
313
|
+
* happens, no console output, no failure mode reported back to the
|
|
314
|
+
* triggering webhook handler. The bug only surfaces when someone notices
|
|
315
|
+
* stale content.
|
|
316
|
+
*
|
|
317
|
+
* Fix: warn ALWAYS (regardless of NODE_ENV) on the FIRST invocation per
|
|
318
|
+
* `(adapterName + varSet)` combination. Dedupe via a module-level Set so
|
|
319
|
+
* a busy revalidation handler doesn't spam logs — one warn per process
|
|
320
|
+
* per missing-env-set is enough to expose the misconfiguration.
|
|
321
|
+
*
|
|
322
|
+
* Returns the canonical `{ regenerated: false }` so adapters can write
|
|
323
|
+
* `return warnMissingEnv(...)` as a one-liner.
|
|
324
|
+
*
|
|
325
|
+
* @internal Exposed for unit tests via `_internal.warnMissingEnv` (not yet wired) + `_warnedKeys` reset.
|
|
326
|
+
*/
|
|
327
|
+
const _warnedKeys = /* @__PURE__ */ new Set();
|
|
328
|
+
function warnMissingEnv(adapterName, missingVars, hint) {
|
|
329
|
+
const key = `${adapterName}:${missingVars.join(",")}`;
|
|
330
|
+
if (!_warnedKeys.has(key)) {
|
|
331
|
+
_warnedKeys.add(key);
|
|
332
|
+
console.warn(`[Pyreon] ${adapterName}Adapter.revalidate() needs ${missingVars.join(" + ")} env var(s). ${hint}`);
|
|
333
|
+
}
|
|
334
|
+
return { regenerated: false };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/adapters/cloudflare.ts
|
|
339
|
+
/**
|
|
340
|
+
* Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
|
|
341
|
+
*
|
|
342
|
+
* Produces:
|
|
343
|
+
* - Client assets in the output directory root (served as static)
|
|
344
|
+
* - `_worker.js` — Cloudflare Pages Function for SSR
|
|
345
|
+
*
|
|
346
|
+
* Note: Cloudflare Pages Functions have a ~1MB module size limit.
|
|
347
|
+
* For large apps, configure Vite's SSR build to bundle server code:
|
|
348
|
+
* `ssr: { noExternal: true }` in vite.config.ts.
|
|
349
|
+
*
|
|
350
|
+
* Deploy with: `npx wrangler pages deploy ./dist`
|
|
351
|
+
*
|
|
352
|
+
* @example
|
|
353
|
+
* ```ts
|
|
354
|
+
* // zero.config.ts
|
|
355
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
356
|
+
*
|
|
357
|
+
* export default defineConfig({
|
|
358
|
+
* adapter: "cloudflare",
|
|
359
|
+
* })
|
|
360
|
+
* ```
|
|
361
|
+
*/
|
|
362
|
+
function cloudflareAdapter() {
|
|
363
|
+
return {
|
|
364
|
+
name: "cloudflare",
|
|
365
|
+
async build(options) {
|
|
366
|
+
if (options.kind === "ssg") {
|
|
367
|
+
const { writeFile } = await import("node:fs/promises");
|
|
368
|
+
const { join } = await import("node:path");
|
|
369
|
+
await writeFile(join(options.outDir, "_routes.json"), JSON.stringify({
|
|
370
|
+
version: 1,
|
|
371
|
+
include: [],
|
|
372
|
+
exclude: ["/*"]
|
|
373
|
+
}, null, 2));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
await validateBuildInputs(options);
|
|
377
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
378
|
+
const { join } = await import("node:path");
|
|
379
|
+
const outDir = options.outDir;
|
|
380
|
+
await mkdir(outDir, { recursive: true });
|
|
381
|
+
await cp(options.clientOutDir, outDir, { recursive: true });
|
|
382
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
|
|
383
|
+
const workerEntry = `
|
|
384
|
+
import handler from "./_server/entry-server.js"
|
|
385
|
+
|
|
386
|
+
export default {
|
|
387
|
+
async fetch(request, env, ctx) {
|
|
388
|
+
const url = new URL(request.url)
|
|
389
|
+
|
|
390
|
+
// Let Cloudflare serve static assets (files with extensions)
|
|
391
|
+
// This check is a fallback — Pages routes static files automatically
|
|
392
|
+
const ext = url.pathname.split(".").pop()
|
|
393
|
+
if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
|
|
394
|
+
// Cloudflare Pages handles static assets automatically via its asset binding
|
|
395
|
+
// Only reach here if the file doesn't exist — fall through to SSR
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// SSR handler
|
|
399
|
+
try {
|
|
400
|
+
return await handler(request)
|
|
401
|
+
} catch (err) {
|
|
402
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
`.trimStart();
|
|
407
|
+
await writeFile(join(outDir, "_worker.js"), workerEntry);
|
|
408
|
+
await writeFile(join(outDir, "_routes.json"), JSON.stringify({
|
|
409
|
+
version: 1,
|
|
410
|
+
include: ["/*"],
|
|
411
|
+
exclude: [
|
|
412
|
+
"/assets/*",
|
|
413
|
+
"/favicon.*",
|
|
414
|
+
"/site.webmanifest",
|
|
415
|
+
"/robots.txt",
|
|
416
|
+
"/sitemap.xml"
|
|
417
|
+
]
|
|
418
|
+
}, null, 2));
|
|
419
|
+
},
|
|
420
|
+
async revalidate(path) {
|
|
421
|
+
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
|
|
422
|
+
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
423
|
+
const siteUrl = process.env.CLOUDFLARE_SITE_URL;
|
|
424
|
+
if (!zoneId || !apiToken || !siteUrl) {
|
|
425
|
+
const missing = [];
|
|
426
|
+
if (!zoneId) missing.push("CLOUDFLARE_ZONE_ID");
|
|
427
|
+
if (!apiToken) missing.push("CLOUDFLARE_API_TOKEN");
|
|
428
|
+
if (!siteUrl) missing.push("CLOUDFLARE_SITE_URL");
|
|
429
|
+
return warnMissingEnv("cloudflare", missing, "Set them in Cloudflare Pages dashboard → Settings → Environment Variables. Note: Cloudflare imposes a 1000-purge-per-24h rate limit per zone — high-frequency revalidation will hit it.");
|
|
430
|
+
}
|
|
431
|
+
const fullUrl = `${siteUrl.replace(/\/$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
432
|
+
try {
|
|
433
|
+
return { regenerated: (await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: {
|
|
436
|
+
"Authorization": `Bearer ${apiToken}`,
|
|
437
|
+
"Content-Type": "application/json"
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify({ files: [fullUrl] })
|
|
440
|
+
})).ok };
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] cloudflareAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
443
|
+
return { regenerated: false };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/adapters/netlify.ts
|
|
451
|
+
/**
|
|
452
|
+
* Netlify adapter — generates output for Netlify Functions (v2).
|
|
453
|
+
*
|
|
454
|
+
* Produces:
|
|
455
|
+
* - Client assets in `publish/` directory
|
|
456
|
+
* - `netlify/functions/ssr.mjs` — Netlify Function for SSR
|
|
457
|
+
* - `netlify.toml` — routing configuration
|
|
458
|
+
*
|
|
459
|
+
* @example
|
|
460
|
+
* ```ts
|
|
461
|
+
* // zero.config.ts
|
|
462
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
463
|
+
*
|
|
464
|
+
* export default defineConfig({
|
|
465
|
+
* adapter: "netlify",
|
|
466
|
+
* })
|
|
467
|
+
* ```
|
|
468
|
+
*/
|
|
469
|
+
function netlifyAdapter() {
|
|
470
|
+
return {
|
|
471
|
+
name: "netlify",
|
|
472
|
+
async build(options) {
|
|
473
|
+
if (options.kind === "ssg") {
|
|
474
|
+
const { writeFile } = await import("node:fs/promises");
|
|
475
|
+
const { join } = await import("node:path");
|
|
476
|
+
await writeFile(join(options.outDir, "netlify.toml"), `[build]
|
|
477
|
+
publish = "."
|
|
478
|
+
|
|
479
|
+
[[headers]]
|
|
480
|
+
for = "/assets/*"
|
|
481
|
+
[headers.values]
|
|
482
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
483
|
+
`);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
await validateBuildInputs(options);
|
|
487
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
488
|
+
const { join } = await import("node:path");
|
|
489
|
+
const outDir = options.outDir;
|
|
490
|
+
const publishDir = join(outDir, "publish");
|
|
491
|
+
const functionsDir = join(outDir, "netlify", "functions");
|
|
492
|
+
await mkdir(publishDir, { recursive: true });
|
|
493
|
+
await mkdir(functionsDir, { recursive: true });
|
|
494
|
+
await cp(options.clientOutDir, publishDir, { recursive: true });
|
|
495
|
+
await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
|
|
496
|
+
const funcEntry = `
|
|
497
|
+
import handler from "./_server/entry-server.js"
|
|
498
|
+
|
|
499
|
+
export default async function(req, context) {
|
|
500
|
+
try {
|
|
501
|
+
return await handler(req)
|
|
502
|
+
} catch (err) {
|
|
503
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export const config = {
|
|
508
|
+
path: "/*",
|
|
509
|
+
preferStatic: true,
|
|
510
|
+
}
|
|
511
|
+
`.trimStart();
|
|
512
|
+
await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
|
|
513
|
+
const toml = `
|
|
514
|
+
[build]
|
|
515
|
+
publish = "publish"
|
|
516
|
+
functions = "netlify/functions"
|
|
517
|
+
|
|
518
|
+
[[headers]]
|
|
519
|
+
for = "/assets/*"
|
|
520
|
+
[headers.values]
|
|
521
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
522
|
+
|
|
523
|
+
[[redirects]]
|
|
524
|
+
from = "/*"
|
|
525
|
+
to = "/.netlify/functions/ssr"
|
|
526
|
+
status = 200
|
|
527
|
+
conditions = {Role = ["admin", "user", ""]}
|
|
528
|
+
`.trimStart();
|
|
529
|
+
await writeFile(join(outDir, "netlify.toml"), toml);
|
|
530
|
+
},
|
|
531
|
+
async revalidate(path) {
|
|
532
|
+
const hookUrl = process.env.NETLIFY_BUILD_HOOK_URL;
|
|
533
|
+
if (!hookUrl) return warnMissingEnv("netlify", ["NETLIFY_BUILD_HOOK_URL"], "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.");
|
|
534
|
+
try {
|
|
535
|
+
const triggerTitle = `revalidate:${path}`;
|
|
536
|
+
return { regenerated: (await fetch(`${hookUrl}?trigger_title=${encodeURIComponent(triggerTitle)}`, { method: "POST" })).ok };
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] netlifyAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
539
|
+
return { regenerated: false };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/adapters/node.ts
|
|
547
|
+
/**
|
|
548
|
+
* Node.js adapter — generates a standalone server entry using node:http.
|
|
549
|
+
*
|
|
550
|
+
* **SSG mode (PR J)**: no-op. Node adapter exists for serving the SSR
|
|
551
|
+
* runtime; SSG output is already complete static HTML — serve it with
|
|
552
|
+
* any static-file server (`bun preview` / nginx / Caddy / `npx serve`).
|
|
553
|
+
* Use `staticAdapter()` if you want explicit SSG semantics.
|
|
554
|
+
*/
|
|
555
|
+
function nodeAdapter() {
|
|
556
|
+
return {
|
|
557
|
+
name: "node",
|
|
558
|
+
async build(options) {
|
|
559
|
+
if (options.kind === "ssg") return;
|
|
560
|
+
await validateBuildInputs(options);
|
|
561
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
562
|
+
const { join } = await import("node:path");
|
|
563
|
+
const outDir = options.outDir;
|
|
564
|
+
await mkdir(outDir, { recursive: true });
|
|
565
|
+
await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
|
|
566
|
+
await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
|
|
567
|
+
const port = options.config.port ?? 3e3;
|
|
568
|
+
const serverEntry = `
|
|
569
|
+
import { createServer } from "node:http"
|
|
570
|
+
import { readFile } from "node:fs/promises"
|
|
571
|
+
import { join, extname } from "node:path"
|
|
572
|
+
import { fileURLToPath } from "node:url"
|
|
573
|
+
|
|
574
|
+
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
|
575
|
+
const handler = (await import("./server/entry-server.js")).default
|
|
576
|
+
const clientDir = join(__dirname, "client")
|
|
577
|
+
|
|
578
|
+
const MIME_TYPES = {
|
|
579
|
+
".html": "text/html",
|
|
580
|
+
".js": "application/javascript",
|
|
581
|
+
".css": "text/css",
|
|
582
|
+
".json": "application/json",
|
|
583
|
+
".png": "image/png",
|
|
584
|
+
".jpg": "image/jpeg",
|
|
585
|
+
".svg": "image/svg+xml",
|
|
586
|
+
".woff2": "font/woff2",
|
|
587
|
+
".woff": "font/woff",
|
|
588
|
+
".ico": "image/x-icon",
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const server = createServer(async (req, res) => {
|
|
592
|
+
const url = new URL(req.url ?? "/", "http://localhost")
|
|
593
|
+
|
|
594
|
+
// Try to serve static files first
|
|
595
|
+
if (req.method === "GET") {
|
|
596
|
+
try {
|
|
597
|
+
const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
|
|
598
|
+
// Prevent path traversal — ensure resolved path stays within clientDir
|
|
599
|
+
const { resolve } = await import("node:path")
|
|
600
|
+
const resolved = resolve(filePath)
|
|
601
|
+
if (!resolved.startsWith(resolve(clientDir))) {
|
|
602
|
+
res.writeHead(403)
|
|
603
|
+
res.end("Forbidden")
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
const ext = extname(filePath)
|
|
607
|
+
if (ext && ext !== ".html") {
|
|
608
|
+
const data = await readFile(filePath)
|
|
609
|
+
const mime = MIME_TYPES[ext] || "application/octet-stream"
|
|
610
|
+
res.writeHead(200, {
|
|
611
|
+
"content-type": mime,
|
|
612
|
+
"cache-control": ext === ".js" || ext === ".css"
|
|
613
|
+
? "public, max-age=31536000, immutable"
|
|
614
|
+
: "public, max-age=3600",
|
|
615
|
+
})
|
|
616
|
+
res.end(data)
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
} catch {}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Fall through to SSR handler
|
|
623
|
+
const headers = {}
|
|
624
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
625
|
+
if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const request = new Request(url.href, {
|
|
629
|
+
method: req.method,
|
|
630
|
+
headers,
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
const response = await handler(request)
|
|
634
|
+
const body = await response.text()
|
|
635
|
+
|
|
636
|
+
const responseHeaders = {}
|
|
637
|
+
response.headers.forEach((v, k) => { responseHeaders[k] = v })
|
|
638
|
+
|
|
639
|
+
res.writeHead(response.status, responseHeaders)
|
|
640
|
+
res.end(body)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
server.listen(${port}, () => {
|
|
644
|
+
console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
|
|
645
|
+
})
|
|
646
|
+
`.trimStart();
|
|
647
|
+
await writeFile(join(outDir, "index.js"), serverEntry);
|
|
648
|
+
await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
|
649
|
+
},
|
|
650
|
+
async revalidate(_path) {
|
|
651
|
+
if (process.env.NODE_ENV !== "production") console.warn("[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.");
|
|
652
|
+
return { regenerated: false };
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
//#endregion
|
|
658
|
+
//#region src/adapters/static.ts
|
|
659
|
+
/**
|
|
660
|
+
* Static adapter — just copies the client build output.
|
|
661
|
+
* Used with SSG mode where all pages are pre-rendered at build time.
|
|
662
|
+
*
|
|
663
|
+
* **SSG mode (PR J)**: no-op — `outDir` already IS the dist directory
|
|
664
|
+
* the SSG plugin produced. Copying it onto itself would only fail. The
|
|
665
|
+
* static adapter is the canonical zero-overhead deploy target for
|
|
666
|
+
* pure-static sites.
|
|
667
|
+
*
|
|
668
|
+
* **SSR mode**: copies clientOutDir → outDir. Calling `static` with SSR
|
|
669
|
+
* mode is unusual — the static adapter doesn't support server-side
|
|
670
|
+
* execution — but preserved as a "client-only output packager".
|
|
671
|
+
*/
|
|
672
|
+
function staticAdapter() {
|
|
673
|
+
return {
|
|
674
|
+
name: "static",
|
|
675
|
+
async build(options) {
|
|
676
|
+
if (options.kind === "ssg") return;
|
|
677
|
+
const { cp, mkdir } = await import("node:fs/promises");
|
|
678
|
+
await mkdir(options.outDir, { recursive: true });
|
|
679
|
+
await cp(options.clientOutDir, options.outDir, { recursive: true });
|
|
680
|
+
},
|
|
681
|
+
async revalidate(_path) {
|
|
682
|
+
if (process.env.NODE_ENV !== "production") console.warn("[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.");
|
|
683
|
+
return { regenerated: false };
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/adapters/vercel.ts
|
|
690
|
+
/**
|
|
691
|
+
* Vercel adapter — generates output for Vercel's Build Output API v3.
|
|
692
|
+
*
|
|
693
|
+
* Produces a `.vercel/output` directory with:
|
|
694
|
+
* - `static/` — client-side assets (JS, CSS, images)
|
|
695
|
+
* - `functions/ssr.func/` — serverless function for SSR
|
|
696
|
+
* - `config.json` — routing configuration
|
|
697
|
+
*
|
|
698
|
+
* @example
|
|
699
|
+
* ```ts
|
|
700
|
+
* // zero.config.ts
|
|
701
|
+
* import { defineConfig } from "@pyreon/zero"
|
|
702
|
+
*
|
|
703
|
+
* export default defineConfig({
|
|
704
|
+
* adapter: "vercel",
|
|
705
|
+
* })
|
|
706
|
+
* ```
|
|
707
|
+
*/
|
|
708
|
+
function vercelAdapter() {
|
|
709
|
+
return {
|
|
710
|
+
name: "vercel",
|
|
711
|
+
async build(options) {
|
|
712
|
+
if (options.kind === "ssg") {
|
|
713
|
+
const { writeFile, mkdir } = await import("node:fs/promises");
|
|
714
|
+
const { join } = await import("node:path");
|
|
715
|
+
const vercelDir = join(options.outDir, ".vercel", "output");
|
|
716
|
+
await mkdir(vercelDir, { recursive: true });
|
|
717
|
+
await writeFile(join(vercelDir, "config.json"), JSON.stringify({
|
|
718
|
+
version: 3,
|
|
719
|
+
routes: [{
|
|
720
|
+
src: "/assets/(.*)",
|
|
721
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
722
|
+
}]
|
|
723
|
+
}, null, 2));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
await validateBuildInputs(options);
|
|
727
|
+
const { writeFile, cp, mkdir } = await import("node:fs/promises");
|
|
728
|
+
const { join } = await import("node:path");
|
|
729
|
+
const vercelDir = join(options.outDir, ".vercel", "output");
|
|
730
|
+
const staticDir = join(vercelDir, "static");
|
|
731
|
+
const funcDir = join(vercelDir, "functions", "ssr.func");
|
|
732
|
+
await mkdir(staticDir, { recursive: true });
|
|
733
|
+
await mkdir(funcDir, { recursive: true });
|
|
734
|
+
await cp(options.clientOutDir, staticDir, { recursive: true });
|
|
735
|
+
await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
|
|
736
|
+
const funcEntry = `
|
|
737
|
+
export default async function handler(req) {
|
|
738
|
+
const handler = (await import("./entry-server.js")).default
|
|
739
|
+
return handler(req)
|
|
740
|
+
}
|
|
741
|
+
`.trimStart();
|
|
742
|
+
await writeFile(join(funcDir, "index.js"), funcEntry);
|
|
743
|
+
await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
|
|
744
|
+
runtime: "nodejs20.x",
|
|
745
|
+
handler: "index.js",
|
|
746
|
+
launcherType: "Nodejs"
|
|
747
|
+
}, null, 2));
|
|
748
|
+
await writeFile(join(vercelDir, "config.json"), JSON.stringify({
|
|
749
|
+
version: 3,
|
|
750
|
+
routes: [
|
|
751
|
+
{
|
|
752
|
+
src: "/assets/(.*)",
|
|
753
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" }
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
|
|
757
|
+
dest: "/$1"
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
src: "/(.*)",
|
|
761
|
+
dest: "/ssr"
|
|
762
|
+
}
|
|
763
|
+
]
|
|
764
|
+
}, null, 2));
|
|
765
|
+
},
|
|
766
|
+
async revalidate(path) {
|
|
767
|
+
const deploymentUrl = process.env.VERCEL_DEPLOYMENT_URL ?? process.env.VERCEL_URL;
|
|
768
|
+
const token = process.env.VERCEL_REVALIDATE_TOKEN;
|
|
769
|
+
if (!deploymentUrl || !token) {
|
|
770
|
+
const missing = [];
|
|
771
|
+
if (!deploymentUrl) missing.push("VERCEL_DEPLOYMENT_URL (or VERCEL_URL)");
|
|
772
|
+
if (!token) missing.push("VERCEL_REVALIDATE_TOKEN");
|
|
773
|
+
return warnMissingEnv("vercel", missing, "Set the token in Vercel project settings → Environment Variables. VERCEL_DEPLOYMENT_URL / VERCEL_URL is auto-injected by the Vercel runtime.");
|
|
774
|
+
}
|
|
775
|
+
const url = `${deploymentUrl.startsWith("http") ? "" : "https://"}${deploymentUrl}/api/_pyreon-revalidate?path=${encodeURIComponent(path)}&secret=${encodeURIComponent(token)}`;
|
|
776
|
+
try {
|
|
777
|
+
return { regenerated: (await fetch(url, { method: "POST" })).ok };
|
|
778
|
+
} catch (err) {
|
|
779
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[Pyreon] vercelAdapter.revalidate(${path}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
780
|
+
return { regenerated: false };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
//#endregion
|
|
787
|
+
//#region src/adapters/index.ts
|
|
788
|
+
/**
|
|
789
|
+
* Resolve the adapter from config.
|
|
790
|
+
* Returns a built-in adapter or throws if unknown.
|
|
791
|
+
*
|
|
792
|
+
* Accepts BOTH forms — the `ZeroConfig.adapter` type advertises string
|
|
793
|
+
* names (`'vercel'` / `'cloudflare'` / …) but the scaffolded templates
|
|
794
|
+
* historically emit `adapter: vercelAdapter()` (an Adapter instance via
|
|
795
|
+
* the named factory). Both work: a string goes through the switch lookup;
|
|
796
|
+
* an Adapter object (duck-typed via `name` + `build` fields) passes
|
|
797
|
+
* through. Pre-PR-J `resolveAdapter` was never called from production
|
|
798
|
+
* code so the string-vs-object mismatch was invisible; PR J wires the
|
|
799
|
+
* call into `ssgPlugin.closeBundle`, surfacing the contract divergence.
|
|
800
|
+
* The passthrough preserves both shapes without a breaking type change.
|
|
801
|
+
*/
|
|
802
|
+
function resolveAdapter(config) {
|
|
803
|
+
const value = config.adapter ?? "node";
|
|
804
|
+
if (typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.build === "function") return value;
|
|
805
|
+
switch (value) {
|
|
806
|
+
case "node": return nodeAdapter();
|
|
807
|
+
case "bun": return bunAdapter();
|
|
808
|
+
case "static": return staticAdapter();
|
|
809
|
+
case "vercel": return vercelAdapter();
|
|
810
|
+
case "cloudflare": return cloudflareAdapter();
|
|
811
|
+
case "netlify": return netlifyAdapter();
|
|
812
|
+
default: throw new Error(`[Pyreon] Unknown adapter: "${String(value)}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/error-overlay.ts
|
|
818
|
+
/**
|
|
819
|
+
* Dev-only error overlay for SSR/loader errors.
|
|
820
|
+
* Renders a styled HTML page with the error stack trace.
|
|
821
|
+
*/
|
|
822
|
+
function renderErrorOverlay(error) {
|
|
823
|
+
return `<!DOCTYPE html>
|
|
824
|
+
<html lang="en">
|
|
825
|
+
<head>
|
|
826
|
+
<meta charset="UTF-8">
|
|
827
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
828
|
+
<title>SSR Error — Pyreon Zero</title>
|
|
829
|
+
<style>
|
|
830
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
831
|
+
body {
|
|
832
|
+
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
|
|
833
|
+
background: #1a1a2e;
|
|
834
|
+
color: #e0e0e0;
|
|
835
|
+
min-height: 100vh;
|
|
836
|
+
padding: 2rem;
|
|
837
|
+
}
|
|
838
|
+
.overlay {
|
|
839
|
+
max-width: 900px;
|
|
840
|
+
margin: 0 auto;
|
|
841
|
+
}
|
|
842
|
+
.header {
|
|
843
|
+
display: flex;
|
|
844
|
+
align-items: center;
|
|
845
|
+
gap: 0.75rem;
|
|
846
|
+
margin-bottom: 1.5rem;
|
|
847
|
+
}
|
|
848
|
+
.badge {
|
|
849
|
+
background: #e74c3c;
|
|
850
|
+
color: white;
|
|
851
|
+
padding: 0.25rem 0.75rem;
|
|
852
|
+
border-radius: 4px;
|
|
853
|
+
font-size: 0.75rem;
|
|
854
|
+
font-weight: 600;
|
|
855
|
+
text-transform: uppercase;
|
|
856
|
+
letter-spacing: 0.05em;
|
|
857
|
+
}
|
|
858
|
+
.label {
|
|
859
|
+
color: #888;
|
|
860
|
+
font-size: 0.85rem;
|
|
861
|
+
}
|
|
862
|
+
.message {
|
|
863
|
+
font-size: 1.25rem;
|
|
864
|
+
color: #ff6b6b;
|
|
865
|
+
margin-bottom: 1.5rem;
|
|
866
|
+
line-height: 1.5;
|
|
867
|
+
word-break: break-word;
|
|
868
|
+
}
|
|
869
|
+
.stack {
|
|
870
|
+
background: #16213e;
|
|
871
|
+
border: 1px solid #2a2a4a;
|
|
872
|
+
border-radius: 8px;
|
|
873
|
+
padding: 1.25rem;
|
|
874
|
+
overflow-x: auto;
|
|
875
|
+
font-size: 0.8rem;
|
|
876
|
+
line-height: 1.7;
|
|
877
|
+
white-space: pre-wrap;
|
|
878
|
+
word-break: break-all;
|
|
879
|
+
}
|
|
880
|
+
.stack .at { color: #888; }
|
|
881
|
+
.stack .file { color: #4ecdc4; }
|
|
882
|
+
.hint {
|
|
883
|
+
margin-top: 1.5rem;
|
|
884
|
+
padding: 1rem;
|
|
885
|
+
background: #1e2a45;
|
|
886
|
+
border-radius: 6px;
|
|
887
|
+
border-left: 3px solid #3498db;
|
|
888
|
+
font-size: 0.8rem;
|
|
889
|
+
color: #aaa;
|
|
890
|
+
line-height: 1.5;
|
|
891
|
+
}
|
|
892
|
+
</style>
|
|
893
|
+
</head>
|
|
894
|
+
<body>
|
|
895
|
+
<div class="overlay">
|
|
896
|
+
<div class="header">
|
|
897
|
+
<span class="badge">SSR Error</span>
|
|
898
|
+
<span class="label">Pyreon Zero — Dev Mode</span>
|
|
899
|
+
</div>
|
|
900
|
+
<div class="message">${escapeHtml(error.message || "Unknown error")}</div>
|
|
901
|
+
<pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
|
|
902
|
+
<div class="hint">
|
|
903
|
+
This error occurred during server-side rendering. Check the terminal for
|
|
904
|
+
the full stack trace. This overlay is only shown in development.
|
|
905
|
+
</div>
|
|
906
|
+
</div>
|
|
907
|
+
</body>
|
|
908
|
+
</html>`;
|
|
909
|
+
}
|
|
910
|
+
function escapeHtml(str) {
|
|
911
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
912
|
+
}
|
|
913
|
+
function formatStack(stack) {
|
|
914
|
+
return stack.split("\n").map((line) => {
|
|
915
|
+
if (line.includes("at ")) {
|
|
916
|
+
const fileMatch = line.match(/\(([^)]+)\)/);
|
|
917
|
+
if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
|
|
918
|
+
}
|
|
919
|
+
return line;
|
|
920
|
+
}).join("\n");
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
//#endregion
|
|
924
|
+
//#region src/i18n-routing.ts
|
|
925
|
+
/**
|
|
926
|
+
* Detect preferred locale from Accept-Language header.
|
|
927
|
+
*/
|
|
928
|
+
function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
|
|
929
|
+
if (!acceptLanguage) return defaultLocale;
|
|
930
|
+
const preferred = acceptLanguage.split(",").map((part) => {
|
|
931
|
+
const [lang, q] = part.trim().split(";q=");
|
|
932
|
+
return {
|
|
933
|
+
lang: lang?.split("-")[0]?.toLowerCase() ?? "",
|
|
934
|
+
quality: q ? Number.parseFloat(q) : 1
|
|
935
|
+
};
|
|
936
|
+
}).sort((a, b) => b.quality - a.quality);
|
|
937
|
+
for (const { lang } of preferred) if (locales.includes(lang)) return lang;
|
|
938
|
+
return defaultLocale;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Extract locale from a URL path.
|
|
942
|
+
* Returns { locale, pathWithoutLocale }.
|
|
943
|
+
*/
|
|
944
|
+
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
945
|
+
const segments = path.split("/").filter(Boolean);
|
|
946
|
+
const firstSegment = segments[0]?.toLowerCase();
|
|
947
|
+
if (firstSegment && locales.includes(firstSegment)) return {
|
|
948
|
+
locale: firstSegment,
|
|
949
|
+
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
950
|
+
};
|
|
951
|
+
return {
|
|
952
|
+
locale: defaultLocale,
|
|
953
|
+
pathWithoutLocale: path
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Build a localized path.
|
|
958
|
+
*/
|
|
959
|
+
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
960
|
+
const clean = path === "/" ? "" : path;
|
|
961
|
+
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
962
|
+
return `/${locale}${clean}`;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Fan a `FileRoute[]` into per-locale duplicates so the file-system router
|
|
966
|
+
* knows about every localized URL pattern at build time. PR H — was the
|
|
967
|
+
* missing half of the i18n story before this PR (the `i18nRouting()` Vite
|
|
968
|
+
* plugin only handled request-time locale detection; routes themselves
|
|
969
|
+
* were never duplicated, so static-host SSG outputs and SSR matching had
|
|
970
|
+
* no `/de/about` / `/cs/about` records to render against).
|
|
971
|
+
*
|
|
972
|
+
* Strategy semantics:
|
|
973
|
+
*
|
|
974
|
+
* - **`prefix-except-default`** (default): the default locale's routes
|
|
975
|
+
* keep their original `urlPath` unchanged (`/about` stays `/about`); all
|
|
976
|
+
* non-default locales get a prefix (`/de/about`, `/cs/about`). Best for
|
|
977
|
+
* SEO-on-default-locale apps — search engines see canonical URLs at
|
|
978
|
+
* `/about` while non-default speakers get explicit prefixes.
|
|
979
|
+
*
|
|
980
|
+
* - **`prefix`**: every locale gets its own prefix, including the default
|
|
981
|
+
* (`/en/about`, `/de/about`, `/cs/about`). Root `/` becomes `/en` /
|
|
982
|
+
* `/de` / `/cs`. Better when no locale is "primary" — every URL
|
|
983
|
+
* self-identifies its locale.
|
|
984
|
+
*
|
|
985
|
+
* Layouts, error boundaries, loading components, and 404 pages duplicate
|
|
986
|
+
* along with their pages — same source file (same `filePath`), new
|
|
987
|
+
* locale-prefixed `urlPath` / `dirPath` / `depth`. The route tree built
|
|
988
|
+
* from the expanded array therefore has one fully-formed subtree per
|
|
989
|
+
* locale, so layout matching, dynamic params (`[id]` → `:id`), and
|
|
990
|
+
* catch-all routes (`[...slug]` → `:slug*`) all compose naturally with
|
|
991
|
+
* the locale prefix — no special cases.
|
|
992
|
+
*
|
|
993
|
+
* `getStaticPaths` composition (for SSG): each duplicate route inherits
|
|
994
|
+
* the same `exports.getStaticPaths`. The SSG plugin's `expandUrlPattern`
|
|
995
|
+
* step then expands `/blog/[slug]` × `[en, de]` × `getStaticPaths()
|
|
996
|
+
* → ['a', 'b']` into `/blog/a`, `/blog/b`, `/de/blog/a`, `/de/blog/b`
|
|
997
|
+
* (or all six prefixed forms under `'prefix'` strategy). Cardinality
|
|
998
|
+
* compounds, which is by design — `ssg.concurrency` (PR D) limits
|
|
999
|
+
* in-flight renders independent of route count.
|
|
1000
|
+
*
|
|
1001
|
+
* No-op when `config.locales` is empty or contains only the default
|
|
1002
|
+
* locale (prefix-except-default strategy with no other locales) — returns
|
|
1003
|
+
* the input array unchanged. Always return a fresh array on duplication
|
|
1004
|
+
* so callers don't accidentally mutate cached input.
|
|
1005
|
+
*
|
|
1006
|
+
* Reference: the helper is called from `vite-plugin.ts`'s virtual route
|
|
1007
|
+
* module load AND `ssg-plugin.ts`'s pre-render path expansion. Tested in
|
|
1008
|
+
* isolation — duplication is a pure transform on FileRoute[] with no
|
|
1009
|
+
* filesystem or network side effects.
|
|
1010
|
+
*/
|
|
1011
|
+
function expandRoutesForLocales(routes, config) {
|
|
1012
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
1013
|
+
const { locales, defaultLocale } = config;
|
|
1014
|
+
if (locales.length === 0) return routes;
|
|
1015
|
+
for (const locale of locales) validateLocale(locale);
|
|
1016
|
+
validateLocale(defaultLocale);
|
|
1017
|
+
if (strategy === "prefix-except-default" && locales.length === 1 && locales[0] === defaultLocale) return routes;
|
|
1018
|
+
const expanded = [];
|
|
1019
|
+
for (const route of routes) for (const locale of locales) {
|
|
1020
|
+
if (strategy === "prefix-except-default" && locale === defaultLocale) {
|
|
1021
|
+
expanded.push(route);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
if (strategy === "prefix-except-default" && route.isLayout && route.urlPath === "/") continue;
|
|
1025
|
+
const newUrlPath = prefixUrlPath(route.urlPath, locale);
|
|
1026
|
+
const newDirPath = route.dirPath === "" ? locale : `${locale}/${route.dirPath}`;
|
|
1027
|
+
const newDepth = newUrlPath === "/" ? 0 : newUrlPath.split("/").filter(Boolean).length;
|
|
1028
|
+
expanded.push({
|
|
1029
|
+
...route,
|
|
1030
|
+
urlPath: newUrlPath,
|
|
1031
|
+
dirPath: newDirPath,
|
|
1032
|
+
depth: newDepth
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
return expanded;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Prepend `/locale` to a URL pattern. Handles three shapes:
|
|
1039
|
+
* `/` → `/de`
|
|
1040
|
+
* `/about` → `/de/about`
|
|
1041
|
+
* `/users/:id` / `/blog/:slug*` → `/de/users/:id` / `/de/blog/:slug*`
|
|
1042
|
+
*
|
|
1043
|
+
* Internal helper to `expandRoutesForLocales`; not exported because the
|
|
1044
|
+
* public surface for path-building is `buildLocalePath` (which strips
|
|
1045
|
+
* existing locale prefixes — different semantics).
|
|
1046
|
+
*/
|
|
1047
|
+
function prefixUrlPath(urlPath, locale) {
|
|
1048
|
+
if (urlPath === "/") return `/${locale}`;
|
|
1049
|
+
return `/${locale}${urlPath}`;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Validate a locale string (PR L2).
|
|
1053
|
+
*
|
|
1054
|
+
* The locale drives both URL pattern emission AND filesystem writes
|
|
1055
|
+
* (see `expandRoutesForLocales` for full rationale). Reject input that
|
|
1056
|
+
* would either:
|
|
1057
|
+
* - break path-traversal boundaries (`..`, `/`, `\`)
|
|
1058
|
+
* - produce invalid URL segments (whitespace, NUL)
|
|
1059
|
+
* - create hidden-file artifacts (`.` leading)
|
|
1060
|
+
* - silently kill the app (empty string)
|
|
1061
|
+
*
|
|
1062
|
+
* Throws with an actionable `[Pyreon]` error message. Called per-locale
|
|
1063
|
+
* by `expandRoutesForLocales` after the empty-locales no-op guard.
|
|
1064
|
+
*
|
|
1065
|
+
* @internal — exported for unit testing.
|
|
1066
|
+
*/
|
|
1067
|
+
function validateLocale(locale) {
|
|
1068
|
+
if (typeof locale !== "string" || locale === "") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Locales must be non-empty strings (e.g. "en", "de", "en-US").`);
|
|
1069
|
+
if (locale.trim() !== locale) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading or trailing whitespace not allowed.`);
|
|
1070
|
+
if (locale.includes("/") || locale.includes("\\")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path separators ("/", "\\\\") not allowed — they would break URL emission and could write outside the dist directory.`);
|
|
1071
|
+
if (locale === ".." || locale === ".") throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Path-traversal segments not allowed.`);
|
|
1072
|
+
if (locale.startsWith(".")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. Leading dot not allowed — it would create a hidden-file directory (\`dist/.${locale.slice(1)}/\`) invisible to most file listings.`);
|
|
1073
|
+
if (locale.includes("\0")) throw new Error(`[Pyreon] Invalid i18n locale: ${JSON.stringify(locale)}. NUL characters not allowed.`);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Create a LocaleContext for use in components and loaders.
|
|
1077
|
+
*/
|
|
1078
|
+
function createLocaleContext(locale, path, config) {
|
|
1079
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
1080
|
+
return {
|
|
1081
|
+
locale,
|
|
1082
|
+
locales: config.locales,
|
|
1083
|
+
defaultLocale: config.defaultLocale,
|
|
1084
|
+
localePath(targetPath, targetLocale) {
|
|
1085
|
+
return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
|
|
1086
|
+
},
|
|
1087
|
+
alternates() {
|
|
1088
|
+
const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
|
|
1089
|
+
return config.locales.map((loc) => ({
|
|
1090
|
+
locale: loc,
|
|
1091
|
+
url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* I18n routing middleware for Zero's server.
|
|
1098
|
+
*
|
|
1099
|
+
* - Detects locale from URL prefix or Accept-Language header
|
|
1100
|
+
* - Redirects root to preferred locale (when detectLocale is true)
|
|
1101
|
+
* - Sets locale context for loaders and components
|
|
1102
|
+
*
|
|
1103
|
+
* @example
|
|
1104
|
+
* ```ts
|
|
1105
|
+
* // zero.config.ts
|
|
1106
|
+
* import { i18nRouting } from "@pyreon/zero"
|
|
1107
|
+
*
|
|
1108
|
+
* export default defineConfig({
|
|
1109
|
+
* plugins: [
|
|
1110
|
+
* i18nRouting({
|
|
1111
|
+
* locales: ["en", "de", "cs"],
|
|
1112
|
+
* defaultLocale: "en",
|
|
1113
|
+
* }),
|
|
1114
|
+
* ],
|
|
1115
|
+
* })
|
|
1116
|
+
* ```
|
|
1117
|
+
*/
|
|
1118
|
+
function i18nRouting(config) {
|
|
1119
|
+
const strategy = config.strategy ?? "prefix-except-default";
|
|
1120
|
+
const detectEnabled = config.detectLocale !== false;
|
|
1121
|
+
const cookieName = config.cookieName ?? "locale";
|
|
1122
|
+
return {
|
|
1123
|
+
name: "pyreon-zero-i18n-routing",
|
|
1124
|
+
configResolved() {},
|
|
1125
|
+
configureServer(server) {
|
|
1126
|
+
server.middlewares.use((req, res, next) => {
|
|
1127
|
+
const url = req.url ?? "/";
|
|
1128
|
+
if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
|
|
1129
|
+
const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
|
|
1130
|
+
if (detectEnabled && url === "/") {
|
|
1131
|
+
const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
|
|
1132
|
+
const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
|
|
1133
|
+
const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
|
|
1134
|
+
if (strategy === "prefix" || preferred !== config.defaultLocale) {
|
|
1135
|
+
res.writeHead(302, { Location: `/${preferred}/` });
|
|
1136
|
+
res.end();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
req.__locale = locale;
|
|
1141
|
+
req.__localeContext = createLocaleContext(locale, url, config);
|
|
1142
|
+
localeSignal.set(locale);
|
|
1143
|
+
next();
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
function parseCookies(header) {
|
|
1149
|
+
if (!header) return {};
|
|
1150
|
+
const result = {};
|
|
1151
|
+
for (const pair of header.split(";")) {
|
|
1152
|
+
const [key, value] = pair.trim().split("=");
|
|
1153
|
+
if (key && value) result[key] = decodeURIComponent(value);
|
|
1154
|
+
}
|
|
1155
|
+
return result;
|
|
1156
|
+
}
|
|
1157
|
+
/** @internal Context for the current locale. */
|
|
1158
|
+
const LocaleCtx = createContext("en");
|
|
1159
|
+
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
1160
|
+
const localeSignal = signal("en");
|
|
1161
|
+
|
|
1162
|
+
//#endregion
|
|
1163
|
+
//#region src/ssg-plugin.ts
|
|
1164
|
+
/**
|
|
1165
|
+
* SSG (Static Site Generation) build hook for `@pyreon/zero`.
|
|
1166
|
+
*
|
|
1167
|
+
* Activates when `mode: "ssg"` is set in zero's config. After Vite's client
|
|
1168
|
+
* build finishes, this plugin:
|
|
1169
|
+
*
|
|
1170
|
+
* 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
|
|
1171
|
+
* a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
|
|
1172
|
+
* that imports `virtual:zero/routes` and `createServer`.
|
|
1173
|
+
* 2. Loads the built handler with dynamic `import()`.
|
|
1174
|
+
* 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
|
|
1175
|
+
* or auto-detected from the static-only routes in the route tree).
|
|
1176
|
+
* 4. Calls `prerender()` from `@pyreon/server` to render each path.
|
|
1177
|
+
* 5. Cleans up the temporary SSR build directory.
|
|
1178
|
+
*
|
|
1179
|
+
* Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
|
|
1180
|
+
* `types.ts` but had no runtime implementation — the plugin file had zero
|
|
1181
|
+
* Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
|
|
1182
|
+
* shell with no per-route HTML files, which broke direct-URL deploys to
|
|
1183
|
+
* static hosts (no `dist/<path>/index.html`, every URL falls back to the
|
|
1184
|
+
* SPA index).
|
|
1185
|
+
*/
|
|
1186
|
+
const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
|
|
1187
|
+
const _countSink = globalThis;
|
|
1188
|
+
const SSG_BUILD_FLAG = "PYREON_ZERO_SSG_INNER_BUILD";
|
|
1189
|
+
const renderSsrEntrySource = (locales = []) => {
|
|
1190
|
+
return `
|
|
1191
|
+
import { routes } from "virtual:zero/routes"
|
|
1192
|
+
import { h } from "@pyreon/core"
|
|
1193
|
+
import { renderWithHead } from "@pyreon/head/ssr"
|
|
1194
|
+
import { getRedirectInfo, serializeLoaderData, stringifyLoaderData } from "@pyreon/router"
|
|
1195
|
+
import { runWithRequestContext } from "@pyreon/runtime-server"
|
|
1196
|
+
import { createApp } from "@pyreon/zero/server"
|
|
1197
|
+
|
|
1198
|
+
// Lazy-imported styler integration. Projects that don't depend on
|
|
1199
|
+
// @pyreon/styler skip this entirely (the import fails silently, the
|
|
1200
|
+
// helper stays a no-op). Hot path: an awaited dynamic import resolved
|
|
1201
|
+
// once at entry-module evaluation, then sync calls per request.
|
|
1202
|
+
//
|
|
1203
|
+
// **No reset between paths.** @pyreon/styler's styled() inserts CSS
|
|
1204
|
+
// rules into sheet.ssrBuffer at MODULE-EVAL TIME (top-level of styled.ts:95),
|
|
1205
|
+
// not per-render. After that initial insert, each render of a styled
|
|
1206
|
+
// component just attaches the cached class name to props — no new buffer
|
|
1207
|
+
// push. Calling sheet.reset() between SSG paths would WIPE all rules and
|
|
1208
|
+
// leave subsequent pages style-less. For SSG this is acceptable: the
|
|
1209
|
+
// generated CSS is identical across all pages (same module-eval cache),
|
|
1210
|
+
// and shipping the full rule set in every page's <style> tag matches
|
|
1211
|
+
// how static SSG sites handle CSS — every page is self-contained,
|
|
1212
|
+
// cacheable by the browser, no per-route CSS code splitting needed.
|
|
1213
|
+
let __pyreonGetStylerTag = () => ""
|
|
1214
|
+
try {
|
|
1215
|
+
const stylerMod = await import("@pyreon/styler")
|
|
1216
|
+
if (stylerMod && stylerMod.sheet && typeof stylerMod.sheet.getStyleTag === "function") {
|
|
1217
|
+
__pyreonGetStylerTag = () => stylerMod.sheet.getStyleTag()
|
|
1218
|
+
}
|
|
1219
|
+
} catch {
|
|
1220
|
+
// No @pyreon/styler in the project — leave the no-op stub in place.
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// PR E — \`__ZERO_BASE__\` is the Vite-defined build-time constant
|
|
1224
|
+
// carrying the value of \`zero({ base })\`. Read it once at module
|
|
1225
|
+
// eval and forward to createRouter via createApp so SSG-rendered
|
|
1226
|
+
// pages have correctly-prefixed RouterLink hrefs that match the
|
|
1227
|
+
// asset URLs Vite already prefixed in the built HTML template.
|
|
1228
|
+
const __ssgBase = typeof __ZERO_BASE__ !== "undefined" && __ZERO_BASE__ !== "/"
|
|
1229
|
+
? __ZERO_BASE__
|
|
1230
|
+
: undefined
|
|
1231
|
+
|
|
1232
|
+
export default async function renderPath(path, options) {
|
|
1233
|
+
const { App, router } = createApp({
|
|
1234
|
+
routes,
|
|
1235
|
+
routerMode: "history",
|
|
1236
|
+
url: path,
|
|
1237
|
+
...(__ssgBase ? { base: __ssgBase } : {}),
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
// PR B — redirect handling. \`router.preload\` runs every loader in the
|
|
1241
|
+
// matched chain and surfaces \`redirect()\` throws as the rejection
|
|
1242
|
+
// reason. We catch BEFORE the render: rendering past a redirect would
|
|
1243
|
+
// produce HTML for the wrong page AND leak the auth-gated layout
|
|
1244
|
+
// structure for unauthenticated users. The runtime SSR handler
|
|
1245
|
+
// (createHandler in @pyreon/server) already does this same catch and
|
|
1246
|
+
// returns a 302/307 Location response; SSG mirrors that — return
|
|
1247
|
+
// \`{ kind: 'redirect', from, to, status }\` instead of HTML, and the
|
|
1248
|
+
// outer plugin emits a redirect manifest entry instead of an
|
|
1249
|
+
// \`index.html\`. Any non-redirect error rethrows and lands in the
|
|
1250
|
+
// existing \`errors[]\` collection.
|
|
1251
|
+
//
|
|
1252
|
+
// PR C — \`isNotFound\` skips parent-layout loaders during the 404 build.
|
|
1253
|
+
// Layout loaders that hit auth resources (cookies, session tokens,
|
|
1254
|
+
// private APIs) shouldn't fire when generating a static 404 page —
|
|
1255
|
+
// the build has no real request context. Lazy components still
|
|
1256
|
+
// resolve so the synthetic chain renders cleanly; only the
|
|
1257
|
+
// \`r.loader()\` invocations are skipped. \`__renderNotFound\` below
|
|
1258
|
+
// forwards \`{ isNotFound: true }\` for this path.
|
|
1259
|
+
try {
|
|
1260
|
+
await router.preload(path, undefined, {
|
|
1261
|
+
skipLoaders: options?.isNotFound === true,
|
|
1262
|
+
})
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
const info = getRedirectInfo(err)
|
|
1265
|
+
if (info) {
|
|
1266
|
+
return { kind: "redirect", from: path, to: info.url, status: info.status }
|
|
1267
|
+
}
|
|
1268
|
+
throw err
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
return runWithRequestContext(async () => {
|
|
1272
|
+
const app = h(App, null)
|
|
1273
|
+
const { html: appHtml, head } = await renderWithHead(app)
|
|
1274
|
+
|
|
1275
|
+
// Inject styler's <style data-pyreon-styler="..."> tag into the head
|
|
1276
|
+
// BEFORE @pyreon/head's tags so the CSS cascade orders correctly with
|
|
1277
|
+
// any meta/link tags the user added. Empty buffer emits a benign
|
|
1278
|
+
// empty <style></style> — detected via the literal closing pair to
|
|
1279
|
+
// avoid polluting the head when no styler is in use.
|
|
1280
|
+
const styleTag = __pyreonGetStylerTag()
|
|
1281
|
+
const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
|
|
1282
|
+
const finalHead = isEmpty ? head : styleTag + "\\n" + head
|
|
1283
|
+
|
|
1284
|
+
const loaderData = serializeLoaderData(router)
|
|
1285
|
+
const hasData = loaderData && Object.keys(loaderData).length > 0
|
|
1286
|
+
// M2.2 — safe serializer drops function/symbol values, throws a clear
|
|
1287
|
+
// Pyreon-prefixed error on circular refs, escapes <\/script> uniformly.
|
|
1288
|
+
const loaderScript = hasData
|
|
1289
|
+
? \`<script>window.__PYREON_LOADER_DATA__=\${stringifyLoaderData(loaderData)}<\/script>\`
|
|
1290
|
+
: ""
|
|
1291
|
+
return { kind: "html", appHtml, head: finalHead, loaderScript }
|
|
1292
|
+
})
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ─── getStaticPaths enumeration (PR A) ──────────────────────────────────────
|
|
1296
|
+
//
|
|
1297
|
+
// Walks the generated routes tree and collects every dynamic route's
|
|
1298
|
+
// \`getStaticPaths\` function alongside its URL pattern. The SSG plugin
|
|
1299
|
+
// calls this once before rendering and uses the returned map to expand
|
|
1300
|
+
// dynamic routes (\`/posts/:id\` × \`[{id:'a'},{id:'b'}]\` → \`/posts/a\`,
|
|
1301
|
+
// \`/posts/b\`). Routes without \`getStaticPaths\` are absent from the map.
|
|
1302
|
+
//
|
|
1303
|
+
// Why we collect ALL routes here instead of resolving on-demand: the
|
|
1304
|
+
// SSG plugin's \`resolvePaths\` runs in the OUTER Vite plugin context (no
|
|
1305
|
+
// access to the bundled routes module). The SSR sub-build is the only
|
|
1306
|
+
// place where the user's compiled route exports are reachable, so we
|
|
1307
|
+
// expose a sync collector that lets the plugin call user functions
|
|
1308
|
+
// indirectly via the entry's exports.
|
|
1309
|
+
function collectStaticPathsRegistry(rs, out) {
|
|
1310
|
+
for (const r of rs) {
|
|
1311
|
+
if (typeof r.getStaticPaths === "function" && typeof r.path === "string") {
|
|
1312
|
+
out.set(r.path, r.getStaticPaths)
|
|
1313
|
+
}
|
|
1314
|
+
if (Array.isArray(r.children)) collectStaticPathsRegistry(r.children, out)
|
|
1315
|
+
}
|
|
1316
|
+
return out
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Map of \`urlPath → getStaticPaths function\` for every dynamic route. */
|
|
1320
|
+
export const __getStaticPathsRegistry = collectStaticPathsRegistry(routes, new Map())
|
|
1321
|
+
|
|
1322
|
+
// ─── 404 emission (PR C) ────────────────────────────────────────────────────
|
|
1323
|
+
//
|
|
1324
|
+
// Locales the build was configured for (PR H: \`zero({ i18n: { locales } })\`).
|
|
1325
|
+
// Injected as a JSON-literal by the outer plugin so the walker can detect
|
|
1326
|
+
// which RouteRecord serves which locale by matching its \`path\` against
|
|
1327
|
+
// the \`/\${locale}\` / \`/\${locale}/*\` prefix. Empty array = no i18n,
|
|
1328
|
+
// single-default-locale shape, walker collects exactly one entry keyed
|
|
1329
|
+
// by \`null\` and the closeBundle writes a single \`dist/404.html\`.
|
|
1330
|
+
const __i18nLocales = ${JSON.stringify(locales)}
|
|
1331
|
+
|
|
1332
|
+
// Walk the route tree and return ALL \`notFoundComponent\` references,
|
|
1333
|
+
// keyed by which locale subtree they were found in (or \`null\` for the
|
|
1334
|
+
// default / no-i18n case). fs-router attaches \`_404.tsx\` to its parent
|
|
1335
|
+
// layout's RouteRecord (or to each page record when no wrapping layout
|
|
1336
|
+
// exists — which is the per-locale subtree shape under PR H's root-
|
|
1337
|
+
// layout-skip). The walker collects the FIRST match per locale via
|
|
1338
|
+
// depth-first traversal: the per-locale subtree's \`notFoundComponent\`
|
|
1339
|
+
// wins over the root's for that locale.
|
|
1340
|
+
//
|
|
1341
|
+
// Locale detection: a RouteRecord serves locale \`X\` if its \`path\`
|
|
1342
|
+
// matches \`/X\` or starts with \`/X/\`. The default-locale entry
|
|
1343
|
+
// (under \`prefix-except-default\` strategy) is keyed by \`null\` since
|
|
1344
|
+
// its path doesn't carry a locale prefix.
|
|
1345
|
+
function findNotFoundComponentsByLocale(rs, currentLocale) {
|
|
1346
|
+
const result = new Map()
|
|
1347
|
+
function walk(records, ambient) {
|
|
1348
|
+
for (const r of records) {
|
|
1349
|
+
const path = typeof r.path === "string" ? r.path : ""
|
|
1350
|
+
let locale = ambient
|
|
1351
|
+
for (const l of __i18nLocales) {
|
|
1352
|
+
if (path === \`/\${l}\` || path.startsWith(\`/\${l}/\`)) {
|
|
1353
|
+
locale = l
|
|
1354
|
+
break
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (typeof r.notFoundComponent === "function") {
|
|
1358
|
+
if (!result.has(locale)) result.set(locale, r.notFoundComponent)
|
|
1359
|
+
}
|
|
1360
|
+
if (Array.isArray(r.children)) walk(r.children, locale)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
walk(rs, currentLocale)
|
|
1364
|
+
return result
|
|
1365
|
+
}
|
|
1366
|
+
export const __notFoundComponentsByLocale = findNotFoundComponentsByLocale(routes, null)
|
|
1367
|
+
|
|
1368
|
+
// Back-compat: legacy single-export, picks up whatever the walker
|
|
1369
|
+
// classified as \`null\`-locale (default-locale or non-i18n root 404).
|
|
1370
|
+
// External callers (none currently in main, but downstream consumers
|
|
1371
|
+
// that imported this export pre-PR) keep working.
|
|
1372
|
+
export const __notFoundComponent = __notFoundComponentsByLocale.get(null) ?? null
|
|
1373
|
+
|
|
1374
|
+
// Render the not-found component THROUGH the router (PR L5). We navigate
|
|
1375
|
+
// to a synthetic non-matching probe URL per locale — \`resolveRoute\`
|
|
1376
|
+
// (post-L5) walks the route tree finding the deepest parent
|
|
1377
|
+
// \`notFoundComponent\` and builds a matched chain
|
|
1378
|
+
// \`[...ancestorLayouts, syntheticLeaf]\`. The leaf carries the
|
|
1379
|
+
// not-found component; rendering through the normal pipeline produces
|
|
1380
|
+
// HTML WITH layout chrome — same headers, footers, navigation as
|
|
1381
|
+
// regular pages. The locale prefix in the probe URL ensures the right
|
|
1382
|
+
// per-locale layout subtree matches (under PR H's prefix strategy).
|
|
1383
|
+
//
|
|
1384
|
+
// Pre-L5 behavior (\`h(component, null)\` standalone) is preserved as a
|
|
1385
|
+
// fallback when the route tree has \`notFoundComponent\` at the root
|
|
1386
|
+
// but no \`isNotFound\` chain forms (older route shapes). The outer
|
|
1387
|
+
// plugin checks \`__notFoundComponentsByLocale\` first to gate emission
|
|
1388
|
+
// — if no notFoundComponent exists anywhere, \`__renderNotFound\` is
|
|
1389
|
+
// never called.
|
|
1390
|
+
export async function __renderNotFound(locale) {
|
|
1391
|
+
// Probe URL chosen to be highly improbable as a real route. The
|
|
1392
|
+
// suffix is deliberately literal (no \`Math.random\`) so build
|
|
1393
|
+
// outputs are deterministic across runs.
|
|
1394
|
+
const probePath = locale == null
|
|
1395
|
+
? "/__pyreon_not_found_probe__"
|
|
1396
|
+
: \`/\${locale}/__pyreon_not_found_probe__\`
|
|
1397
|
+
|
|
1398
|
+
// Try the router-driven path first. If the route tree has a parent
|
|
1399
|
+
// \`notFoundComponent\` reachable from the probe URL, resolveRoute's
|
|
1400
|
+
// fallback builds the chain through the layout and the normal render
|
|
1401
|
+
// pipeline produces 404 HTML wrapped in layout chrome.
|
|
1402
|
+
//
|
|
1403
|
+
// PR C — pass \`isNotFound: true\` so renderPath skips parent-layout
|
|
1404
|
+
// loaders. Layout loaders that hit auth resources / external APIs
|
|
1405
|
+
// shouldn't fire when generating a static 404 page (the build has
|
|
1406
|
+
// no real request context). Lazy components still resolve; only
|
|
1407
|
+
// \`r.loader()\` invocations are skipped.
|
|
1408
|
+
const result = await renderPath(probePath, { isNotFound: true })
|
|
1409
|
+
if (result && result.kind === "html") {
|
|
1410
|
+
return {
|
|
1411
|
+
appHtml: result.appHtml,
|
|
1412
|
+
head: result.head,
|
|
1413
|
+
loaderScript: result.loaderScript,
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Fallback for tree shapes where the resolver returns empty matched
|
|
1418
|
+
// (no notFoundComponent walkable from this probe path). Render the
|
|
1419
|
+
// component standalone — same shape pre-L5.
|
|
1420
|
+
const component = locale == null
|
|
1421
|
+
? (__notFoundComponentsByLocale.get(null) ?? __notFoundComponent)
|
|
1422
|
+
: __notFoundComponentsByLocale.get(locale)
|
|
1423
|
+
if (typeof component !== "function") return null
|
|
1424
|
+
|
|
1425
|
+
return runWithRequestContext(async () => {
|
|
1426
|
+
const vnode = h(component, null)
|
|
1427
|
+
const { html: appHtml, head } = await renderWithHead(vnode)
|
|
1428
|
+
|
|
1429
|
+
const styleTag = __pyreonGetStylerTag()
|
|
1430
|
+
const isEmpty = !styleTag || styleTag.indexOf("></style>") !== -1
|
|
1431
|
+
const finalHead = isEmpty ? head : styleTag + "\\n" + head
|
|
1432
|
+
|
|
1433
|
+
return { appHtml, head: finalHead, loaderScript: "" }
|
|
1434
|
+
})
|
|
1435
|
+
}
|
|
1436
|
+
`.trimStart();
|
|
1437
|
+
};
|
|
1438
|
+
const SSR_ENTRY_FILENAME = "__pyreon-zero-ssg-entry.js";
|
|
1439
|
+
/**
|
|
1440
|
+
* Substitute concrete values for `:param` / `:param*` segments in a URL
|
|
1441
|
+
* pattern. Mirrors the inverse of `filePathToUrlPath`.
|
|
1442
|
+
*
|
|
1443
|
+
* /posts/:id × { id: 'a' } → /posts/a
|
|
1444
|
+
* /posts/:id/:slug × { id: 'a', slug: 'b' } → /posts/a/b
|
|
1445
|
+
* /blog/:rest* × { rest: 'a/b' } → /blog/a/b (catch-all preserves slashes)
|
|
1446
|
+
*
|
|
1447
|
+
* Missing params or empty values throw — the SSG plugin treats this as a
|
|
1448
|
+
* `getStaticPaths` error and records it in the per-path errors array.
|
|
1449
|
+
*/
|
|
1450
|
+
function expandUrlPattern(pattern, params) {
|
|
1451
|
+
return pattern.split("/").map((seg) => {
|
|
1452
|
+
if (!seg.startsWith(":")) return seg;
|
|
1453
|
+
const name = seg.endsWith("*") ? seg.slice(1, -1) : seg.slice(1);
|
|
1454
|
+
const value = params[name];
|
|
1455
|
+
if (value === void 0 || value === "") throw new Error(`[zero:ssg] getStaticPaths for "${pattern}" returned params without "${name}"`);
|
|
1456
|
+
return value;
|
|
1457
|
+
}).join("/");
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Auto-detect static paths from the route tree AND expand dynamic routes
|
|
1461
|
+
* via each route's `getStaticPaths` export (when present). A "static" path
|
|
1462
|
+
* is one with NO dynamic segments (`[id]`, `[...rest]`); a "dynamic" path
|
|
1463
|
+
* with `getStaticPaths` is expanded via the registry; remaining dynamic
|
|
1464
|
+
* routes are silently skipped (the user must hand-list them in
|
|
1465
|
+
* `ssg.paths`).
|
|
1466
|
+
*/
|
|
1467
|
+
async function autoDetectStaticPaths(routesDir, registry, errors = [], i18n) {
|
|
1468
|
+
if (!existsSync(routesDir)) return ["/"];
|
|
1469
|
+
const baseRoutes = parseFileRoutes(await scanRouteFiles(routesDir));
|
|
1470
|
+
const fileRoutes = i18n ? expandRoutesForLocales(baseRoutes, i18n) : baseRoutes;
|
|
1471
|
+
const out = [];
|
|
1472
|
+
for (const r of fileRoutes) {
|
|
1473
|
+
if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue;
|
|
1474
|
+
const path = r.urlPath;
|
|
1475
|
+
if (!path) continue;
|
|
1476
|
+
if (!/[:*]/.test(path)) {
|
|
1477
|
+
out.push(path);
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
const enumerator = registry?.get(path);
|
|
1481
|
+
if (!enumerator) continue;
|
|
1482
|
+
try {
|
|
1483
|
+
const result = await enumerator();
|
|
1484
|
+
if (!Array.isArray(result)) throw new Error(`getStaticPaths for "${path}" must return an array, got ${typeof result}`);
|
|
1485
|
+
for (const entry of result) {
|
|
1486
|
+
if (!entry || typeof entry !== "object" || !entry.params) throw new Error(`getStaticPaths for "${path}" returned an entry without "params"`);
|
|
1487
|
+
out.push(expandUrlPattern(path, entry.params));
|
|
1488
|
+
}
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
errors.push({
|
|
1491
|
+
path,
|
|
1492
|
+
error
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return out.length > 0 ? out : ["/"];
|
|
1497
|
+
}
|
|
1498
|
+
async function resolvePaths(config, routesDir, registry, errors = []) {
|
|
1499
|
+
const explicit = config.ssg?.paths;
|
|
1500
|
+
if (typeof explicit === "function") {
|
|
1501
|
+
const result = await explicit();
|
|
1502
|
+
return Array.isArray(result) ? result : [];
|
|
1503
|
+
}
|
|
1504
|
+
if (Array.isArray(explicit)) return explicit;
|
|
1505
|
+
return autoDetectStaticPaths(routesDir, registry, errors, config.i18n);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Write `content` to `target` atomically: write to a sibling temp file first,
|
|
1509
|
+
* then `rename` into place. Rename is an atomic syscall on POSIX (and Windows
|
|
1510
|
+
* for same-volume renames) — readers either see the OLD content or the FULL
|
|
1511
|
+
* new content, never a half-written file.
|
|
1512
|
+
*
|
|
1513
|
+
* M2.1 — Use this for manifests that adapters consume (`_redirects`,
|
|
1514
|
+
* `_pyreon-ssg-paths.json`, `_pyreon-revalidate.json`, etc.). A SIGINT during
|
|
1515
|
+
* a sequential plain-`writeFile` chain in `closeBundle` would leave partial
|
|
1516
|
+
* state: half the manifests pointing at the new render, half the old. Atomic
|
|
1517
|
+
* writes mean each manifest is independently consistent — readers see either
|
|
1518
|
+
* the old build's manifest or the new build's, never a mix.
|
|
1519
|
+
*
|
|
1520
|
+
* Per-page HTML writes (`dist/<path>/index.html`) intentionally do NOT use
|
|
1521
|
+
* this — they're individually-readable files (no cross-file invariants), and
|
|
1522
|
+
* the rename-per-page cost on 10k-path sites would be significant.
|
|
1523
|
+
*
|
|
1524
|
+
* Temp filename embeds `pid + perf-counter` so concurrent runs (e.g. CI
|
|
1525
|
+
* pipelines that fight over the same dist) don't collide. The tmp file is
|
|
1526
|
+
* cleaned up in a finally block — even if the rename fails, no orphaned
|
|
1527
|
+
* `.tmp.*` files leak.
|
|
1528
|
+
*
|
|
1529
|
+
* @internal Exposed via `_internal.writeFileAtomic` for unit tests.
|
|
1530
|
+
*/
|
|
1531
|
+
let _atomicSeq = 0;
|
|
1532
|
+
async function writeFileAtomic(target, content) {
|
|
1533
|
+
const tmp = `${target}.tmp.${process.pid}.${++_atomicSeq}`;
|
|
1534
|
+
try {
|
|
1535
|
+
await writeFile(tmp, content);
|
|
1536
|
+
await rename(tmp, target);
|
|
1537
|
+
} catch (err) {
|
|
1538
|
+
try {
|
|
1539
|
+
await unlink(tmp);
|
|
1540
|
+
} catch {}
|
|
1541
|
+
throw err;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Build the per-locale breakdown summary string for the closeBundle log.
|
|
1546
|
+
*
|
|
1547
|
+
* M2.5 — Computes per-locale path counts from `writtenPaths` by checking
|
|
1548
|
+
* each path's leading segment against the configured locale list. Paths
|
|
1549
|
+
* with no locale prefix go to the default locale (under
|
|
1550
|
+
* `prefix-except-default`) or are skipped (under `prefix`, where every
|
|
1551
|
+
* locale carries an explicit prefix — unprefixed paths are unexpected).
|
|
1552
|
+
*
|
|
1553
|
+
* Returns `` ` [en: 100, de: 100, cs: 100]` `` (with leading space) for
|
|
1554
|
+
* pretty concatenation into the summary line, or empty string when i18n
|
|
1555
|
+
* is unconfigured / writtenPaths is empty.
|
|
1556
|
+
*
|
|
1557
|
+
* @internal Exposed via `_internal.buildLocaleSummary` for unit tests.
|
|
1558
|
+
*/
|
|
1559
|
+
function buildLocaleSummary(writtenPaths, i18n) {
|
|
1560
|
+
if (writtenPaths.length === 0 || i18n.locales.length === 0) return "";
|
|
1561
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1562
|
+
for (const locale of i18n.locales) counts.set(locale, 0);
|
|
1563
|
+
const defaultLocale = i18n.defaultLocale ?? i18n.locales[0] ?? "";
|
|
1564
|
+
const strategy = i18n.strategy ?? "prefix-except-default";
|
|
1565
|
+
for (const p of writtenPaths) {
|
|
1566
|
+
const firstSeg = p.split("/")[1];
|
|
1567
|
+
if (firstSeg && counts.has(firstSeg)) counts.set(firstSeg, (counts.get(firstSeg) ?? 0) + 1);
|
|
1568
|
+
else if (strategy === "prefix-except-default" && defaultLocale) counts.set(defaultLocale, (counts.get(defaultLocale) ?? 0) + 1);
|
|
1569
|
+
}
|
|
1570
|
+
const parts = [];
|
|
1571
|
+
for (const locale of i18n.locales) parts.push(`${locale}: ${counts.get(locale) ?? 0}`);
|
|
1572
|
+
return ` [${parts.join(", ")}]`;
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Detect duplicate URLs in the resolved-paths list. Returns the duplicates
|
|
1576
|
+
* (sorted, unique). Empty array = no collisions.
|
|
1577
|
+
*
|
|
1578
|
+
* The render loop's `writtenPaths.push(p)` would silently last-wins on
|
|
1579
|
+
* duplicates — two routes producing the same URL would have one's HTML
|
|
1580
|
+
* overwrite the other's with no error. Catching the collision before
|
|
1581
|
+
* render makes the conflict visible at the source-of-truth (the routes
|
|
1582
|
+
* tree), not at the symptom (mysterious HTML drift between rebuilds).
|
|
1583
|
+
*
|
|
1584
|
+
* @internal Exposed via `_internal.detectPathCollisions` for unit tests.
|
|
1585
|
+
*/
|
|
1586
|
+
function detectPathCollisions(paths) {
|
|
1587
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1588
|
+
const duplicates = /* @__PURE__ */ new Set();
|
|
1589
|
+
for (const p of paths) {
|
|
1590
|
+
if (seen.has(p)) duplicates.add(p);
|
|
1591
|
+
seen.add(p);
|
|
1592
|
+
}
|
|
1593
|
+
return [...duplicates].sort();
|
|
1594
|
+
}
|
|
1595
|
+
/** Format a path-collision error message with actionable guidance. */
|
|
1596
|
+
/**
|
|
1597
|
+
* Wiring helper: run the collision detector + throw with the formatted error
|
|
1598
|
+
* when any collisions are found. The closeBundle handler calls this between
|
|
1599
|
+
* `resolvePaths` and the render loop. Factored out so unit tests can exercise
|
|
1600
|
+
* the full "detect → throw" path without spinning up a Vite SSR sub-build.
|
|
1601
|
+
*
|
|
1602
|
+
* @internal Exposed via `_internal.assertNoPathCollisions` for unit tests.
|
|
1603
|
+
*/
|
|
1604
|
+
function assertNoPathCollisions(paths) {
|
|
1605
|
+
const collisions = detectPathCollisions(paths);
|
|
1606
|
+
if (collisions.length > 0) throw new Error(formatPathCollisionError(collisions));
|
|
1607
|
+
}
|
|
1608
|
+
function formatPathCollisionError(duplicates) {
|
|
1609
|
+
const list = duplicates.map((p) => ` - ${p}`).join("\n");
|
|
1610
|
+
return `[Pyreon] SSG path collision — ${duplicates.length} URL(s) resolved by multiple routes:\n${list}\nThis happens when a static route + getStaticPaths return overlap, or two getStaticPaths enumerators produce the same URL. Inspect your routes tree and ensure each URL is produced by exactly one route.`;
|
|
1611
|
+
}
|
|
1612
|
+
function resolveOutputPath(distDir, path) {
|
|
1613
|
+
if (path === "/") return join(distDir, "index.html");
|
|
1614
|
+
if (path.endsWith(".html")) return join(distDir, path);
|
|
1615
|
+
return join(distDir, path, "index.html");
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Render Netlify / Cloudflare Pages `_redirects` file content. One line
|
|
1619
|
+
* per redirect, format: `<from> <to> <status>`. Both platforms parse this
|
|
1620
|
+
* format identically; Vercel ignores it (use the JSON below). Lines with
|
|
1621
|
+
* leading `#` are comments — included so the file is self-documenting in
|
|
1622
|
+
* a deploy log.
|
|
1623
|
+
*/
|
|
1624
|
+
function renderNetlifyRedirects(entries) {
|
|
1625
|
+
if (entries.length === 0) return "";
|
|
1626
|
+
const lines = ["# Auto-generated by @pyreon/zero SSG. Do not edit."];
|
|
1627
|
+
for (const e of entries) lines.push(`${e.from} ${e.to} ${e.status}`);
|
|
1628
|
+
return `${lines.join("\n")}\n`;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Render Vercel `_redirects.json` content. Vercel reads this from the
|
|
1632
|
+
* `vercel.json` `redirects` array shape — but the bare `_redirects.json`
|
|
1633
|
+
* file ships alongside as documentation / fallback for adapters that
|
|
1634
|
+
* read either format. The 308/301/302/307 status maps to Vercel's
|
|
1635
|
+
* `permanent: true|false` boolean (308/301 → permanent; 302/307 →
|
|
1636
|
+
* temporary).
|
|
1637
|
+
*/
|
|
1638
|
+
function renderVercelRedirectsJson(entries) {
|
|
1639
|
+
return `${JSON.stringify({ redirects: entries.map((e) => ({
|
|
1640
|
+
source: e.from,
|
|
1641
|
+
destination: e.to,
|
|
1642
|
+
permanent: e.status === 301 || e.status === 308,
|
|
1643
|
+
statusCode: e.status
|
|
1644
|
+
})) }, null, 2)}\n`;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Render a meta-refresh HTML stub for static hosts that don't read
|
|
1648
|
+
* `_redirects` (plain S3, GitHub Pages, simple file servers). The
|
|
1649
|
+
* `<meta http-equiv="refresh" content="0; url=…">` triggers a
|
|
1650
|
+
* client-side refresh; the canonical link is for SEO so search
|
|
1651
|
+
* engines de-dupe the source path against the target. Status code
|
|
1652
|
+
* has no HTML equivalent — a meta-refresh is always "client-side."
|
|
1653
|
+
*/
|
|
1654
|
+
function renderMetaRefreshHtml(target) {
|
|
1655
|
+
const escaped = target.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1656
|
+
return `<!DOCTYPE html>
|
|
1657
|
+
<html>
|
|
1658
|
+
<head>
|
|
1659
|
+
<meta charset="utf-8">
|
|
1660
|
+
<meta http-equiv="refresh" content="0; url=${escaped}">
|
|
1661
|
+
<link rel="canonical" href="${escaped}">
|
|
1662
|
+
<title>Redirecting to ${escaped}</title>
|
|
1663
|
+
</head>
|
|
1664
|
+
<body>
|
|
1665
|
+
<p>Redirecting to <a href="${escaped}">${escaped}</a>...</p>
|
|
1666
|
+
</body>
|
|
1667
|
+
</html>
|
|
1668
|
+
`;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Serialize the captured render-loop errors as a stable JSON artifact.
|
|
1672
|
+
* Each entry has `{ path, message, name, stack }`. Errors that aren't
|
|
1673
|
+
* `Error` instances (e.g. a loader that threw a string) are coerced via
|
|
1674
|
+
* `String()` for `message`; `name` falls back to `'Error'`; `stack` is
|
|
1675
|
+
* `undefined` (omitted from JSON output).
|
|
1676
|
+
*
|
|
1677
|
+
* Wrapped in `{ errors: [...] }` rather than emitted as a bare array so
|
|
1678
|
+
* future fields (timing, build metadata) can be added without breaking
|
|
1679
|
+
* existing CI consumers. Pretty-printed with 2-space indent — the file
|
|
1680
|
+
* is meant to be read both by tooling AND humans diagnosing a failed
|
|
1681
|
+
* build, so byte-density is not the priority.
|
|
1682
|
+
*/
|
|
1683
|
+
/**
|
|
1684
|
+
* Drain `items` through `concurrency` parallel workers, calling
|
|
1685
|
+
* `processItem(item)` on each and `onSettled(item, idx)` once each
|
|
1686
|
+
* settles (regardless of resolve/reject). The work-stealing pattern
|
|
1687
|
+
* (each worker pulls from a shared `nextIdx++` cursor) keeps load
|
|
1688
|
+
* balanced even when individual `processItem` calls vary widely in
|
|
1689
|
+
* duration — a fast item doesn't make its worker idle until the slowest
|
|
1690
|
+
* peer finishes.
|
|
1691
|
+
*
|
|
1692
|
+
* Settle ordering: `onSettled` fires in the order items finish, NOT in
|
|
1693
|
+
* input order. `idx` is the index into `items` (same identity across
|
|
1694
|
+
* `processItem` and `onSettled`), useful for "completed N of M"
|
|
1695
|
+
* progress reporting.
|
|
1696
|
+
*
|
|
1697
|
+
* Concurrency clamping: ≤ 0 inputs ARE clamped to 1 — the worker pool
|
|
1698
|
+
* is meaningless without at least one worker, but a value of `0` from
|
|
1699
|
+
* a misconfiguration shouldn't silently hang. The actual worker count
|
|
1700
|
+
* is `min(concurrency, items.length)` so a 2-item list with concurrency
|
|
1701
|
+
* 10 only spawns 2 workers (no idle workers spawned).
|
|
1702
|
+
*
|
|
1703
|
+
* Errors from `processItem` are NOT caught here — callers must handle
|
|
1704
|
+
* exceptions inside `processItem` (the SSG path does so via try/catch
|
|
1705
|
+
* in `renderOne`). Errors from `onSettled` likewise propagate; in the
|
|
1706
|
+
* SSG path the caller wraps it to record into `errors[]`. We don't
|
|
1707
|
+
* silently swallow because that would hide real bugs.
|
|
1708
|
+
*
|
|
1709
|
+
* Atomic operations under Node's single-threaded JS: `nextIdx++` is
|
|
1710
|
+
* atomic — workers never observe a partial increment, so two workers
|
|
1711
|
+
* never claim the same index. The pool relies on this invariant; do
|
|
1712
|
+
* NOT port to a multi-threaded runtime without revisiting.
|
|
1713
|
+
*/
|
|
1714
|
+
async function runWithConcurrency(items, concurrency, processItem, onSettled) {
|
|
1715
|
+
const workerCount = Math.min(Math.max(1, concurrency), items.length);
|
|
1716
|
+
if (workerCount === 0) return;
|
|
1717
|
+
let nextIdx = 0;
|
|
1718
|
+
const worker = async () => {
|
|
1719
|
+
while (true) {
|
|
1720
|
+
const idx = nextIdx++;
|
|
1721
|
+
if (idx >= items.length) return;
|
|
1722
|
+
const item = items[idx];
|
|
1723
|
+
await processItem(item, idx);
|
|
1724
|
+
if (onSettled) await onSettled(item, idx);
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* PR I — build the revalidate manifest from scanned FileRoutes + the
|
|
1731
|
+
* list of paths that successfully rendered.
|
|
1732
|
+
*
|
|
1733
|
+
* For each FileRoute with a `revalidateLiteral` (captured at scan time
|
|
1734
|
+
* via `detectRouteExports`), parse the literal as JSON (numbers and
|
|
1735
|
+
* `false` are valid JSON tokens), build a regex from the route's
|
|
1736
|
+
* urlPath pattern, and match against `writtenPaths`. Each matching
|
|
1737
|
+
* concrete path goes into the manifest under the route's revalidate
|
|
1738
|
+
* value.
|
|
1739
|
+
*
|
|
1740
|
+
* Returns `{}` when no routes have a revalidate literal — the caller
|
|
1741
|
+
* checks `Object.keys(...).length > 0` before writing the manifest.
|
|
1742
|
+
*
|
|
1743
|
+
* Static routes match exactly (urlPath === concretePath). Dynamic
|
|
1744
|
+
* routes (`/posts/:id`) compile to `^\/posts\/[^/]+$`. Catch-alls
|
|
1745
|
+
* (`/blog/:slug*`) compile to `^\/blog\/.*$`. Layout / error / loading
|
|
1746
|
+
* / not-found routes are skipped — they don't appear in writtenPaths
|
|
1747
|
+
* anyway, but the explicit guard keeps the helper stand-alone-testable.
|
|
1748
|
+
*
|
|
1749
|
+
* Exposed via `_internal.buildRevalidateManifest` so it can be unit-
|
|
1750
|
+
* tested without a full SSG round-trip.
|
|
1751
|
+
*/
|
|
1752
|
+
function buildRevalidateManifest(fileRoutes, writtenPaths) {
|
|
1753
|
+
const manifest = {};
|
|
1754
|
+
for (const route of fileRoutes) {
|
|
1755
|
+
if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue;
|
|
1756
|
+
const literal = route.exports?.revalidateLiteral;
|
|
1757
|
+
if (literal === void 0) continue;
|
|
1758
|
+
let parsed;
|
|
1759
|
+
try {
|
|
1760
|
+
parsed = JSON.parse(literal);
|
|
1761
|
+
} catch {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
if (typeof parsed !== "number" && parsed !== false) continue;
|
|
1765
|
+
const value = parsed;
|
|
1766
|
+
const matcher = compileUrlPatternMatcher(route.urlPath);
|
|
1767
|
+
for (const concretePath of writtenPaths) if (matcher(concretePath)) manifest[concretePath] = value;
|
|
1768
|
+
}
|
|
1769
|
+
return manifest;
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Compile a route's urlPath pattern (`/posts/:id`, `/blog/:slug*`,
|
|
1773
|
+
* `/about`) into a predicate that returns `true` for any concrete
|
|
1774
|
+
* path that matches. Static patterns return a `===` comparator.
|
|
1775
|
+
* Dynamic / catch-all patterns return a regex predicate.
|
|
1776
|
+
*
|
|
1777
|
+
* Internal helper to `buildRevalidateManifest`. Mirrors the routing
|
|
1778
|
+
* matcher's behaviour for `:param` (single segment) and `:param*`
|
|
1779
|
+
* (catch-all, zero-or-more segments). Doesn't need to handle every
|
|
1780
|
+
* router edge case — the writtenPaths it matches against are already
|
|
1781
|
+
* concrete (no params, no wildcards).
|
|
1782
|
+
*/
|
|
1783
|
+
function compileUrlPatternMatcher(urlPath) {
|
|
1784
|
+
if (!urlPath.includes(":") && !urlPath.includes("*")) return (concrete) => concrete === urlPath;
|
|
1785
|
+
const regex = urlPath.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/:([A-Za-z_$][\w$]*)\*/g, ".*").replace(/:([A-Za-z_$][\w$]*)/g, "[^/]+");
|
|
1786
|
+
const re = new RegExp(`^${regex}$`);
|
|
1787
|
+
return (concrete) => re.test(concrete);
|
|
1788
|
+
}
|
|
1789
|
+
function renderErrorArtifact(entries) {
|
|
1790
|
+
const errors = entries.map(({ path, error }) => ({
|
|
1791
|
+
path,
|
|
1792
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1793
|
+
name: error instanceof Error ? error.name : "Error",
|
|
1794
|
+
stack: error instanceof Error ? error.stack : void 0
|
|
1795
|
+
}));
|
|
1796
|
+
return `${JSON.stringify({ errors }, null, 2)}\n`;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* Inject a rendered SSR result into the index.html template. Prefers
|
|
1800
|
+
* Pyreon's `<!--pyreon-head-->` / `<!--pyreon-app-->` /
|
|
1801
|
+
* `<!--pyreon-scripts-->` placeholders; falls back to inserting before
|
|
1802
|
+
* `</head>` / inside `<div id="app">` / before `</body>` so a bare
|
|
1803
|
+
* Vite-style `index.html` (no Pyreon comments) still receives content.
|
|
1804
|
+
*
|
|
1805
|
+
* Factored out of the per-path render loop so the 404 emission path can
|
|
1806
|
+
* reuse the exact same injection rules — keeps the rendered _404.tsx
|
|
1807
|
+
* subject to the same head/body/scripts pipeline as regular pages
|
|
1808
|
+
* (styler tag, @pyreon/head meta, hashed asset preload links).
|
|
1809
|
+
*/
|
|
1810
|
+
function injectIntoTemplate(template, result) {
|
|
1811
|
+
let html = template;
|
|
1812
|
+
if (html.includes("<!--pyreon-head-->")) html = html.replace("<!--pyreon-head-->", result.head);
|
|
1813
|
+
else if (result.head) html = html.replace("</head>", `${result.head}</head>`);
|
|
1814
|
+
if (html.includes("<!--pyreon-app-->")) html = html.replace("<!--pyreon-app-->", result.appHtml);
|
|
1815
|
+
else if (result.appHtml) {
|
|
1816
|
+
const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/);
|
|
1817
|
+
if (appDivMatch) html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`);
|
|
1818
|
+
else html = html.replace("</body>", `<div id="app">${result.appHtml}</div></body>`);
|
|
1819
|
+
}
|
|
1820
|
+
if (html.includes("<!--pyreon-scripts-->")) html = html.replace("<!--pyreon-scripts-->", result.loaderScript);
|
|
1821
|
+
else if (result.loaderScript) html = html.replace("</body>", `${result.loaderScript}</body>`);
|
|
1822
|
+
return html;
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
|
|
1826
|
+
* Vite's `closeBundle` hook so it runs once after the main client build
|
|
1827
|
+
* completes. The recursive SSR sub-build is gated by an env flag.
|
|
1828
|
+
*/
|
|
1829
|
+
function ssgPlugin(userConfig = {}) {
|
|
1830
|
+
const config = resolveConfig(userConfig);
|
|
1831
|
+
let root = "";
|
|
1832
|
+
let distDir = "";
|
|
1833
|
+
const isInnerBuild = process.env[SSG_BUILD_FLAG] === "1";
|
|
1834
|
+
return {
|
|
1835
|
+
name: "pyreon-zero-ssg",
|
|
1836
|
+
apply: "build",
|
|
1837
|
+
enforce: "post",
|
|
1838
|
+
configResolved(resolved) {
|
|
1839
|
+
root = resolved.root;
|
|
1840
|
+
distDir = resolve(root, resolved.build.outDir);
|
|
1841
|
+
},
|
|
1842
|
+
async closeBundle() {
|
|
1843
|
+
if (config.mode !== "ssg") return;
|
|
1844
|
+
if (isInnerBuild) return;
|
|
1845
|
+
const ssrOutDir = join(distDir, ".zero-ssg-server");
|
|
1846
|
+
const indexHtmlPath = join(distDir, "index.html");
|
|
1847
|
+
if (!existsSync(indexHtmlPath)) {
|
|
1848
|
+
console.warn(`[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
const entryPath = join(root, SSR_ENTRY_FILENAME);
|
|
1852
|
+
await writeFile(entryPath, renderSsrEntrySource(config.i18n?.locales ?? []), "utf-8");
|
|
1853
|
+
const { build } = await import("vite");
|
|
1854
|
+
process.env[SSG_BUILD_FLAG] = "1";
|
|
1855
|
+
try {
|
|
1856
|
+
const [{ zeroPlugin }, pyreonModule] = await Promise.all([Promise.resolve().then(() => vite_plugin_exports), import("@pyreon/vite-plugin")]);
|
|
1857
|
+
const pyreon = pyreonModule.default;
|
|
1858
|
+
await build({
|
|
1859
|
+
root,
|
|
1860
|
+
mode: "production",
|
|
1861
|
+
logLevel: "error",
|
|
1862
|
+
configFile: false,
|
|
1863
|
+
publicDir: false,
|
|
1864
|
+
plugins: [pyreon(), zeroPlugin(userConfig)],
|
|
1865
|
+
resolve: { conditions: ["bun"] },
|
|
1866
|
+
build: {
|
|
1867
|
+
ssr: entryPath,
|
|
1868
|
+
outDir: ssrOutDir,
|
|
1869
|
+
emptyOutDir: true,
|
|
1870
|
+
target: "esnext",
|
|
1871
|
+
rollupOptions: {
|
|
1872
|
+
input: entryPath,
|
|
1873
|
+
output: {
|
|
1874
|
+
format: "es",
|
|
1875
|
+
entryFileNames: "entry-server.mjs"
|
|
1876
|
+
},
|
|
1877
|
+
external: [/^node:/]
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
} finally {
|
|
1882
|
+
delete process.env[SSG_BUILD_FLAG];
|
|
1883
|
+
try {
|
|
1884
|
+
await rm(entryPath, { force: true });
|
|
1885
|
+
} catch {}
|
|
1886
|
+
}
|
|
1887
|
+
const handlerPath = join(ssrOutDir, "entry-server.mjs");
|
|
1888
|
+
if (!existsSync(handlerPath)) {
|
|
1889
|
+
console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const handlerMod = await import(
|
|
1893
|
+
/* @vite-ignore */
|
|
1894
|
+
pathToFileURL(handlerPath).href
|
|
1895
|
+
);
|
|
1896
|
+
const renderPath = handlerMod.default;
|
|
1897
|
+
const registry = handlerMod.__getStaticPathsRegistry;
|
|
1898
|
+
const template = await readFile(indexHtmlPath, "utf-8");
|
|
1899
|
+
const errors = [];
|
|
1900
|
+
const routesDir = join(root, "src", "routes");
|
|
1901
|
+
const paths = await resolvePaths(config, routesDir, registry, errors);
|
|
1902
|
+
if (paths.length === 0) {
|
|
1903
|
+
console.warn("[zero:ssg] No static paths to prerender — set ssg.paths in zero config");
|
|
1904
|
+
await rm(ssrOutDir, {
|
|
1905
|
+
recursive: true,
|
|
1906
|
+
force: true
|
|
1907
|
+
});
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
assertNoPathCollisions(paths);
|
|
1911
|
+
let pages = 0;
|
|
1912
|
+
const redirects = [];
|
|
1913
|
+
const writtenPaths = [];
|
|
1914
|
+
const start = Date.now();
|
|
1915
|
+
const renderOne = async (p) => {
|
|
1916
|
+
if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRender");
|
|
1917
|
+
try {
|
|
1918
|
+
const result = await Promise.race([renderPath(p), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for "${p}" (30s)`)), 3e4))]);
|
|
1919
|
+
if (result.kind === "redirect") {
|
|
1920
|
+
if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathRedirect");
|
|
1921
|
+
redirects.push({
|
|
1922
|
+
from: result.from,
|
|
1923
|
+
to: result.to,
|
|
1924
|
+
status: result.status
|
|
1925
|
+
});
|
|
1926
|
+
if (config.ssg?.redirectsAsHtml === "meta-refresh") {
|
|
1927
|
+
const filePath = resolveOutputPath(distDir, p);
|
|
1928
|
+
const resolvedOut = resolve(distDir);
|
|
1929
|
+
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1930
|
+
errors.push({
|
|
1931
|
+
path: p,
|
|
1932
|
+
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
1933
|
+
});
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1937
|
+
await writeFile(filePath, renderMetaRefreshHtml(result.to), "utf-8");
|
|
1938
|
+
}
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const html = injectIntoTemplate(template, result);
|
|
1942
|
+
const filePath = resolveOutputPath(distDir, p);
|
|
1943
|
+
const resolvedOut = resolve(distDir);
|
|
1944
|
+
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1945
|
+
errors.push({
|
|
1946
|
+
path: p,
|
|
1947
|
+
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
1948
|
+
});
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1952
|
+
await writeFile(filePath, html, "utf-8");
|
|
1953
|
+
pages++;
|
|
1954
|
+
writtenPaths.push(p);
|
|
1955
|
+
if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathWrite");
|
|
1956
|
+
} catch (error) {
|
|
1957
|
+
if (__DEV__) _countSink.__pyreon_count__?.("ssg.pathError");
|
|
1958
|
+
errors.push({
|
|
1959
|
+
path: p,
|
|
1960
|
+
error
|
|
1961
|
+
});
|
|
1962
|
+
if (config.ssg?.onPathError) try {
|
|
1963
|
+
const fallbackHtml = await config.ssg.onPathError(p, error);
|
|
1964
|
+
if (typeof fallbackHtml === "string") {
|
|
1965
|
+
const filePath = resolveOutputPath(distDir, p);
|
|
1966
|
+
const resolvedOut = resolve(distDir);
|
|
1967
|
+
if (!resolve(filePath).startsWith(resolvedOut)) {
|
|
1968
|
+
errors.push({
|
|
1969
|
+
path: p,
|
|
1970
|
+
error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
|
|
1971
|
+
});
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1975
|
+
await writeFile(filePath, fallbackHtml, "utf-8");
|
|
1976
|
+
pages++;
|
|
1977
|
+
}
|
|
1978
|
+
} catch (callbackError) {
|
|
1979
|
+
errors.push({
|
|
1980
|
+
path: `${p} (onPathError)`,
|
|
1981
|
+
error: callbackError
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
};
|
|
1986
|
+
const concurrency = Math.max(1, config.ssg?.concurrency ?? 4);
|
|
1987
|
+
let completed = 0;
|
|
1988
|
+
await runWithConcurrency(paths, concurrency, renderOne, async (p) => {
|
|
1989
|
+
completed++;
|
|
1990
|
+
if (config.ssg?.onProgress) try {
|
|
1991
|
+
await config.ssg.onProgress({
|
|
1992
|
+
completed,
|
|
1993
|
+
total: paths.length,
|
|
1994
|
+
currentPath: p,
|
|
1995
|
+
elapsed: Date.now() - start
|
|
1996
|
+
});
|
|
1997
|
+
} catch (callbackError) {
|
|
1998
|
+
errors.push({
|
|
1999
|
+
path: `${p} (onProgress)`,
|
|
2000
|
+
error: callbackError
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
if (writtenPaths.length > 0) await writeFileAtomic(join(distDir, "_pyreon-ssg-paths.json"), `${JSON.stringify({
|
|
2005
|
+
paths: writtenPaths,
|
|
2006
|
+
...config.i18n ? { i18n: config.i18n } : {}
|
|
2007
|
+
}, null, 2)}\n`);
|
|
2008
|
+
let revalidateCount = 0;
|
|
2009
|
+
if (existsSync(routesDir)) try {
|
|
2010
|
+
const revalidateManifest = buildRevalidateManifest(await scanRouteFilesWithExports(routesDir, config.mode), writtenPaths);
|
|
2011
|
+
revalidateCount = Object.keys(revalidateManifest).length;
|
|
2012
|
+
if (revalidateCount > 0) await writeFileAtomic(join(distDir, "_pyreon-revalidate.json"), `${JSON.stringify({ revalidate: revalidateManifest }, null, 2)}\n`);
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
errors.push({
|
|
2015
|
+
path: "(revalidate-manifest)",
|
|
2016
|
+
error: err
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
let emitted404Count = 0;
|
|
2020
|
+
if (config.ssg?.emit404 !== false && handlerMod.__renderNotFound) {
|
|
2021
|
+
const localeEntries = handlerMod.__notFoundComponentsByLocale instanceof Map ? [...handlerMod.__notFoundComponentsByLocale.keys()] : handlerMod.__notFoundComponent ? [null] : [];
|
|
2022
|
+
for (const locale of localeEntries) try {
|
|
2023
|
+
const result = await Promise.race([handlerMod.__renderNotFound(locale), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for ${locale == null ? "404" : `${locale}/404`} (30s)`)), 3e4))]);
|
|
2024
|
+
if (result) {
|
|
2025
|
+
const html = injectIntoTemplate(template, result);
|
|
2026
|
+
const filePath = locale == null ? join(distDir, "404.html") : join(distDir, locale, "404.html");
|
|
2027
|
+
if (locale != null) await mkdir(join(distDir, locale), { recursive: true });
|
|
2028
|
+
await writeFile(filePath, html, "utf-8");
|
|
2029
|
+
emitted404Count++;
|
|
2030
|
+
if (__DEV__) _countSink.__pyreon_count__?.("ssg.404Emit");
|
|
2031
|
+
}
|
|
2032
|
+
} catch (error) {
|
|
2033
|
+
errors.push({
|
|
2034
|
+
path: locale == null ? "404.html" : `${locale}/404.html`,
|
|
2035
|
+
error
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
if (redirects.length > 0 && config.ssg?.emitRedirects !== false) {
|
|
2040
|
+
await writeFileAtomic(join(distDir, "_redirects"), renderNetlifyRedirects(redirects));
|
|
2041
|
+
await writeFileAtomic(join(distDir, "_redirects.json"), renderVercelRedirectsJson(redirects));
|
|
2042
|
+
}
|
|
2043
|
+
if (errors.length > 0 && config.ssg?.errorArtifact !== "none") await writeFileAtomic(join(distDir, "_pyreon-ssg-errors.json"), renderErrorArtifact(errors));
|
|
2044
|
+
const adapter = resolveAdapter(config);
|
|
2045
|
+
try {
|
|
2046
|
+
await adapter.build({
|
|
2047
|
+
kind: "ssg",
|
|
2048
|
+
outDir: distDir,
|
|
2049
|
+
config
|
|
2050
|
+
});
|
|
2051
|
+
} catch (adapterError) {
|
|
2052
|
+
errors.push({
|
|
2053
|
+
path: `(adapter:${adapter.name})`,
|
|
2054
|
+
error: adapterError
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
await rm(ssrOutDir, {
|
|
2058
|
+
recursive: true,
|
|
2059
|
+
force: true
|
|
2060
|
+
});
|
|
2061
|
+
const elapsed = Date.now() - start;
|
|
2062
|
+
const redirectsSummary = redirects.length > 0 ? ` + ${redirects.length} redirect(s)` : "";
|
|
2063
|
+
const concurrencySummary = concurrency > 1 ? ` (concurrency: ${concurrency})` : "";
|
|
2064
|
+
const adapterSummary = adapter.name !== "node" ? ` [adapter: ${adapter.name}]` : "";
|
|
2065
|
+
const revalidateSummary = revalidateCount > 0 ? ` + ${revalidateCount} revalidate path(s)` : "";
|
|
2066
|
+
const localeSummary = config.i18n ? buildLocaleSummary(writtenPaths, config.i18n) : "";
|
|
2067
|
+
console.log(`[zero:ssg] Prerendered ${pages} page(s)${emitted404Count > 0 ? emitted404Count === 1 ? " + 404.html" : ` + ${emitted404Count} 404 pages` : ""}${redirectsSummary}${revalidateSummary} in ${elapsed}ms${concurrencySummary}${adapterSummary}${localeSummary}` + (errors.length > 0 ? ` (${errors.length} error(s))` : ""));
|
|
2068
|
+
for (const { path: errPath, error } of errors) console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error);
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
//#endregion
|
|
2074
|
+
//#region src/vite-plugin.ts
|
|
2075
|
+
var vite_plugin_exports = /* @__PURE__ */ __exportAll({
|
|
2076
|
+
argvHasPortFlag: () => argvHasPortFlag,
|
|
2077
|
+
getZeroPluginConfig: () => getZeroPluginConfig,
|
|
2078
|
+
zeroPlugin: () => zeroPlugin
|
|
2079
|
+
});
|
|
2080
|
+
/**
|
|
2081
|
+
* Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
|
|
2082
|
+
* Returns package names to exclude from Vite's dep optimizer.
|
|
2083
|
+
*/
|
|
2084
|
+
function scanPyreonPackages(root) {
|
|
2085
|
+
const pyreonDir = join(root, "node_modules", "@pyreon");
|
|
2086
|
+
if (!existsSync(pyreonDir)) return [];
|
|
2087
|
+
try {
|
|
2088
|
+
return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
|
|
2089
|
+
} catch {
|
|
2090
|
+
return [];
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* Resolve a package that isn't at the app's top-level `node_modules` but is
|
|
2095
|
+
* nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
|
|
2096
|
+
* to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
|
|
2097
|
+
* so `ssrLoadModule` works without requiring the app to declare it as a
|
|
2098
|
+
* direct dep.
|
|
2099
|
+
*/
|
|
2100
|
+
function resolveNestedPackage(root, name) {
|
|
2101
|
+
const direct = join(root, "node_modules", name);
|
|
2102
|
+
if (existsSync(direct)) return direct;
|
|
2103
|
+
const nested = join(root, "node_modules", "@pyreon", "zero", "node_modules", name);
|
|
2104
|
+
if (existsSync(nested)) return nested;
|
|
2105
|
+
}
|
|
2106
|
+
const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
|
|
2107
|
+
const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
|
|
2108
|
+
const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
|
|
2109
|
+
const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
|
|
2110
|
+
const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
|
|
2111
|
+
const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
|
|
2112
|
+
/**
|
|
2113
|
+
* Per-plugin-instance storage for the user-supplied ZeroConfig. Lets
|
|
2114
|
+
* downstream consumers (e.g. `@pyreon/zero-cli`'s `build` command, which
|
|
2115
|
+
* loads the user's `vite.config.ts` and inspects its plugin list)
|
|
2116
|
+
* recover the original config without us attaching internal state to
|
|
2117
|
+
* the public Plugin object via an underscore-prefixed property.
|
|
2118
|
+
*
|
|
2119
|
+
* Exported via `getZeroPluginConfig(plugin)` so the WeakMap itself
|
|
2120
|
+
* stays an implementation detail — callers can't enumerate or mutate
|
|
2121
|
+
* the table, only read by Plugin identity.
|
|
2122
|
+
*/
|
|
2123
|
+
const zeroPluginConfigMap = /* @__PURE__ */ new WeakMap();
|
|
2124
|
+
/**
|
|
2125
|
+
* Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
|
|
2126
|
+
* when the plugin was created. Returns `undefined` if the argument
|
|
2127
|
+
* isn't a recognized pyreon-zero main plugin instance.
|
|
2128
|
+
*/
|
|
2129
|
+
function getZeroPluginConfig(plugin) {
|
|
2130
|
+
return zeroPluginConfigMap.get(plugin);
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Detects `--port` / `--port=N` / `-p N` / `-p=N` in `process.argv`.
|
|
2134
|
+
* Used by the plugin's `config()` hook to decide whether to apply the
|
|
2135
|
+
* default port — when the CLI was invoked with `--port`, the plugin
|
|
2136
|
+
* must skip its default so the CLI flag wins (see the comment at the
|
|
2137
|
+
* port-handling block in `zeroPlugin()` for the full precedence model).
|
|
2138
|
+
*
|
|
2139
|
+
* Exported for testing only (the plugin uses it internally).
|
|
2140
|
+
*
|
|
2141
|
+
* @internal
|
|
2142
|
+
*/
|
|
2143
|
+
function argvHasPortFlag(argv = process.argv) {
|
|
2144
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2145
|
+
const a = argv[i];
|
|
2146
|
+
if (a === "--port" || a === "-p") return true;
|
|
2147
|
+
if (a !== void 0 && (a.startsWith("--port=") || a.startsWith("-p="))) return true;
|
|
2148
|
+
}
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Zero Vite plugin — adds file-based routing and zero-config conventions
|
|
2153
|
+
* on top of @pyreon/vite-plugin.
|
|
2154
|
+
*
|
|
2155
|
+
* @example
|
|
2156
|
+
* // vite.config.ts
|
|
2157
|
+
* import pyreon from "@pyreon/vite-plugin"
|
|
2158
|
+
* import zero from "@pyreon/zero"
|
|
2159
|
+
*
|
|
2160
|
+
* export default {
|
|
2161
|
+
* plugins: [pyreon(), zero()],
|
|
2162
|
+
* }
|
|
2163
|
+
*/
|
|
2164
|
+
function zeroPlugin(userConfig = {}) {
|
|
2165
|
+
const config = resolveConfig(userConfig);
|
|
2166
|
+
let routesDir;
|
|
2167
|
+
let root;
|
|
2168
|
+
const mainPlugin = {
|
|
2169
|
+
name: "pyreon-zero",
|
|
2170
|
+
enforce: "pre",
|
|
2171
|
+
configResolved(resolvedConfig) {
|
|
2172
|
+
root = resolvedConfig.root;
|
|
2173
|
+
routesDir = `${root}/src/routes`;
|
|
2174
|
+
},
|
|
2175
|
+
resolveId(id) {
|
|
2176
|
+
if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
|
|
2177
|
+
if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
|
|
2178
|
+
if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
|
|
2179
|
+
},
|
|
2180
|
+
async load(id) {
|
|
2181
|
+
if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
|
|
2182
|
+
const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
|
|
2183
|
+
const routes = config.i18n ? expandRoutesForLocales(baseRoutes, config.i18n) : baseRoutes;
|
|
2184
|
+
const ssgSplitDisabled = config.mode === "ssg" && config.ssg?.splitChunks === false;
|
|
2185
|
+
return generateRouteModuleFromRoutes(routes, routesDir, { staticImports: ssgSplitDisabled });
|
|
2186
|
+
} catch (_err) {
|
|
2187
|
+
return `export const routes = []`;
|
|
2188
|
+
}
|
|
2189
|
+
if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
|
|
2190
|
+
return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
|
|
2191
|
+
} catch (_err) {
|
|
2192
|
+
return `export const routeMiddleware = []`;
|
|
2193
|
+
}
|
|
2194
|
+
if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
|
|
2195
|
+
return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
|
|
2196
|
+
} catch (_err) {
|
|
2197
|
+
return `export const apiRoutes = []`;
|
|
2198
|
+
}
|
|
2199
|
+
},
|
|
2200
|
+
configureServer(server) {
|
|
2201
|
+
server.middlewares.use((req, res, next) => {
|
|
2202
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
2203
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
|
|
2204
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
2205
|
+
dispatchApiRoute(server, req, res).then((handled) => {
|
|
2206
|
+
if (!handled) next();
|
|
2207
|
+
}, (err) => {
|
|
2208
|
+
console.error("[Pyreon] Error in dev API dispatcher:", err);
|
|
2209
|
+
next();
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
if (config.mode === "ssr") server.middlewares.use((req, res, next) => {
|
|
2213
|
+
const accept = req.headers.accept ?? "";
|
|
2214
|
+
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
2215
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
2216
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
|
|
2217
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
2218
|
+
const reqHost = req.headers.host ?? "localhost";
|
|
2219
|
+
const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
|
|
2220
|
+
const reqHeaders = new Headers();
|
|
2221
|
+
for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) reqHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
2222
|
+
const webReq = new Request(reqUrl.href, {
|
|
2223
|
+
method: req.method ?? "GET",
|
|
2224
|
+
headers: reqHeaders
|
|
2225
|
+
});
|
|
2226
|
+
renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then((result) => {
|
|
2227
|
+
if (result === null) return next();
|
|
2228
|
+
res.statusCode = result.status;
|
|
2229
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2230
|
+
res.setHeader("Content-Length", Buffer.byteLength(result.html));
|
|
2231
|
+
res.end(result.html);
|
|
2232
|
+
}, (err) => {
|
|
2233
|
+
const info = getRedirectInfo(err);
|
|
2234
|
+
if (info) {
|
|
2235
|
+
res.statusCode = info.status;
|
|
2236
|
+
res.setHeader("Location", info.url);
|
|
2237
|
+
res.end();
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2241
|
+
server.ssrFixStacktrace(error);
|
|
2242
|
+
const html = renderErrorOverlay(error);
|
|
2243
|
+
res.statusCode = 500;
|
|
2244
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2245
|
+
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
2246
|
+
res.end(html);
|
|
2247
|
+
});
|
|
2248
|
+
});
|
|
2249
|
+
server.middlewares.use((req, res, next) => {
|
|
2250
|
+
const accept = req.headers.accept ?? "";
|
|
2251
|
+
if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
|
|
2252
|
+
const pathname = req.url?.split("?")[0] ?? "/";
|
|
2253
|
+
if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
|
|
2254
|
+
if (/\.\w+$/.test(pathname)) return next();
|
|
2255
|
+
handle404(server, routesDir, pathname, res).then((handled) => {
|
|
2256
|
+
if (!handled) next();
|
|
2257
|
+
}, (err) => {
|
|
2258
|
+
console.error("[Pyreon] Error in 404 handler:", err);
|
|
2259
|
+
next();
|
|
2260
|
+
});
|
|
2261
|
+
});
|
|
2262
|
+
server.middlewares.use((req, res, next) => {
|
|
2263
|
+
if (!(req.headers.accept ?? "").includes("text/html")) return next();
|
|
2264
|
+
const originalEnd = res.end.bind(res);
|
|
2265
|
+
let errored = false;
|
|
2266
|
+
const handleError = (err) => {
|
|
2267
|
+
if (errored) return;
|
|
2268
|
+
errored = true;
|
|
2269
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2270
|
+
server.ssrFixStacktrace(error);
|
|
2271
|
+
const html = renderErrorOverlay(error);
|
|
2272
|
+
res.statusCode = 500;
|
|
2273
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2274
|
+
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
2275
|
+
originalEnd(html);
|
|
2276
|
+
};
|
|
2277
|
+
res.on("error", handleError);
|
|
2278
|
+
try {
|
|
2279
|
+
const result = next();
|
|
2280
|
+
if (result && typeof result.catch === "function") result.catch(handleError);
|
|
2281
|
+
} catch (err) {
|
|
2282
|
+
handleError(err);
|
|
2283
|
+
}
|
|
2284
|
+
});
|
|
2285
|
+
server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
|
|
2286
|
+
server.watcher.on("all", (event, path) => {
|
|
2287
|
+
if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
|
|
2288
|
+
for (const resolvedId of [
|
|
2289
|
+
RESOLVED_VIRTUAL_ROUTES_ID,
|
|
2290
|
+
RESOLVED_VIRTUAL_MIDDLEWARE_ID,
|
|
2291
|
+
RESOLVED_VIRTUAL_API_ROUTES_ID
|
|
2292
|
+
]) {
|
|
2293
|
+
const mod = server.moduleGraph.getModuleById(resolvedId);
|
|
2294
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
2295
|
+
}
|
|
2296
|
+
server.ws.send({ type: "full-reload" });
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
},
|
|
2300
|
+
config(viteUserConfig) {
|
|
2301
|
+
const cwd = viteUserConfig.root ?? process.cwd();
|
|
2302
|
+
const pyreonExclude = scanPyreonPackages(cwd);
|
|
2303
|
+
const runtimeServerAlias = resolveNestedPackage(cwd, "@pyreon/runtime-server");
|
|
2304
|
+
return {
|
|
2305
|
+
resolve: {
|
|
2306
|
+
conditions: ["bun"],
|
|
2307
|
+
...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
|
|
2308
|
+
},
|
|
2309
|
+
ssr: { resolve: {
|
|
2310
|
+
conditions: ["bun"],
|
|
2311
|
+
...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
|
|
2312
|
+
} },
|
|
2313
|
+
optimizeDeps: { exclude: pyreonExclude },
|
|
2314
|
+
...userConfig.port === void 0 && argvHasPortFlag() ? {} : { server: { port: config.port } },
|
|
2315
|
+
base: config.base,
|
|
2316
|
+
define: {
|
|
2317
|
+
__ZERO_MODE__: JSON.stringify(config.mode),
|
|
2318
|
+
__ZERO_BASE__: JSON.stringify(config.base)
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
zeroPluginConfigMap.set(mainPlugin, userConfig);
|
|
2324
|
+
return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
|
|
2325
|
+
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
|
|
2328
|
+
* module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
|
|
2329
|
+
* the matching route's HTTP-method handler.
|
|
2330
|
+
*
|
|
2331
|
+
* Returns `true` if the API middleware handled the request (response written).
|
|
2332
|
+
* Returns `false` if no route matched (caller falls through to next middleware).
|
|
2333
|
+
*
|
|
2334
|
+
* Mirrors what `createServer` wires up in production via `createApiMiddleware`,
|
|
2335
|
+
* but adapted for Vite's connect-style middleware stack — needs a Node→Web
|
|
2336
|
+
* request adapter.
|
|
2337
|
+
*/
|
|
2338
|
+
async function dispatchApiRoute(server, req, res) {
|
|
2339
|
+
let apiRoutes;
|
|
2340
|
+
try {
|
|
2341
|
+
apiRoutes = (await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID)).apiRoutes ?? [];
|
|
2342
|
+
} catch {
|
|
2343
|
+
return false;
|
|
2344
|
+
}
|
|
2345
|
+
if (apiRoutes.length === 0) return false;
|
|
2346
|
+
const host = req.headers.host ?? "localhost";
|
|
2347
|
+
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
2348
|
+
const pathname = url.pathname;
|
|
2349
|
+
if (!apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null)) return false;
|
|
2350
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2351
|
+
const headers = new Headers();
|
|
2352
|
+
for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
|
|
2353
|
+
const requestInit = {
|
|
2354
|
+
method,
|
|
2355
|
+
headers
|
|
2356
|
+
};
|
|
2357
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
2358
|
+
requestInit.body = Readable.toWeb(req);
|
|
2359
|
+
requestInit.duplex = "half";
|
|
2360
|
+
}
|
|
2361
|
+
const webReq = new Request(url.href, requestInit);
|
|
2362
|
+
const response = await createApiMiddleware(apiRoutes)({
|
|
2363
|
+
req: webReq,
|
|
2364
|
+
url,
|
|
2365
|
+
path: pathname + url.search,
|
|
2366
|
+
headers: new Headers(),
|
|
2367
|
+
locals: {}
|
|
2368
|
+
});
|
|
2369
|
+
if (!response) return false;
|
|
2370
|
+
res.statusCode = response.status;
|
|
2371
|
+
response.headers.forEach((v, k) => {
|
|
2372
|
+
res.setHeader(k, v);
|
|
2373
|
+
});
|
|
2374
|
+
if (response.body) Readable.fromWeb(response.body).pipe(res);
|
|
2375
|
+
else res.end();
|
|
2376
|
+
return true;
|
|
2377
|
+
}
|
|
2378
|
+
/**
|
|
2379
|
+
* Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
|
|
2380
|
+
*
|
|
2381
|
+
* For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
|
|
2382
|
+
* routes unmatched URLs through the router-driven path (PR L5 + M1.2) — that
|
|
2383
|
+
* produces a layout-wrapped 404 with HTTP status 404, never reaching here.
|
|
2384
|
+
* This function is the LEGACY fallback that fires only when:
|
|
2385
|
+
* - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
|
|
2386
|
+
* - The app has no reachable `notFoundComponent` in its routes tree (so the
|
|
2387
|
+
* SSR middleware's `resolveRoute` returns matched: [] and falls through).
|
|
2388
|
+
*
|
|
2389
|
+
* Returns true if the 404 was handled (response sent), false if the path
|
|
2390
|
+
* actually matches a route (caller continues to next middleware).
|
|
2391
|
+
*
|
|
2392
|
+
* Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
|
|
2393
|
+
* the compiler emits _tpl() calls that require document". That was wrong — the
|
|
2394
|
+
* SSR runtime renders compiler-emitted components fine via `renderToString`
|
|
2395
|
+
* (no document needed). The static fallback exists for backward compat with
|
|
2396
|
+
* apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
|
|
2397
|
+
*/
|
|
2398
|
+
async function handle404(server, _routesDir, pathname, res) {
|
|
2399
|
+
const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
|
|
2400
|
+
if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
|
|
2401
|
+
const html = await render404Page(void 0);
|
|
2402
|
+
res.statusCode = 404;
|
|
2403
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
2404
|
+
res.setHeader("Content-Length", Buffer.byteLength(html));
|
|
2405
|
+
res.end(html);
|
|
2406
|
+
return true;
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2409
|
+
* Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
|
|
2410
|
+
* if the URL doesn't match any known route (caller falls through to the 404
|
|
2411
|
+
* middleware). Mirrors the production `createServer` flow:
|
|
2412
|
+
* 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
|
|
2413
|
+
* 2. Create a per-request router bound to the request URL
|
|
2414
|
+
* 3. Pre-run loaders for the matched route(s)
|
|
2415
|
+
* 4. Render app tree with head tag collection
|
|
2416
|
+
* 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
|
|
2417
|
+
* 6. Inject everything into the user's transformed index.html (so Vite
|
|
2418
|
+
* still gets a chance to inject its HMR client + JSX runtime prelude)
|
|
2419
|
+
*/
|
|
2420
|
+
async function renderSsr(server, root, originalUrl, pathname, req) {
|
|
2421
|
+
const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
|
|
2422
|
+
let template = await readFile(join(root, "index.html"), "utf-8");
|
|
2423
|
+
template = await server.transformIndexHtml(originalUrl, template);
|
|
2424
|
+
const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all([
|
|
2425
|
+
server.ssrLoadModule("@pyreon/core"),
|
|
2426
|
+
server.ssrLoadModule("@pyreon/head"),
|
|
2427
|
+
server.ssrLoadModule("@pyreon/head/ssr"),
|
|
2428
|
+
server.ssrLoadModule("@pyreon/router"),
|
|
2429
|
+
server.ssrLoadModule("@pyreon/runtime-server")
|
|
2430
|
+
]);
|
|
2431
|
+
const { App, router: routerInst } = (await server.ssrLoadModule("@pyreon/zero/server")).createApp({
|
|
2432
|
+
routes,
|
|
2433
|
+
routerMode: "history",
|
|
2434
|
+
url: pathname
|
|
2435
|
+
});
|
|
2436
|
+
await routerInst.preload(pathname, req);
|
|
2437
|
+
const resolved = routerInst.currentRoute();
|
|
2438
|
+
if (!resolved?.matched || resolved.matched.length === 0) return null;
|
|
2439
|
+
const status = resolved.isNotFound === true ? 404 : 200;
|
|
2440
|
+
return runtimeServer.runWithRequestContext(async () => {
|
|
2441
|
+
const app = core.h(App, null);
|
|
2442
|
+
const { html: appHtml, head } = await headSsr.renderWithHead(app);
|
|
2443
|
+
const loaderData = routerPkg.serializeLoaderData(routerInst);
|
|
2444
|
+
const loaderScript = loaderData && Object.keys(loaderData).length > 0 ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}<\/script>` : "";
|
|
2445
|
+
return {
|
|
2446
|
+
html: template.replace("<!--pyreon-head-->", head).replace("<!--pyreon-app-->", appHtml).replace("<!--pyreon-scripts-->", loaderScript),
|
|
2447
|
+
status
|
|
2448
|
+
};
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Extract all URL patterns from a nested route tree.
|
|
2453
|
+
*
|
|
2454
|
+
* The fs-router emits ABSOLUTE paths for every route, including grandchildren —
|
|
2455
|
+
* `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
|
|
2456
|
+
* each route's `path` as-is; no prefix accumulation. Pre-fix, this function
|
|
2457
|
+
* concatenated `${prefix}${route.path}` which produced patterns like
|
|
2458
|
+
* `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
|
|
2459
|
+
* `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
|
|
2460
|
+
* — which can't match a real `/app/dashboard` request — so dev-server returned
|
|
2461
|
+
* 404 for every nested-layout route. Re-enables PR #411 specs that rely on
|
|
2462
|
+
* `/app/*` routing.
|
|
2463
|
+
*/
|
|
2464
|
+
function flattenRoutePatterns(routes) {
|
|
2465
|
+
const patterns = [];
|
|
2466
|
+
for (const route of routes) {
|
|
2467
|
+
if (!route.path) continue;
|
|
2468
|
+
patterns.push(route.path);
|
|
2469
|
+
if (route.children) patterns.push(...flattenRoutePatterns(route.children));
|
|
2470
|
+
}
|
|
2471
|
+
return patterns;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
//#endregion
|
|
2475
|
+
export { render404Page as _, detectLocaleFromHeader as a, vercelAdapter as c, netlifyAdapter as d, cloudflareAdapter as f, createServer as g, resolveConfig as h, createLocaleContext as i, staticAdapter as l, defineConfig as m, vite_plugin_exports as n, i18nRouting as o, bunAdapter as p, zeroPlugin as r, resolveAdapter as s, getZeroPluginConfig as t, nodeAdapter as u, createApp as v };
|
|
2476
|
+
//# sourceMappingURL=vite-plugin-y0NmCLJA.js.map
|