@pyreon/zero 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/lib/api-routes-DANluJic.js +146 -0
  2. package/lib/client.js +3 -1
  3. package/lib/csp.js +19 -9
  4. package/lib/{fs-router-CQ7Zxeca.js → fs-router-ZebyutPa.js} +43 -6
  5. package/lib/image-plugin.js +4 -0
  6. package/lib/image.js +1 -50
  7. package/lib/index.js +1 -50
  8. package/lib/link.js +1 -49
  9. package/lib/script.js +1 -49
  10. package/lib/server.js +6 -688
  11. package/lib/theme.js +1 -50
  12. package/lib/types/i18n-routing.d.ts +4 -4
  13. package/lib/types/index.d.ts +23 -13
  14. package/lib/types/link.d.ts +3 -3
  15. package/lib/types/server.d.ts +28 -5
  16. package/lib/types/theme.d.ts +2 -2
  17. package/lib/vite-plugin-E4BHYvYW.js +855 -0
  18. package/package.json +15 -13
  19. package/src/app.ts +21 -1
  20. package/src/csp.ts +28 -12
  21. package/src/fs-router.ts +53 -3
  22. package/src/ssg-plugin.ts +366 -0
  23. package/src/types.ts +28 -9
  24. package/src/vite-plugin.ts +220 -40
  25. package/lib/actions.js.map +0 -1
  26. package/lib/ai.js.map +0 -1
  27. package/lib/api-routes.js.map +0 -1
  28. package/lib/cache.js.map +0 -1
  29. package/lib/client.js.map +0 -1
  30. package/lib/compression.js.map +0 -1
  31. package/lib/config.js.map +0 -1
  32. package/lib/cors.js.map +0 -1
  33. package/lib/csp.js.map +0 -1
  34. package/lib/env.js.map +0 -1
  35. package/lib/favicon.js.map +0 -1
  36. package/lib/font.js.map +0 -1
  37. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  38. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  39. package/lib/i18n-routing.js.map +0 -1
  40. package/lib/image-plugin.js.map +0 -1
  41. package/lib/image.js.map +0 -1
  42. package/lib/index.js.map +0 -1
  43. package/lib/link.js.map +0 -1
  44. package/lib/logger.js.map +0 -1
  45. package/lib/meta.js.map +0 -1
  46. package/lib/middleware.js.map +0 -1
  47. package/lib/og-image.js.map +0 -1
  48. package/lib/rate-limit.js.map +0 -1
  49. package/lib/script.js.map +0 -1
  50. package/lib/seo.js.map +0 -1
  51. package/lib/server.js.map +0 -1
  52. package/lib/testing.js.map +0 -1
  53. package/lib/theme.js.map +0 -1
  54. package/lib/types/actions.d.ts.map +0 -1
  55. package/lib/types/ai.d.ts.map +0 -1
  56. package/lib/types/api-routes.d.ts.map +0 -1
  57. package/lib/types/cache.d.ts.map +0 -1
  58. package/lib/types/client.d.ts.map +0 -1
  59. package/lib/types/compression.d.ts.map +0 -1
  60. package/lib/types/config.d.ts.map +0 -1
  61. package/lib/types/cors.d.ts.map +0 -1
  62. package/lib/types/csp.d.ts.map +0 -1
  63. package/lib/types/env.d.ts.map +0 -1
  64. package/lib/types/favicon.d.ts.map +0 -1
  65. package/lib/types/font.d.ts.map +0 -1
  66. package/lib/types/i18n-routing.d.ts.map +0 -1
  67. package/lib/types/image-plugin.d.ts.map +0 -1
  68. package/lib/types/image.d.ts.map +0 -1
  69. package/lib/types/index.d.ts.map +0 -1
  70. package/lib/types/link.d.ts.map +0 -1
  71. package/lib/types/logger.d.ts.map +0 -1
  72. package/lib/types/meta.d.ts.map +0 -1
  73. package/lib/types/middleware.d.ts.map +0 -1
  74. package/lib/types/og-image.d.ts.map +0 -1
  75. package/lib/types/rate-limit.d.ts.map +0 -1
  76. package/lib/types/script.d.ts.map +0 -1
  77. package/lib/types/seo.d.ts.map +0 -1
  78. package/lib/types/server.d.ts.map +0 -1
  79. package/lib/types/testing.d.ts.map +0 -1
  80. package/lib/types/theme.d.ts.map +0 -1
