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