@@ -0,0 +1,855 @@
1
+ import { a as generateRouteModuleFromRoutes, c as scanRouteFilesWithExports, l as __exportAll, o as parseFileRoutes, r as generateMiddlewareModule, s as scanRouteFiles } from "./fs-router-ZebyutPa.js";
2
+ import { i as matchApiRoute, n as createApiMiddleware, r as generateApiRouteModule } from "./api-routes-DANluJic.js";
3
+ import { Fragment, h } from "@pyreon/core";
4
+ import { HeadProvider } from "@pyreon/head";
5
+ import { RouterProvider, RouterView, createRouter, getRedirectInfo } from "@pyreon/router";
6
+ import { createHandler } from "@pyreon/server";
7
+ import { renderToString } from "@pyreon/runtime-server";
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { Readable } from "node:stream";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ //#region src/app.ts
15
+ /**
16
+ * Create a full Zero app — assembles router, head provider, and root layout.
17
+ *
18
+ * Used internally by entry-server and entry-client.
19
+ */
20
+ function createApp(options) {
21
+ const router = createRouter({
22
+ routes: options.routes,
23
+ mode: options.routerMode ?? "history",
24
+ ...options.url ? { url: options.url } : {},
25
+ scrollBehavior: "top"
26
+ });
27
+ const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
28
+ 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.");
29
+ const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
30
+ function App() {
31
+ return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
32
+ }
33
+ return {
34
+ App,
35
+ router
36
+ };
37
+ }
38
+ function DefaultLayout(props) {
39
+ return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
40
+ }
41
+
42
+ //#endregion
43
+ //#region src/not-found.ts
44
+ const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
45
+ /**
46
+ * Render a 404 component to a full HTML string.
47
+ * If no component is provided, returns a default 404 page.
48
+ */
49
+ async function render404Page(component, template) {
50
+ let body;
51
+ if (component) body = await renderToString(h(component, null));
52
+ else body = DEFAULT_404_BODY;
53
+ if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
54
+ return `<!DOCTYPE html>
55
+ <html lang="en">
56
+ <head>
57
+ <meta charset="UTF-8">
58
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
59
+ <title>404 — Not Found</title>
60
+ </head>
61
+ <body>
62
+ ${body}
63
+ </body>
64
+ </html>`;
65
+ }
66
+
67
+ //#endregion
68
+ //#region src/entry-server.ts
69
+ /**
70
+ * Create a middleware that dispatches per-route middleware based on URL pattern matching.
71
+ */
72
+ function createRouteMiddlewareDispatcher(entries) {
73
+ return async (ctx) => {
74
+ for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
75
+ const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
76
+ for (const fn of mw) {
77
+ const result = await fn(ctx);
78
+ if (result) return result;
79
+ }
80
+ }
81
+ };
82
+ }
83
+ /**
84
+ * URL pattern matcher supporting :param and :param* segments.
85
+ *
86
+ * Rules:
87
+ * - Static segments must match exactly
88
+ * - `:param` matches a single path segment
89
+ * - `:param*` matches all remaining segments (must be last, and path must
90
+ * have matched all preceding segments)
91
+ * - Path length must match pattern length (unless catch-all)
92
+ */
93
+ function matchPattern(pattern, path) {
94
+ const patternParts = pattern.split("/").filter(Boolean);
95
+ const pathParts = path.split("/").filter(Boolean);
96
+ for (let i = 0; i < patternParts.length; i++) {
97
+ const pp = patternParts[i];
98
+ if (pp.endsWith("*")) return i <= pathParts.length;
99
+ if (i >= pathParts.length) return false;
100
+ if (pp.startsWith(":")) continue;
101
+ if (pp !== pathParts[i]) return false;
102
+ }
103
+ return patternParts.length === pathParts.length;
104
+ }
105
+ /**
106
+ * Create the SSR request handler for production.
107
+ *
108
+ * @example
109
+ * import { routes } from "virtual:zero/routes"
110
+ * import { routeMiddleware } from "virtual:zero/route-middleware"
111
+ * import { createServer } from "@pyreon/zero"
112
+ *
113
+ * export default createServer({ routes, routeMiddleware, apiRoutes })
114
+ */
115
+ function createServer(options) {
116
+ const config = options.config ?? {};
117
+ const allMiddleware = [];
118
+ if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
119
+ if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
120
+ allMiddleware.push(...config.middleware ?? []);
121
+ allMiddleware.push(...options.middleware ?? []);
122
+ const { App } = createApp({
123
+ routes: options.routes,
124
+ routerMode: "history"
125
+ });
126
+ const handler = createHandler({
127
+ App,
128
+ routes: options.routes,
129
+ middleware: allMiddleware,
130
+ mode: config.ssr?.mode ?? "string",
131
+ ...options.template ? { template: options.template } : {},
132
+ ...options.clientEntry ? { clientEntry: options.clientEntry } : {}
133
+ });
134
+ if (!options.notFoundComponent) return handler;
135
+ const NotFound = options.notFoundComponent;
136
+ const routePatterns = flattenRoutePatterns$1(options.routes);
137
+ return async (req) => {
138
+ const pathname = new URL(req.url).pathname;
139
+ if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
140
+ const fullHtml = await render404Page(NotFound, options.template);
141
+ return new Response(fullHtml, {
142
+ status: 404,
143
+ headers: { "Content-Type": "text/html; charset=utf-8" }
144
+ });
145
+ }
146
+ return handler(req);
147
+ };
148
+ }
149
+ /** Extract all URL patterns from a nested route tree. */
150
+ function flattenRoutePatterns$1(routes, prefix = "") {
151
+ const patterns = [];
152
+ for (const route of routes) {
153
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
154
+ patterns.push(fullPath);
155
+ if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
156
+ }
157
+ return patterns;
158
+ }
159
+
160
+ //#endregion
161
+ //#region src/config.ts
162
+ /**
163
+ * Define a Zero configuration.
164
+ * Used in `zero.config.ts` at the project root.
165
+ *
166
+ * @example
167
+ * import { defineConfig } from "@pyreon/zero/config"
168
+ *
169
+ * export default defineConfig({
170
+ * mode: "ssr",
171
+ * ssr: { mode: "stream" },
172
+ * port: 3000,
173
+ * })
174
+ */
175
+ function defineConfig(config) {
176
+ return config;
177
+ }
178
+ /** Merge user config with defaults. */
179
+ function resolveConfig(userConfig = {}) {
180
+ return {
181
+ mode: "ssr",
182
+ base: "/",
183
+ port: 3e3,
184
+ adapter: "node",
185
+ ...userConfig,
186
+ ssr: {
187
+ mode: "string",
188
+ ...userConfig.ssr
189
+ }
190
+ };
191
+ }
192
+
193
+ //#endregion
194
+ //#region src/error-overlay.ts
195
+ /**
196
+ * Dev-only error overlay for SSR/loader errors.
197
+ * Renders a styled HTML page with the error stack trace.
198
+ */
199
+ function renderErrorOverlay(error) {
200
+ return `<!DOCTYPE html>
201
+ <html lang="en">
202
+ <head>
203
+ <meta charset="UTF-8">
204
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
205
+ <title>SSR Error — Pyreon Zero</title>
206
+ <style>
207
+ * { margin: 0; padding: 0; box-sizing: border-box; }
208
+ body {
209
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
210
+ background: #1a1a2e;
211
+ color: #e0e0e0;
212
+ min-height: 100vh;
213
+ padding: 2rem;
214
+ }
215
+ .overlay {
216
+ max-width: 900px;
217
+ margin: 0 auto;
218
+ }
219
+ .header {
220
+ display: flex;
221
+ align-items: center;
222
+ gap: 0.75rem;
223
+ margin-bottom: 1.5rem;
224
+ }
225
+ .badge {
226
+ background: #e74c3c;
227
+ color: white;
228
+ padding: 0.25rem 0.75rem;
229
+ border-radius: 4px;
230
+ font-size: 0.75rem;
231
+ font-weight: 600;
232
+ text-transform: uppercase;
233
+ letter-spacing: 0.05em;
234
+ }
235
+ .label {
236
+ color: #888;
237
+ font-size: 0.85rem;
238
+ }
239
+ .message {
240
+ font-size: 1.25rem;
241
+ color: #ff6b6b;
242
+ margin-bottom: 1.5rem;
243
+ line-height: 1.5;
244
+ word-break: break-word;
245
+ }
246
+ .stack {
247
+ background: #16213e;
248
+ border: 1px solid #2a2a4a;
249
+ border-radius: 8px;
250
+ padding: 1.25rem;
251
+ overflow-x: auto;
252
+ font-size: 0.8rem;
253
+ line-height: 1.7;
254
+ white-space: pre-wrap;
255
+ word-break: break-all;
256
+ }
257
+ .stack .at { color: #888; }
258
+ .stack .file { color: #4ecdc4; }
259
+ .hint {
260
+ margin-top: 1.5rem;
261
+ padding: 1rem;
262
+ background: #1e2a45;
263
+ border-radius: 6px;
264
+ border-left: 3px solid #3498db;
265
+ font-size: 0.8rem;
266
+ color: #aaa;
267
+ line-height: 1.5;
268
+ }
269
+ </style>
270
+ </head>
271
+ <body>
272
+ <div class="overlay">
273
+ <div class="header">
274
+ <span class="badge">SSR Error</span>
275
+ <span class="label">Pyreon Zero — Dev Mode</span>
276
+ </div>
277
+ <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
278
+ <pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
279
+ <div class="hint">
280
+ This error occurred during server-side rendering. Check the terminal for
281
+ the full stack trace. This overlay is only shown in development.
282
+ </div>
283
+ </div>
284
+ </body>
285
+ </html>`;
286
+ }
287
+ function escapeHtml(str) {
288
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
289
+ }
290
+ function formatStack(stack) {
291
+ return stack.split("\n").map((line) => {
292
+ if (line.includes("at ")) {
293
+ const fileMatch = line.match(/\(([^)]+)\)/);
294
+ if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
295
+ }
296
+ return line;
297
+ }).join("\n");
298
+ }
299
+
300
+ //#endregion
301
+ //#region src/ssg-plugin.ts
302
+ /**
303
+ * SSG (Static Site Generation) build hook for `@pyreon/zero`.
304
+ *
305
+ * Activates when `mode: "ssg"` is set in zero's config. After Vite's client
306
+ * build finishes, this plugin:
307
+ *
308
+ * 1. Triggers a programmatic SSR build via Vite's `build()` API, producing
309
+ * a server bundle in `dist/.zero-ssg-server/` from a synthetic entry
310
+ * that imports `virtual:zero/routes` and `createServer`.
311
+ * 2. Loads the built handler with dynamic `import()`.
312
+ * 3. Resolves the path list from `config.ssg.paths` (string[], async fn,
313
+ * or auto-detected from the static-only routes in the route tree).
314
+ * 4. Calls `prerender()` from `@pyreon/server` to render each path.
315
+ * 5. Cleans up the temporary SSR build directory.
316
+ *
317
+ * Before this PR, `mode: "ssg"` and `ssg.paths` were typed in
318
+ * `types.ts` but had no runtime implementation — the plugin file had zero
319
+ * Rollup build hooks. Apps configured for SSG silently shipped a bare SPA
320
+ * shell with no per-route HTML files, which broke direct-URL deploys to
321
+ * static hosts (no `dist/<path>/index.html`, every URL falls back to the
322
+ * SPA index).
323
+ */
324
+ const SSG_BUILD_FLAG = "PYREON_ZERO_SSG_INNER_BUILD";
325
+ const SSR_ENTRY_SOURCE = `
326
+ import { routes } from "virtual:zero/routes"
327
+ import { h } from "@pyreon/core"
328
+ import { renderWithHead } from "@pyreon/head/ssr"
329
+ import { serializeLoaderData } from "@pyreon/router"
330
+ import { runWithRequestContext } from "@pyreon/runtime-server"
331
+ import { createApp } from "@pyreon/zero/server"
332
+
333
+ export default async function renderPath(path) {
334
+ const { App, router } = createApp({
335
+ routes,
336
+ routerMode: "history",
337
+ url: path,
338
+ })
339
+
340
+ await router.preload(path)
341
+
342
+ return runWithRequestContext(async () => {
343
+ const app = h(App, null)
344
+ const { html: appHtml, head } = await renderWithHead(app)
345
+ const loaderData = serializeLoaderData(router)
346
+ const hasData = loaderData && Object.keys(loaderData).length > 0
347
+ const loaderScript = hasData
348
+ ? \`<script>window.__PYREON_LOADER_DATA__=\${JSON.stringify(loaderData).replace(/<\\//g, "<\\\\/")}<\/script>\`
349
+ : ""
350
+ return { appHtml, head, loaderScript }
351
+ })
352
+ }
353
+ `.trimStart();
354
+ const SSR_ENTRY_FILENAME = "__pyreon-zero-ssg-entry.js";
355
+ /**
356
+ * Auto-detect static paths from the route tree. A "static" path is one with
357
+ * NO dynamic segments (`[id]`, `[...rest]`). Dynamic routes are skipped
358
+ * because we can't enumerate their values at build time without a
359
+ * `getStaticPaths`-style API.
360
+ */
361
+ async function autoDetectStaticPaths(routesDir) {
362
+ if (!existsSync(routesDir)) return ["/"];
363
+ const fileRoutes = parseFileRoutes(await scanRouteFiles(routesDir));
364
+ const out = [];
365
+ for (const r of fileRoutes) {
366
+ if (r.isLayout || r.isError || r.isLoading || r.isNotFound) continue;
367
+ const path = r.urlPath;
368
+ if (!path) continue;
369
+ if (/[:*]/.test(path)) continue;
370
+ out.push(path);
371
+ }
372
+ return out.length > 0 ? out : ["/"];
373
+ }
374
+ async function resolvePaths(config, routesDir) {
375
+ const explicit = config.ssg?.paths;
376
+ if (typeof explicit === "function") {
377
+ const result = await explicit();
378
+ return Array.isArray(result) ? result : [];
379
+ }
380
+ if (Array.isArray(explicit)) return explicit;
381
+ return autoDetectStaticPaths(routesDir);
382
+ }
383
+ function resolveOutputPath(distDir, path) {
384
+ if (path === "/") return join(distDir, "index.html");
385
+ if (path.endsWith(".html")) return join(distDir, path);
386
+ return join(distDir, path, "index.html");
387
+ }
388
+ /**
389
+ * Plugin that performs SSG when `mode: "ssg"` is configured. Wires into
390
+ * Vite's `closeBundle` hook so it runs once after the main client build
391
+ * completes. The recursive SSR sub-build is gated by an env flag.
392
+ */
393
+ function ssgPlugin(userConfig = {}) {
394
+ const config = resolveConfig(userConfig);
395
+ let root = "";
396
+ let distDir = "";
397
+ const isInnerBuild = process.env[SSG_BUILD_FLAG] === "1";
398
+ return {
399
+ name: "pyreon-zero-ssg",
400
+ apply: "build",
401
+ enforce: "post",
402
+ configResolved(resolved) {
403
+ root = resolved.root;
404
+ distDir = resolve(root, resolved.build.outDir);
405
+ },
406
+ async closeBundle() {
407
+ if (config.mode !== "ssg") return;
408
+ if (isInnerBuild) return;
409
+ const ssrOutDir = join(distDir, ".zero-ssg-server");
410
+ const indexHtmlPath = join(distDir, "index.html");
411
+ if (!existsSync(indexHtmlPath)) {
412
+ console.warn(`[zero:ssg] Skipping SSG — ${indexHtmlPath} not found. Did the client build complete?`);
413
+ return;
414
+ }
415
+ const entryPath = join(root, SSR_ENTRY_FILENAME);
416
+ await writeFile(entryPath, SSR_ENTRY_SOURCE, "utf-8");
417
+ const { build } = await import("vite");
418
+ process.env[SSG_BUILD_FLAG] = "1";
419
+ try {
420
+ const [{ zeroPlugin }, pyreonModule] = await Promise.all([Promise.resolve().then(() => vite_plugin_exports), import("@pyreon/vite-plugin")]);
421
+ const pyreon = pyreonModule.default;
422
+ await build({
423
+ root,
424
+ mode: "production",
425
+ logLevel: "error",
426
+ configFile: false,
427
+ publicDir: false,
428
+ plugins: [pyreon(), zeroPlugin(userConfig)],
429
+ resolve: { conditions: ["bun"] },
430
+ build: {
431
+ ssr: entryPath,
432
+ outDir: ssrOutDir,
433
+ emptyOutDir: true,
434
+ target: "esnext",
435
+ rollupOptions: {
436
+ input: entryPath,
437
+ output: {
438
+ format: "es",
439
+ entryFileNames: "entry-server.mjs"
440
+ },
441
+ external: [/^node:/]
442
+ }
443
+ }
444
+ });
445
+ } finally {
446
+ delete process.env[SSG_BUILD_FLAG];
447
+ try {
448
+ await rm(entryPath, { force: true });
449
+ } catch {}
450
+ }
451
+ const handlerPath = join(ssrOutDir, "entry-server.mjs");
452
+ if (!existsSync(handlerPath)) {
453
+ console.warn(`[zero:ssg] SSR build did not produce ${handlerPath} — skipping prerender`);
454
+ return;
455
+ }
456
+ const renderPath = (await import(
457
+ /* @vite-ignore */
458
+ pathToFileURL(handlerPath).href
459
+ )).default;
460
+ const template = await readFile(indexHtmlPath, "utf-8");
461
+ const paths = await resolvePaths(config, join(root, "src", "routes"));
462
+ if (paths.length === 0) {
463
+ console.warn("[zero:ssg] No static paths to prerender — set ssg.paths in zero config");
464
+ await rm(ssrOutDir, {
465
+ recursive: true,
466
+ force: true
467
+ });
468
+ return;
469
+ }
470
+ let pages = 0;
471
+ const errors = [];
472
+ const start = Date.now();
473
+ for (const p of paths) try {
474
+ const result = await Promise.race([renderPath(p), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`Prerender timeout for "${p}" (30s)`)), 3e4))]);
475
+ let html = template;
476
+ if (html.includes("<!--pyreon-head-->")) html = html.replace("<!--pyreon-head-->", result.head);
477
+ else if (result.head) html = html.replace("</head>", `${result.head}</head>`);
478
+ if (html.includes("<!--pyreon-app-->")) html = html.replace("<!--pyreon-app-->", result.appHtml);
479
+ else if (result.appHtml) {
480
+ const appDivMatch = html.match(/<div\s+id=["']app["']\s*>([\s\S]*?)<\/div>/);
481
+ if (appDivMatch) html = html.replace(appDivMatch[0], `<div id="app">${result.appHtml}</div>`);
482
+ else html = html.replace("</body>", `<div id="app">${result.appHtml}</div></body>`);
483
+ }
484
+ if (html.includes("<!--pyreon-scripts-->")) html = html.replace("<!--pyreon-scripts-->", result.loaderScript);
485
+ else if (result.loaderScript) html = html.replace("</body>", `${result.loaderScript}</body>`);
486
+ const filePath = resolveOutputPath(distDir, p);
487
+ const resolvedOut = resolve(distDir);
488
+ if (!resolve(filePath).startsWith(resolvedOut)) {
489
+ errors.push({
490
+ path: p,
491
+ error: /* @__PURE__ */ new Error(`Path traversal detected: "${p}"`)
492
+ });
493
+ continue;
494
+ }
495
+ await mkdir(dirname(filePath), { recursive: true });
496
+ await writeFile(filePath, html, "utf-8");
497
+ pages++;
498
+ } catch (error) {
499
+ errors.push({
500
+ path: p,
501
+ error
502
+ });
503
+ }
504
+ await rm(ssrOutDir, {
505
+ recursive: true,
506
+ force: true
507
+ });
508
+ const elapsed = Date.now() - start;
509
+ console.log(`[zero:ssg] Prerendered ${pages} page(s) in ${elapsed}ms` + (errors.length > 0 ? ` (${errors.length} error(s))` : ""));
510
+ for (const { path: errPath, error } of errors) console.error(`[zero:ssg] Failed to prerender "${errPath}":`, error);
511
+ }
512
+ };
513
+ }
514
+
515
+ //#endregion
516
+ //#region src/vite-plugin.ts
517
+ var vite_plugin_exports = /* @__PURE__ */ __exportAll({ zeroPlugin: () => zeroPlugin });
518
+ /**
519
+ * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
520
+ * Returns package names to exclude from Vite's dep optimizer.
521
+ */
522
+ function scanPyreonPackages(root) {
523
+ const pyreonDir = join(root, "node_modules", "@pyreon");
524
+ if (!existsSync(pyreonDir)) return [];
525
+ try {
526
+ return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
527
+ } catch {
528
+ return [];
529
+ }
530
+ }
531
+ /**
532
+ * Resolve a package that isn't at the app's top-level `node_modules` but is
533
+ * nested under another `@pyreon/*` package. Used to alias `@pyreon/runtime-server`
534
+ * to the copy under `node_modules/@pyreon/zero/node_modules/@pyreon/runtime-server`
535
+ * so `ssrLoadModule` works without requiring the app to declare it as a
536
+ * direct dep.
537
+ */
538
+ function resolveNestedPackage(root, name) {
539
+ const direct = join(root, "node_modules", name);
540
+ if (existsSync(direct)) return direct;
541
+ const nested = join(root, "node_modules", "@pyreon", "zero", "node_modules", name);
542
+ if (existsSync(nested)) return nested;
543
+ }
544
+ const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
545
+ const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
546
+ const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
547
+ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
548
+ const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
549
+ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
550
+ /**
551
+ * Zero Vite plugin — adds file-based routing and zero-config conventions
552
+ * on top of @pyreon/vite-plugin.
553
+ *
554
+ * @example
555
+ * // vite.config.ts
556
+ * import pyreon from "@pyreon/vite-plugin"
557
+ * import zero from "@pyreon/zero"
558
+ *
559
+ * export default {
560
+ * plugins: [pyreon(), zero()],
561
+ * }
562
+ */
563
+ function zeroPlugin(userConfig = {}) {
564
+ const config = resolveConfig(userConfig);
565
+ let routesDir;
566
+ let root;
567
+ const mainPlugin = {
568
+ name: "pyreon-zero",
569
+ enforce: "pre",
570
+ _zeroConfig: userConfig,
571
+ configResolved(resolvedConfig) {
572
+ root = resolvedConfig.root;
573
+ routesDir = `${root}/src/routes`;
574
+ },
575
+ resolveId(id) {
576
+ if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
577
+ if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
578
+ if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
579
+ },
580
+ async load(id) {
581
+ if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
582
+ return generateRouteModuleFromRoutes(await scanRouteFilesWithExports(routesDir, config.mode), routesDir, { staticImports: config.mode === "ssg" });
583
+ } catch (_err) {
584
+ return `export const routes = []`;
585
+ }
586
+ if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
587
+ return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
588
+ } catch (_err) {
589
+ return `export const routeMiddleware = []`;
590
+ }
591
+ if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
592
+ return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
593
+ } catch (_err) {
594
+ return `export const apiRoutes = []`;
595
+ }
596
+ },
597
+ configureServer(server) {
598
+ server.middlewares.use((req, res, next) => {
599
+ const pathname = req.url?.split("?")[0] ?? "/";
600
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
601
+ if (/\.\w+$/.test(pathname)) return next();
602
+ dispatchApiRoute(server, req, res).then((handled) => {
603
+ if (!handled) next();
604
+ }, (err) => {
605
+ console.error("[Pyreon] Error in dev API dispatcher:", err);
606
+ next();
607
+ });
608
+ });
609
+ if (config.mode === "ssr") server.middlewares.use((req, res, next) => {
610
+ const accept = req.headers.accept ?? "";
611
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
612
+ const pathname = req.url?.split("?")[0] ?? "/";
613
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
614
+ if (/\.\w+$/.test(pathname)) return next();
615
+ const reqHost = req.headers.host ?? "localhost";
616
+ const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
617
+ const reqHeaders = new Headers();
618
+ for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) reqHeaders.set(key, Array.isArray(value) ? value.join(", ") : String(value));
619
+ const webReq = new Request(reqUrl.href, {
620
+ method: req.method ?? "GET",
621
+ headers: reqHeaders
622
+ });
623
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then((result) => {
624
+ if (result === null) return next();
625
+ res.statusCode = 200;
626
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
627
+ res.setHeader("Content-Length", Buffer.byteLength(result));
628
+ res.end(result);
629
+ }, (err) => {
630
+ const info = getRedirectInfo(err);
631
+ if (info) {
632
+ res.statusCode = info.status;
633
+ res.setHeader("Location", info.url);
634
+ res.end();
635
+ return;
636
+ }
637
+ const error = err instanceof Error ? err : new Error(String(err));
638
+ server.ssrFixStacktrace(error);
639
+ const html = renderErrorOverlay(error);
640
+ res.statusCode = 500;
641
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
642
+ res.setHeader("Content-Length", Buffer.byteLength(html));
643
+ res.end(html);
644
+ });
645
+ });
646
+ server.middlewares.use((req, res, next) => {
647
+ const accept = req.headers.accept ?? "";
648
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
649
+ const pathname = req.url?.split("?")[0] ?? "/";
650
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
651
+ if (/\.\w+$/.test(pathname)) return next();
652
+ handle404(server, routesDir, pathname, res).then((handled) => {
653
+ if (!handled) next();
654
+ }, (err) => {
655
+ console.error("[Pyreon] Error in 404 handler:", err);
656
+ next();
657
+ });
658
+ });
659
+ server.middlewares.use((req, res, next) => {
660
+ if (!(req.headers.accept ?? "").includes("text/html")) return next();
661
+ const originalEnd = res.end.bind(res);
662
+ let errored = false;
663
+ const handleError = (err) => {
664
+ if (errored) return;
665
+ errored = true;
666
+ const error = err instanceof Error ? err : new Error(String(err));
667
+ server.ssrFixStacktrace(error);
668
+ const html = renderErrorOverlay(error);
669
+ res.statusCode = 500;
670
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
671
+ res.setHeader("Content-Length", Buffer.byteLength(html));
672
+ originalEnd(html);
673
+ };
674
+ res.on("error", handleError);
675
+ try {
676
+ const result = next();
677
+ if (result && typeof result.catch === "function") result.catch(handleError);
678
+ } catch (err) {
679
+ handleError(err);
680
+ }
681
+ });
682
+ server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
683
+ server.watcher.on("all", (event, path) => {
684
+ if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
685
+ for (const resolvedId of [
686
+ RESOLVED_VIRTUAL_ROUTES_ID,
687
+ RESOLVED_VIRTUAL_MIDDLEWARE_ID,
688
+ RESOLVED_VIRTUAL_API_ROUTES_ID
689
+ ]) {
690
+ const mod = server.moduleGraph.getModuleById(resolvedId);
691
+ if (mod) server.moduleGraph.invalidateModule(mod);
692
+ }
693
+ server.ws.send({ type: "full-reload" });
694
+ }
695
+ });
696
+ },
697
+ config(viteUserConfig) {
698
+ const root = viteUserConfig.root ?? process.cwd();
699
+ const pyreonExclude = scanPyreonPackages(root);
700
+ const runtimeServerAlias = resolveNestedPackage(root, "@pyreon/runtime-server");
701
+ return {
702
+ resolve: {
703
+ conditions: ["bun"],
704
+ ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
705
+ },
706
+ ssr: { resolve: {
707
+ conditions: ["bun"],
708
+ ...runtimeServerAlias ? { alias: { "@pyreon/runtime-server": runtimeServerAlias } } : {}
709
+ } },
710
+ optimizeDeps: { exclude: pyreonExclude },
711
+ ...userConfig.port !== void 0 ? { server: { port: config.port } } : {},
712
+ define: {
713
+ __ZERO_MODE__: JSON.stringify(config.mode),
714
+ __ZERO_BASE__: JSON.stringify(config.base)
715
+ }
716
+ };
717
+ }
718
+ };
719
+ return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
720
+ }
721
+ /**
722
+ * Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
723
+ * module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
724
+ * the matching route's HTTP-method handler.
725
+ *
726
+ * Returns `true` if the API middleware handled the request (response written).
727
+ * Returns `false` if no route matched (caller falls through to next middleware).
728
+ *
729
+ * Mirrors what `createServer` wires up in production via `createApiMiddleware`,
730
+ * but adapted for Vite's connect-style middleware stack — needs a Node→Web
731
+ * request adapter.
732
+ */
733
+ async function dispatchApiRoute(server, req, res) {
734
+ let apiRoutes;
735
+ try {
736
+ apiRoutes = (await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID)).apiRoutes ?? [];
737
+ } catch {
738
+ return false;
739
+ }
740
+ if (apiRoutes.length === 0) return false;
741
+ const host = req.headers.host ?? "localhost";
742
+ const url = new URL(req.url ?? "/", `http://${host}`);
743
+ const pathname = url.pathname;
744
+ if (!apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null)) return false;
745
+ const method = (req.method ?? "GET").toUpperCase();
746
+ const headers = new Headers();
747
+ for (const [key, value] of Object.entries(req.headers)) if (value !== void 0) headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
748
+ const requestInit = {
749
+ method,
750
+ headers
751
+ };
752
+ if (method !== "GET" && method !== "HEAD") {
753
+ requestInit.body = Readable.toWeb(req);
754
+ requestInit.duplex = "half";
755
+ }
756
+ const webReq = new Request(url.href, requestInit);
757
+ const response = await createApiMiddleware(apiRoutes)({
758
+ req: webReq,
759
+ url,
760
+ path: pathname + url.search,
761
+ headers: new Headers(),
762
+ locals: {}
763
+ });
764
+ if (!response) return false;
765
+ res.statusCode = response.status;
766
+ response.headers.forEach((v, k) => {
767
+ res.setHeader(k, v);
768
+ });
769
+ if (response.body) Readable.fromWeb(response.body).pipe(res);
770
+ else res.end();
771
+ return true;
772
+ }
773
+ /**
774
+ * Check if the requested path matches any route. If not, render a 404 page.
775
+ * Returns true if the 404 was handled (response sent), false otherwise.
776
+ *
777
+ * In dev mode, the _404.tsx component cannot be SSR-rendered because
778
+ * the compiler emits _tpl() calls that require `document`. Instead,
779
+ * we return a static 404 page. The actual component rendering happens
780
+ * on the client side when the SPA loads.
781
+ */
782
+ async function handle404(server, _routesDir, pathname, res) {
783
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
784
+ if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
785
+ const html = await render404Page(void 0);
786
+ res.statusCode = 404;
787
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
788
+ res.setHeader("Content-Length", Buffer.byteLength(html));
789
+ res.end(html);
790
+ return true;
791
+ }
792
+ /**
793
+ * Dev-mode SSR render pipeline. Returns the composed HTML string, or `null`
794
+ * if the URL doesn't match any known route (caller falls through to the 404
795
+ * middleware). Mirrors the production `createServer` flow:
796
+ * 1. Load virtual:zero/routes + app.ts via Vite's ssrLoadModule
797
+ * 2. Create a per-request router bound to the request URL
798
+ * 3. Pre-run loaders for the matched route(s)
799
+ * 4. Render app tree with head tag collection
800
+ * 5. Serialize loader data into `window.__PYREON_LOADER_DATA__`
801
+ * 6. Inject everything into the user's transformed index.html (so Vite
802
+ * still gets a chance to inject its HMR client + JSX runtime prelude)
803
+ */
804
+ async function renderSsr(server, root, originalUrl, pathname, req) {
805
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
806
+ if (!flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return null;
807
+ let template = await readFile(join(root, "index.html"), "utf-8");
808
+ template = await server.transformIndexHtml(originalUrl, template);
809
+ const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all([
810
+ server.ssrLoadModule("@pyreon/core"),
811
+ server.ssrLoadModule("@pyreon/head"),
812
+ server.ssrLoadModule("@pyreon/head/ssr"),
813
+ server.ssrLoadModule("@pyreon/router"),
814
+ server.ssrLoadModule("@pyreon/runtime-server")
815
+ ]);
816
+ const { App, router: routerInst } = (await server.ssrLoadModule("@pyreon/zero/server")).createApp({
817
+ routes,
818
+ routerMode: "history",
819
+ url: pathname
820
+ });
821
+ await routerInst.preload(pathname, req);
822
+ return runtimeServer.runWithRequestContext(async () => {
823
+ const app = core.h(App, null);
824
+ const { html: appHtml, head } = await headSsr.renderWithHead(app);
825
+ const loaderData = routerPkg.serializeLoaderData(routerInst);
826
+ const loaderScript = loaderData && Object.keys(loaderData).length > 0 ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}<\/script>` : "";
827
+ return template.replace("<!--pyreon-head-->", head).replace("<!--pyreon-app-->", appHtml).replace("<!--pyreon-scripts-->", loaderScript);
828
+ });
829
+ }
830
+ /**
831
+ * Extract all URL patterns from a nested route tree.
832
+ *
833
+ * The fs-router emits ABSOLUTE paths for every route, including grandchildren —
834
+ * `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
835
+ * each route's `path` as-is; no prefix accumulation. Pre-fix, this function
836
+ * concatenated `${prefix}${route.path}` which produced patterns like
837
+ * `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
838
+ * `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
839
+ * — which can't match a real `/app/dashboard` request — so dev-server returned
840
+ * 404 for every nested-layout route. Re-enables PR #411 specs that rely on
841
+ * `/app/*` routing.
842
+ */
843
+ function flattenRoutePatterns(routes) {
844
+ const patterns = [];
845
+ for (const route of routes) {
846
+ if (!route.path) continue;
847
+ patterns.push(route.path);
848
+ if (route.children) patterns.push(...flattenRoutePatterns(route.children));
849
+ }
850
+ return patterns;
851
+ }
852
+
853
+ //#endregion
854
+ export { createServer as a, resolveConfig as i, zeroPlugin as n, render404Page as o, defineConfig as r, createApp as s, vite_plugin_exports as t };
855
+ //# sourceMappingURL=vite-plugin-E4BHYvYW.js.map