@pyreon/zero 0.12.3 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.js ADDED
@@ -0,0 +1,1534 @@
1
+ import { Fragment, createContext, h } from "@pyreon/core";
2
+ import { HeadProvider } from "@pyreon/head";
3
+ import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
4
+ import { createHandler } from "@pyreon/server";
5
+ import { renderToString } from "@pyreon/runtime-server";
6
+ import { existsSync, readdirSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { signal } from "@pyreon/reactivity";
9
+
10
+ //#region src/app.ts
11
+ /**
12
+ * Create a full Zero app — assembles router, head provider, and root layout.
13
+ *
14
+ * Used internally by entry-server and entry-client.
15
+ */
16
+ function createApp(options) {
17
+ const router = createRouter({
18
+ routes: options.routes,
19
+ mode: options.routerMode ?? "history",
20
+ ...options.url ? { url: options.url } : {},
21
+ scrollBehavior: "top"
22
+ });
23
+ const Layout = options.layout ?? DefaultLayout;
24
+ function App() {
25
+ return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
26
+ }
27
+ return {
28
+ App,
29
+ router
30
+ };
31
+ }
32
+ function DefaultLayout(props) {
33
+ return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
34
+ }
35
+
36
+ //#endregion
37
+ //#region src/api-routes.ts
38
+ /**
39
+ * Match a URL path against an API route pattern.
40
+ * Returns extracted params or null if no match.
41
+ */
42
+ function matchApiRoute(pattern, path) {
43
+ const patternParts = pattern.split("/").filter(Boolean);
44
+ const pathParts = path.split("/").filter(Boolean);
45
+ const params = {};
46
+ for (let i = 0; i < patternParts.length; i++) {
47
+ const pp = patternParts[i];
48
+ if (!pp) continue;
49
+ if (pp.endsWith("*")) {
50
+ const paramName = pp.slice(1, -1);
51
+ params[paramName] = pathParts.slice(i).join("/");
52
+ return params;
53
+ }
54
+ if (i >= pathParts.length) return null;
55
+ if (pp.startsWith(":")) {
56
+ params[pp.slice(1)] = pathParts[i];
57
+ continue;
58
+ }
59
+ if (pp !== pathParts[i]) return null;
60
+ }
61
+ return patternParts.length === pathParts.length ? params : null;
62
+ }
63
+ const HTTP_METHODS = [
64
+ "GET",
65
+ "POST",
66
+ "PUT",
67
+ "PATCH",
68
+ "DELETE",
69
+ "HEAD",
70
+ "OPTIONS"
71
+ ];
72
+ /**
73
+ * Create a middleware that dispatches API route requests.
74
+ * API routes are matched by URL pattern and HTTP method.
75
+ */
76
+ function createApiMiddleware(routes) {
77
+ return async (ctx) => {
78
+ for (const route of routes) {
79
+ const params = matchApiRoute(route.pattern, ctx.path);
80
+ if (!params) continue;
81
+ const method = ctx.req.method.toUpperCase();
82
+ const handler = route.module[method];
83
+ if (!handler) {
84
+ const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
85
+ return new Response(null, {
86
+ status: 405,
87
+ headers: {
88
+ Allow: allowed,
89
+ "Content-Type": "application/json"
90
+ }
91
+ });
92
+ }
93
+ return handler({
94
+ request: ctx.req,
95
+ url: ctx.url,
96
+ path: ctx.path,
97
+ params,
98
+ headers: ctx.req.headers
99
+ });
100
+ }
101
+ };
102
+ }
103
+ /**
104
+ * Detect whether a route file is an API route.
105
+ * API routes are `.ts` or `.js` files inside an `api/` directory.
106
+ */
107
+ function isApiRoute(filePath) {
108
+ const normalized = filePath.replace(/\\/g, "/");
109
+ return normalized.startsWith("api/") && (normalized.endsWith(".ts") || normalized.endsWith(".js")) && !normalized.endsWith(".tsx") && !normalized.endsWith(".jsx");
110
+ }
111
+ /**
112
+ * Convert an API route file path to a URL pattern.
113
+ *
114
+ * Examples:
115
+ * "api/posts.ts" → "/api/posts"
116
+ * "api/posts/index.ts" → "/api/posts"
117
+ * "api/posts/[id].ts" → "/api/posts/:id"
118
+ * "api/[...path].ts" → "/api/:path*"
119
+ */
120
+ function apiFilePathToPattern(filePath) {
121
+ let route = filePath;
122
+ for (const ext of [".ts", ".js"]) if (route.endsWith(ext)) {
123
+ route = route.slice(0, -ext.length);
124
+ break;
125
+ }
126
+ const segments = route.split("/");
127
+ const urlSegments = [];
128
+ for (const seg of segments) {
129
+ if (seg === "index") continue;
130
+ const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
131
+ if (catchAll) {
132
+ urlSegments.push(`:${catchAll[1]}*`);
133
+ continue;
134
+ }
135
+ const dynamic = seg.match(/^\[(\w+)\]$/);
136
+ if (dynamic) {
137
+ urlSegments.push(`:${dynamic[1]}`);
138
+ continue;
139
+ }
140
+ urlSegments.push(seg);
141
+ }
142
+ return `/${urlSegments.join("/")}`;
143
+ }
144
+ /**
145
+ * Generate a virtual module that exports API route entries.
146
+ * Each entry maps a URL pattern to a module with HTTP method handlers.
147
+ */
148
+ function generateApiRouteModule(files, routesDir) {
149
+ const apiFiles = files.filter(isApiRoute);
150
+ if (apiFiles.length === 0) return "export const apiRoutes = []\n";
151
+ const imports = [];
152
+ const entries = [];
153
+ for (let i = 0; i < apiFiles.length; i++) {
154
+ const name = `_api${i}`;
155
+ const file = apiFiles[i];
156
+ if (!file) continue;
157
+ const fullPath = `${routesDir}/${file}`;
158
+ const pattern = apiFilePathToPattern(file);
159
+ imports.push(`import * as ${name} from "${fullPath}"`);
160
+ entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
161
+ }
162
+ return [
163
+ ...imports,
164
+ "",
165
+ "export const apiRoutes = [",
166
+ entries.join(",\n"),
167
+ "]"
168
+ ].join("\n");
169
+ }
170
+
171
+ //#endregion
172
+ //#region src/not-found.ts
173
+ const DEFAULT_404_BODY = "<h1>404 — Not Found</h1><p>The page you requested does not exist.</p>";
174
+ /**
175
+ * Render a 404 component to a full HTML string.
176
+ * If no component is provided, returns a default 404 page.
177
+ */
178
+ async function render404Page(component, template) {
179
+ let body;
180
+ if (component) body = await renderToString(h(component, null));
181
+ else body = DEFAULT_404_BODY;
182
+ if (template?.includes("<!--pyreon-app-->")) return template.replace("<!--pyreon-app-->", body);
183
+ return `<!DOCTYPE html>
184
+ <html lang="en">
185
+ <head>
186
+ <meta charset="UTF-8">
187
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
188
+ <title>404 — Not Found</title>
189
+ </head>
190
+ <body>
191
+ ${body}
192
+ </body>
193
+ </html>`;
194
+ }
195
+
196
+ //#endregion
197
+ //#region src/entry-server.ts
198
+ /**
199
+ * Create a middleware that dispatches per-route middleware based on URL pattern matching.
200
+ */
201
+ function createRouteMiddlewareDispatcher(entries) {
202
+ return async (ctx) => {
203
+ for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
204
+ const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
205
+ for (const fn of mw) {
206
+ const result = await fn(ctx);
207
+ if (result) return result;
208
+ }
209
+ }
210
+ };
211
+ }
212
+ /**
213
+ * URL pattern matcher supporting :param and :param* segments.
214
+ *
215
+ * Rules:
216
+ * - Static segments must match exactly
217
+ * - `:param` matches a single path segment
218
+ * - `:param*` matches all remaining segments (must be last, and path must
219
+ * have matched all preceding segments)
220
+ * - Path length must match pattern length (unless catch-all)
221
+ */
222
+ function matchPattern(pattern, path) {
223
+ const patternParts = pattern.split("/").filter(Boolean);
224
+ const pathParts = path.split("/").filter(Boolean);
225
+ for (let i = 0; i < patternParts.length; i++) {
226
+ const pp = patternParts[i];
227
+ if (pp.endsWith("*")) return i <= pathParts.length;
228
+ if (i >= pathParts.length) return false;
229
+ if (pp.startsWith(":")) continue;
230
+ if (pp !== pathParts[i]) return false;
231
+ }
232
+ return patternParts.length === pathParts.length;
233
+ }
234
+ /**
235
+ * Create the SSR request handler for production.
236
+ *
237
+ * @example
238
+ * import { routes } from "virtual:zero/routes"
239
+ * import { routeMiddleware } from "virtual:zero/route-middleware"
240
+ * import { createServer } from "@pyreon/zero"
241
+ *
242
+ * export default createServer({ routes, routeMiddleware, apiRoutes })
243
+ */
244
+ function createServer(options) {
245
+ const config = options.config ?? {};
246
+ const allMiddleware = [];
247
+ if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
248
+ if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
249
+ allMiddleware.push(...config.middleware ?? []);
250
+ allMiddleware.push(...options.middleware ?? []);
251
+ const { App } = createApp({
252
+ routes: options.routes,
253
+ routerMode: "history"
254
+ });
255
+ const handler = createHandler({
256
+ App,
257
+ routes: options.routes,
258
+ middleware: allMiddleware,
259
+ mode: config.ssr?.mode ?? "string",
260
+ ...options.template ? { template: options.template } : {},
261
+ ...options.clientEntry ? { clientEntry: options.clientEntry } : {}
262
+ });
263
+ if (!options.notFoundComponent) return handler;
264
+ const NotFound = options.notFoundComponent;
265
+ const routePatterns = flattenRoutePatterns$1(options.routes);
266
+ return async (req) => {
267
+ const pathname = new URL(req.url).pathname;
268
+ if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
269
+ const fullHtml = await render404Page(NotFound, options.template);
270
+ return new Response(fullHtml, {
271
+ status: 404,
272
+ headers: { "Content-Type": "text/html; charset=utf-8" }
273
+ });
274
+ }
275
+ return handler(req);
276
+ };
277
+ }
278
+ /** Extract all URL patterns from a nested route tree. */
279
+ function flattenRoutePatterns$1(routes, prefix = "") {
280
+ const patterns = [];
281
+ for (const route of routes) {
282
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
283
+ patterns.push(fullPath);
284
+ if (route.children) patterns.push(...flattenRoutePatterns$1(route.children, fullPath));
285
+ }
286
+ return patterns;
287
+ }
288
+
289
+ //#endregion
290
+ //#region src/config.ts
291
+ /**
292
+ * Define a Zero configuration.
293
+ * Used in `zero.config.ts` at the project root.
294
+ *
295
+ * @example
296
+ * import { defineConfig } from "@pyreon/zero/config"
297
+ *
298
+ * export default defineConfig({
299
+ * mode: "ssr",
300
+ * ssr: { mode: "stream" },
301
+ * port: 3000,
302
+ * })
303
+ */
304
+ function defineConfig(config) {
305
+ return config;
306
+ }
307
+ /** Merge user config with defaults. */
308
+ function resolveConfig(userConfig = {}) {
309
+ return {
310
+ mode: "ssr",
311
+ base: "/",
312
+ port: 3e3,
313
+ adapter: "node",
314
+ ...userConfig,
315
+ ssr: {
316
+ mode: "string",
317
+ ...userConfig.ssr
318
+ }
319
+ };
320
+ }
321
+
322
+ //#endregion
323
+ //#region src/fs-router.ts
324
+ const ROUTE_EXTENSIONS = [
325
+ ".tsx",
326
+ ".jsx",
327
+ ".ts",
328
+ ".js"
329
+ ];
330
+ /**
331
+ * Parse a set of file paths (relative to routes dir) into FileRoute objects.
332
+ *
333
+ * @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
334
+ * @param defaultMode Default rendering mode from config
335
+ */
336
+ function parseFileRoutes(files, defaultMode = "ssr") {
337
+ return files.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext))).map((filePath) => parseFilePath(filePath, defaultMode)).sort(sortRoutes);
338
+ }
339
+ function parseFilePath(filePath, defaultMode) {
340
+ let route = filePath;
341
+ for (const ext of ROUTE_EXTENSIONS) if (route.endsWith(ext)) {
342
+ route = route.slice(0, -ext.length);
343
+ break;
344
+ }
345
+ const fileName = getFileName(route);
346
+ const isLayout = fileName === "_layout";
347
+ const isError = fileName === "_error";
348
+ const isLoading = fileName === "_loading";
349
+ const isNotFound = fileName === "_404" || fileName === "_not-found";
350
+ const isCatchAll = route.includes("[...");
351
+ const parts = route.split("/");
352
+ parts.pop();
353
+ const dirPath = parts.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).join("/");
354
+ const urlPath = filePathToUrlPath(route);
355
+ return {
356
+ filePath,
357
+ urlPath,
358
+ dirPath,
359
+ depth: urlPath === "/" ? 0 : urlPath.split("/").filter(Boolean).length,
360
+ isLayout,
361
+ isError,
362
+ isLoading,
363
+ isNotFound,
364
+ isCatchAll,
365
+ renderMode: defaultMode
366
+ };
367
+ }
368
+ /**
369
+ * Convert a file path (without extension) to a URL path pattern.
370
+ *
371
+ * Examples:
372
+ * "index" → "/"
373
+ * "about" → "/about"
374
+ * "users/index" → "/users"
375
+ * "users/[id]" → "/users/:id"
376
+ * "blog/[...slug]" → "/blog/:slug*"
377
+ * "(auth)/login" → "/login" (group stripped)
378
+ * "_layout" → "/" (layout marker)
379
+ */
380
+ function filePathToUrlPath(filePath) {
381
+ const segments = filePath.split("/");
382
+ const urlSegments = [];
383
+ for (const seg of segments) {
384
+ if (seg.startsWith("(") && seg.endsWith(")")) continue;
385
+ if (seg === "_layout" || seg === "_error" || seg === "_loading" || seg === "_404" || seg === "_not-found") continue;
386
+ if (seg === "index") continue;
387
+ const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
388
+ if (catchAll) {
389
+ urlSegments.push(`:${catchAll[1]}*`);
390
+ continue;
391
+ }
392
+ const dynamic = seg.match(/^\[(\w+)\]$/);
393
+ if (dynamic) {
394
+ urlSegments.push(`:${dynamic[1]}`);
395
+ continue;
396
+ }
397
+ urlSegments.push(seg);
398
+ }
399
+ return `/${urlSegments.join("/")}` || "/";
400
+ }
401
+ /** Sort routes: static before dynamic, catch-all last. */
402
+ function sortRoutes(a, b) {
403
+ if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
404
+ if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1;
405
+ const aDynamic = a.urlPath.includes(":");
406
+ if (aDynamic !== b.urlPath.includes(":")) return aDynamic ? 1 : -1;
407
+ return a.urlPath.localeCompare(b.urlPath);
408
+ }
409
+ function getFileName(filePath) {
410
+ const parts = filePath.split("/");
411
+ return parts[parts.length - 1] ?? "";
412
+ }
413
+ /**
414
+ * Group flat file routes into a directory tree.
415
+ */
416
+ function getOrCreateChild(node, segment) {
417
+ let child = node.children.get(segment);
418
+ if (!child) {
419
+ child = {
420
+ pages: [],
421
+ children: /* @__PURE__ */ new Map()
422
+ };
423
+ node.children.set(segment, child);
424
+ }
425
+ return child;
426
+ }
427
+ function resolveNode(root, dirPath) {
428
+ let node = root;
429
+ if (dirPath) for (const segment of dirPath.split("/")) node = getOrCreateChild(node, segment);
430
+ return node;
431
+ }
432
+ function placeRoute(node, route) {
433
+ if (route.isLayout) node.layout = route;
434
+ else if (route.isError) node.error = route;
435
+ else if (route.isLoading) node.loading = route;
436
+ else if (route.isNotFound) node.notFound = route;
437
+ else node.pages.push(route);
438
+ }
439
+ function buildRouteTree(routes) {
440
+ const root = {
441
+ pages: [],
442
+ children: /* @__PURE__ */ new Map()
443
+ };
444
+ for (const route of routes) placeRoute(resolveNode(root, route.dirPath), route);
445
+ return root;
446
+ }
447
+ function generateRouteModule(files, routesDir, options) {
448
+ const tree = buildRouteTree(parseFileRoutes(files));
449
+ const imports = [];
450
+ let importCounter = 0;
451
+ const useStaticImports = options?.staticImports ?? false;
452
+ function nextImport(filePath, exportName = "default") {
453
+ const name = `_${importCounter++}`;
454
+ const fullPath = `${routesDir}/${filePath}`;
455
+ if (exportName === "default") imports.push(`import ${name} from "${fullPath}"`);
456
+ else imports.push(`import { ${exportName} as ${name} } from "${fullPath}"`);
457
+ return name;
458
+ }
459
+ function nextLazy(filePath, loadingName, errorName) {
460
+ const name = `_${importCounter++}`;
461
+ const fullPath = `${routesDir}/${filePath}`;
462
+ if (useStaticImports) imports.push(`import ${name} from "${fullPath}"`);
463
+ else {
464
+ const opts = [];
465
+ if (loadingName) opts.push(`loading: ${loadingName}`);
466
+ if (errorName) opts.push(`error: ${errorName}`);
467
+ const optsStr = opts.length > 0 ? `, { ${opts.join(", ")} }` : "";
468
+ imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`);
469
+ }
470
+ return name;
471
+ }
472
+ function nextModuleImport(filePath) {
473
+ const name = `_m${importCounter++}`;
474
+ const fullPath = `${routesDir}/${filePath}`;
475
+ imports.push(`import * as ${name} from "${fullPath}"`);
476
+ return name;
477
+ }
478
+ function generatePageRoute(page, indent, loadingName, errorName, notFoundName) {
479
+ const mod = nextModuleImport(page.filePath);
480
+ const comp = nextLazy(page.filePath, loadingName, errorName);
481
+ const props = [
482
+ `${indent} path: ${JSON.stringify(page.urlPath)}`,
483
+ `${indent} component: ${comp}`,
484
+ `${indent} loader: ${mod}.loader`,
485
+ `${indent} beforeEnter: ${mod}.guard`,
486
+ `${indent} meta: { ...${mod}.meta, renderMode: ${mod}.renderMode }`
487
+ ];
488
+ if (errorName) props.push(`${indent} errorComponent: ${mod}.error || ${errorName}`);
489
+ if (notFoundName) props.push(`${indent} notFoundComponent: ${notFoundName}`);
490
+ return `${indent}{\n${props.join(",\n")}\n${indent}}`;
491
+ }
492
+ function wrapWithLayout(node, children, indent, errorName, notFoundName) {
493
+ const layout = node.layout;
494
+ const layoutMod = nextModuleImport(layout.filePath);
495
+ const layoutComp = nextImport(layout.filePath, "layout");
496
+ const props = [
497
+ `${indent}path: ${JSON.stringify(layout.urlPath)}`,
498
+ `${indent}component: ${layoutComp}`,
499
+ `${indent}loader: ${layoutMod}.loader`,
500
+ `${indent}beforeEnter: ${layoutMod}.guard`,
501
+ `${indent}meta: { ...${layoutMod}.meta, renderMode: ${layoutMod}.renderMode }`
502
+ ];
503
+ if (errorName) props.push(`${indent}errorComponent: ${errorName}`);
504
+ if (notFoundName) props.push(`${indent}notFoundComponent: ${notFoundName}`);
505
+ if (children.length > 0) props.push(`${indent}children: [\n${children.join(",\n")}\n${indent}]`);
506
+ return `${indent}{\n${props.map((p) => ` ${p}`).join(",\n")}\n${indent}}`;
507
+ }
508
+ /**
509
+ * Generate route definitions for a tree node.
510
+ */
511
+ function generateNode(node, depth) {
512
+ const indent = " ".repeat(depth + 1);
513
+ const errorName = node.error ? nextImport(node.error.filePath) : void 0;
514
+ const loadingName = node.loading ? nextImport(node.loading.filePath) : void 0;
515
+ const notFoundName = node.notFound ? nextImport(node.notFound.filePath) : void 0;
516
+ const childRouteDefs = [];
517
+ for (const [, childNode] of node.children) childRouteDefs.push(...generateNode(childNode, depth + 1));
518
+ const allChildren = [...node.pages.map((page) => generatePageRoute(page, indent, loadingName, errorName, notFoundName)), ...childRouteDefs];
519
+ if (node.layout) return [wrapWithLayout(node, allChildren, indent, errorName, notFoundName)];
520
+ return allChildren;
521
+ }
522
+ const routeDefs = generateNode(tree, 0);
523
+ return [
524
+ `import { lazy } from "@pyreon/router"`,
525
+ "",
526
+ ...imports,
527
+ "",
528
+ `function clean(routes) {`,
529
+ ` return routes.map(r => {`,
530
+ ` const c = {}`,
531
+ ` for (const k in r) if (r[k] !== undefined) c[k] = r[k]`,
532
+ ` if (c.children) c.children = clean(c.children)`,
533
+ ` return c`,
534
+ ` })`,
535
+ `}`,
536
+ "",
537
+ `export const routes = clean([`,
538
+ routeDefs.join(",\n"),
539
+ `])`
540
+ ].join("\n");
541
+ }
542
+ /**
543
+ * Generate a virtual module that maps URL patterns to their middleware exports.
544
+ * Used by the server entry to dispatch per-route middleware.
545
+ */
546
+ function generateMiddlewareModule(files, routesDir) {
547
+ const routes = parseFileRoutes(files);
548
+ const imports = [];
549
+ const entries = [];
550
+ let counter = 0;
551
+ for (const route of routes) {
552
+ if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue;
553
+ const name = `_mw${counter++}`;
554
+ const fullPath = `${routesDir}/${route.filePath}`;
555
+ imports.push(`import { middleware as ${name} } from "${fullPath}"`);
556
+ entries.push(` { pattern: ${JSON.stringify(route.urlPath)}, middleware: ${name} }`);
557
+ }
558
+ return [
559
+ ...imports,
560
+ "",
561
+ `export const routeMiddleware = [`,
562
+ entries.join(",\n"),
563
+ `].filter(e => e.middleware)`
564
+ ].join("\n");
565
+ }
566
+ /**
567
+ * Scan a directory for route files.
568
+ * Returns paths relative to the routes directory.
569
+ */
570
+ async function scanRouteFiles(routesDir) {
571
+ const { readdir } = await import("node:fs/promises");
572
+ const { join, relative } = await import("node:path");
573
+ const files = [];
574
+ async function walk(dir) {
575
+ const entries = await readdir(dir, { withFileTypes: true });
576
+ for (const entry of entries) {
577
+ const fullPath = join(dir, entry.name);
578
+ if (entry.isDirectory()) await walk(fullPath);
579
+ else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) files.push(relative(routesDir, fullPath));
580
+ }
581
+ }
582
+ await walk(routesDir);
583
+ return files;
584
+ }
585
+
586
+ //#endregion
587
+ //#region src/isr.ts
588
+ /**
589
+ * In-memory ISR cache with stale-while-revalidate semantics.
590
+ *
591
+ * Wraps an SSR handler and caches responses per URL path.
592
+ * Serves stale content immediately while revalidating in the background.
593
+ */
594
+ function createISRHandler(handler, config) {
595
+ const cache = /* @__PURE__ */ new Map();
596
+ const revalidating = /* @__PURE__ */ new Set();
597
+ const revalidateMs = config.revalidate * 1e3;
598
+ async function revalidate(url) {
599
+ const key = url.pathname;
600
+ if (revalidating.has(key)) return;
601
+ revalidating.add(key);
602
+ try {
603
+ const res = await handler(new Request(url.href, { method: "GET" }));
604
+ const html = await res.text();
605
+ const headers = {};
606
+ res.headers.forEach((v, k) => {
607
+ headers[k] = v;
608
+ });
609
+ cache.set(key, {
610
+ html,
611
+ headers,
612
+ timestamp: Date.now()
613
+ });
614
+ } catch {} finally {
615
+ revalidating.delete(key);
616
+ }
617
+ }
618
+ return async (req) => {
619
+ if (req.method !== "GET") return handler(req);
620
+ const url = new URL(req.url);
621
+ const key = url.pathname;
622
+ const entry = cache.get(key);
623
+ if (entry) {
624
+ const age = Date.now() - entry.timestamp;
625
+ if (age > revalidateMs) revalidate(url);
626
+ return new Response(entry.html, {
627
+ status: 200,
628
+ headers: {
629
+ ...entry.headers,
630
+ "content-type": "text/html; charset=utf-8",
631
+ "x-isr-cache": age > revalidateMs ? "STALE" : "HIT",
632
+ "x-isr-age": String(Math.round(age / 1e3))
633
+ }
634
+ });
635
+ }
636
+ const res = await handler(req);
637
+ const html = await res.text();
638
+ const headers = {};
639
+ res.headers.forEach((v, k) => {
640
+ headers[k] = v;
641
+ });
642
+ cache.set(key, {
643
+ html,
644
+ headers,
645
+ timestamp: Date.now()
646
+ });
647
+ return new Response(html, {
648
+ status: 200,
649
+ headers: {
650
+ ...headers,
651
+ "content-type": "text/html; charset=utf-8",
652
+ "x-isr-cache": "MISS"
653
+ }
654
+ });
655
+ };
656
+ }
657
+
658
+ //#endregion
659
+ //#region src/adapters/validate.ts
660
+ /**
661
+ * Validate that adapter build inputs exist before copying.
662
+ * Throws with a clear error message if directories are missing.
663
+ * @internal
664
+ */
665
+ async function validateBuildInputs(options) {
666
+ const { existsSync } = await import("node:fs");
667
+ if (!existsSync(options.clientOutDir)) throw new Error(`[zero:adapter] Client build output not found: ${options.clientOutDir}. Run "vite build" first.`);
668
+ if (!existsSync(options.serverEntry)) throw new Error(`[zero:adapter] Server entry not found: ${options.serverEntry}. Run "vite build --ssr" first.`);
669
+ }
670
+
671
+ //#endregion
672
+ //#region src/adapters/bun.ts
673
+ /**
674
+ * Bun adapter — generates a standalone Bun.serve() entry.
675
+ */
676
+ function bunAdapter() {
677
+ return {
678
+ name: "bun",
679
+ async build(options) {
680
+ await validateBuildInputs(options);
681
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
682
+ const { join } = await import("node:path");
683
+ const outDir = options.outDir;
684
+ await mkdir(outDir, { recursive: true });
685
+ await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
686
+ await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
687
+ const port = options.config.port ?? 3e3;
688
+ const serverEntry = `
689
+ const handler = (await import("./server/entry-server.js")).default
690
+ const clientDir = new URL("./client/", import.meta.url).pathname
691
+
692
+ Bun.serve({
693
+ port: ${port},
694
+ async fetch(req) {
695
+ const url = new URL(req.url)
696
+
697
+ // Try static files first
698
+ if (req.method === "GET") {
699
+ const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
700
+ // Prevent path traversal — ensure resolved path stays within clientDir
701
+ const resolved = Bun.resolveSync(filePath, ".")
702
+ if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
703
+ return new Response("Forbidden", { status: 403 })
704
+ }
705
+ const file = Bun.file(filePath)
706
+ if (await file.exists()) {
707
+ return new Response(file, {
708
+ headers: {
709
+ "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
710
+ ? "public, max-age=31536000, immutable"
711
+ : "public, max-age=3600",
712
+ },
713
+ })
714
+ }
715
+ }
716
+
717
+ // Fall through to SSR handler
718
+ return handler(req)
719
+ },
720
+ })
721
+
722
+ console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
723
+ `.trimStart();
724
+ await writeFile(join(outDir, "index.ts"), serverEntry);
725
+ }
726
+ };
727
+ }
728
+
729
+ //#endregion
730
+ //#region src/adapters/cloudflare.ts
731
+ /**
732
+ * Cloudflare Pages adapter — generates output for Cloudflare Pages with Functions.
733
+ *
734
+ * Produces:
735
+ * - Client assets in the output directory root (served as static)
736
+ * - `_worker.js` — Cloudflare Pages Function for SSR
737
+ *
738
+ * Note: Cloudflare Pages Functions have a ~1MB module size limit.
739
+ * For large apps, configure Vite's SSR build to bundle server code:
740
+ * `ssr: { noExternal: true }` in vite.config.ts.
741
+ *
742
+ * Deploy with: `npx wrangler pages deploy ./dist`
743
+ *
744
+ * @example
745
+ * ```ts
746
+ * // zero.config.ts
747
+ * import { defineConfig } from "@pyreon/zero"
748
+ *
749
+ * export default defineConfig({
750
+ * adapter: "cloudflare",
751
+ * })
752
+ * ```
753
+ */
754
+ function cloudflareAdapter() {
755
+ return {
756
+ name: "cloudflare",
757
+ async build(options) {
758
+ await validateBuildInputs(options);
759
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
760
+ const { join } = await import("node:path");
761
+ const outDir = options.outDir;
762
+ await mkdir(outDir, { recursive: true });
763
+ await cp(options.clientOutDir, outDir, { recursive: true });
764
+ await cp(join(options.serverEntry, ".."), join(outDir, "_server"), { recursive: true });
765
+ const workerEntry = `
766
+ import handler from "./_server/entry-server.js"
767
+
768
+ export default {
769
+ async fetch(request, env, ctx) {
770
+ const url = new URL(request.url)
771
+
772
+ // Let Cloudflare serve static assets (files with extensions)
773
+ // This check is a fallback — Pages routes static files automatically
774
+ const ext = url.pathname.split(".").pop()
775
+ if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
776
+ // Cloudflare Pages handles static assets automatically via its asset binding
777
+ // Only reach here if the file doesn't exist — fall through to SSR
778
+ }
779
+
780
+ // SSR handler
781
+ try {
782
+ return await handler(request)
783
+ } catch (err) {
784
+ return new Response("Internal Server Error", { status: 500 })
785
+ }
786
+ },
787
+ }
788
+ `.trimStart();
789
+ await writeFile(join(outDir, "_worker.js"), workerEntry);
790
+ await writeFile(join(outDir, "_routes.json"), JSON.stringify({
791
+ version: 1,
792
+ include: ["/*"],
793
+ exclude: [
794
+ "/assets/*",
795
+ "/favicon.*",
796
+ "/site.webmanifest",
797
+ "/robots.txt",
798
+ "/sitemap.xml"
799
+ ]
800
+ }, null, 2));
801
+ }
802
+ };
803
+ }
804
+
805
+ //#endregion
806
+ //#region src/adapters/netlify.ts
807
+ /**
808
+ * Netlify adapter — generates output for Netlify Functions (v2).
809
+ *
810
+ * Produces:
811
+ * - Client assets in `publish/` directory
812
+ * - `netlify/functions/ssr.mjs` — Netlify Function for SSR
813
+ * - `netlify.toml` — routing configuration
814
+ *
815
+ * @example
816
+ * ```ts
817
+ * // zero.config.ts
818
+ * import { defineConfig } from "@pyreon/zero"
819
+ *
820
+ * export default defineConfig({
821
+ * adapter: "netlify",
822
+ * })
823
+ * ```
824
+ */
825
+ function netlifyAdapter() {
826
+ return {
827
+ name: "netlify",
828
+ async build(options) {
829
+ await validateBuildInputs(options);
830
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
831
+ const { join } = await import("node:path");
832
+ const outDir = options.outDir;
833
+ const publishDir = join(outDir, "publish");
834
+ const functionsDir = join(outDir, "netlify", "functions");
835
+ await mkdir(publishDir, { recursive: true });
836
+ await mkdir(functionsDir, { recursive: true });
837
+ await cp(options.clientOutDir, publishDir, { recursive: true });
838
+ await cp(join(options.serverEntry, ".."), join(functionsDir, "_server"), { recursive: true });
839
+ const funcEntry = `
840
+ import handler from "./_server/entry-server.js"
841
+
842
+ export default async function(req, context) {
843
+ try {
844
+ return await handler(req)
845
+ } catch (err) {
846
+ return new Response("Internal Server Error", { status: 500 })
847
+ }
848
+ }
849
+
850
+ export const config = {
851
+ path: "/*",
852
+ preferStatic: true,
853
+ }
854
+ `.trimStart();
855
+ await writeFile(join(functionsDir, "ssr.mjs"), funcEntry);
856
+ const toml = `
857
+ [build]
858
+ publish = "publish"
859
+ functions = "netlify/functions"
860
+
861
+ [[headers]]
862
+ for = "/assets/*"
863
+ [headers.values]
864
+ Cache-Control = "public, max-age=31536000, immutable"
865
+
866
+ [[redirects]]
867
+ from = "/*"
868
+ to = "/.netlify/functions/ssr"
869
+ status = 200
870
+ conditions = {Role = ["admin", "user", ""]}
871
+ `.trimStart();
872
+ await writeFile(join(outDir, "netlify.toml"), toml);
873
+ }
874
+ };
875
+ }
876
+
877
+ //#endregion
878
+ //#region src/adapters/node.ts
879
+ /**
880
+ * Node.js adapter — generates a standalone server entry using node:http.
881
+ */
882
+ function nodeAdapter() {
883
+ return {
884
+ name: "node",
885
+ async build(options) {
886
+ await validateBuildInputs(options);
887
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
888
+ const { join } = await import("node:path");
889
+ const outDir = options.outDir;
890
+ await mkdir(outDir, { recursive: true });
891
+ await cp(options.clientOutDir, join(outDir, "client"), { recursive: true });
892
+ await cp(join(options.serverEntry, ".."), join(outDir, "server"), { recursive: true });
893
+ const port = options.config.port ?? 3e3;
894
+ const serverEntry = `
895
+ import { createServer } from "node:http"
896
+ import { readFile } from "node:fs/promises"
897
+ import { join, extname } from "node:path"
898
+ import { fileURLToPath } from "node:url"
899
+
900
+ const __dirname = fileURLToPath(new URL(".", import.meta.url))
901
+ const handler = (await import("./server/entry-server.js")).default
902
+ const clientDir = join(__dirname, "client")
903
+
904
+ const MIME_TYPES = {
905
+ ".html": "text/html",
906
+ ".js": "application/javascript",
907
+ ".css": "text/css",
908
+ ".json": "application/json",
909
+ ".png": "image/png",
910
+ ".jpg": "image/jpeg",
911
+ ".svg": "image/svg+xml",
912
+ ".woff2": "font/woff2",
913
+ ".woff": "font/woff",
914
+ ".ico": "image/x-icon",
915
+ }
916
+
917
+ const server = createServer(async (req, res) => {
918
+ const url = new URL(req.url ?? "/", "http://localhost")
919
+
920
+ // Try to serve static files first
921
+ if (req.method === "GET") {
922
+ try {
923
+ const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
924
+ // Prevent path traversal — ensure resolved path stays within clientDir
925
+ const { resolve } = await import("node:path")
926
+ const resolved = resolve(filePath)
927
+ if (!resolved.startsWith(resolve(clientDir))) {
928
+ res.writeHead(403)
929
+ res.end("Forbidden")
930
+ return
931
+ }
932
+ const ext = extname(filePath)
933
+ if (ext && ext !== ".html") {
934
+ const data = await readFile(filePath)
935
+ const mime = MIME_TYPES[ext] || "application/octet-stream"
936
+ res.writeHead(200, {
937
+ "content-type": mime,
938
+ "cache-control": ext === ".js" || ext === ".css"
939
+ ? "public, max-age=31536000, immutable"
940
+ : "public, max-age=3600",
941
+ })
942
+ res.end(data)
943
+ return
944
+ }
945
+ } catch {}
946
+ }
947
+
948
+ // Fall through to SSR handler
949
+ const headers = {}
950
+ for (const [key, value] of Object.entries(req.headers)) {
951
+ if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
952
+ }
953
+
954
+ const request = new Request(url.href, {
955
+ method: req.method,
956
+ headers,
957
+ })
958
+
959
+ const response = await handler(request)
960
+ const body = await response.text()
961
+
962
+ const responseHeaders = {}
963
+ response.headers.forEach((v, k) => { responseHeaders[k] = v })
964
+
965
+ res.writeHead(response.status, responseHeaders)
966
+ res.end(body)
967
+ })
968
+
969
+ server.listen(${port}, () => {
970
+ console.log("\\n ⚡ Zero production server running on http://localhost:${port}\\n")
971
+ })
972
+ `.trimStart();
973
+ await writeFile(join(outDir, "index.js"), serverEntry);
974
+ await writeFile(join(outDir, "package.json"), JSON.stringify({ type: "module" }, null, 2));
975
+ }
976
+ };
977
+ }
978
+
979
+ //#endregion
980
+ //#region src/adapters/static.ts
981
+ /**
982
+ * Static adapter — just copies the client build output.
983
+ * Used with SSG mode where all pages are pre-rendered at build time.
984
+ */
985
+ function staticAdapter() {
986
+ return {
987
+ name: "static",
988
+ async build(options) {
989
+ const { cp, mkdir } = await import("node:fs/promises");
990
+ await mkdir(options.outDir, { recursive: true });
991
+ await cp(options.clientOutDir, options.outDir, { recursive: true });
992
+ }
993
+ };
994
+ }
995
+
996
+ //#endregion
997
+ //#region src/adapters/vercel.ts
998
+ /**
999
+ * Vercel adapter — generates output for Vercel's Build Output API v3.
1000
+ *
1001
+ * Produces a `.vercel/output` directory with:
1002
+ * - `static/` — client-side assets (JS, CSS, images)
1003
+ * - `functions/ssr.func/` — serverless function for SSR
1004
+ * - `config.json` — routing configuration
1005
+ *
1006
+ * @example
1007
+ * ```ts
1008
+ * // zero.config.ts
1009
+ * import { defineConfig } from "@pyreon/zero"
1010
+ *
1011
+ * export default defineConfig({
1012
+ * adapter: "vercel",
1013
+ * })
1014
+ * ```
1015
+ */
1016
+ function vercelAdapter() {
1017
+ return {
1018
+ name: "vercel",
1019
+ async build(options) {
1020
+ await validateBuildInputs(options);
1021
+ const { writeFile, cp, mkdir } = await import("node:fs/promises");
1022
+ const { join } = await import("node:path");
1023
+ const vercelDir = join(options.outDir, ".vercel", "output");
1024
+ const staticDir = join(vercelDir, "static");
1025
+ const funcDir = join(vercelDir, "functions", "ssr.func");
1026
+ await mkdir(staticDir, { recursive: true });
1027
+ await mkdir(funcDir, { recursive: true });
1028
+ await cp(options.clientOutDir, staticDir, { recursive: true });
1029
+ await cp(join(options.serverEntry, ".."), funcDir, { recursive: true });
1030
+ const funcEntry = `
1031
+ export default async function handler(req) {
1032
+ const handler = (await import("./entry-server.js")).default
1033
+ return handler(req)
1034
+ }
1035
+ `.trimStart();
1036
+ await writeFile(join(funcDir, "index.js"), funcEntry);
1037
+ await writeFile(join(funcDir, ".vc-config.json"), JSON.stringify({
1038
+ runtime: "nodejs20.x",
1039
+ handler: "index.js",
1040
+ launcherType: "Nodejs"
1041
+ }, null, 2));
1042
+ await writeFile(join(vercelDir, "config.json"), JSON.stringify({
1043
+ version: 3,
1044
+ routes: [
1045
+ {
1046
+ src: "/assets/(.*)",
1047
+ headers: { "Cache-Control": "public, max-age=31536000, immutable" }
1048
+ },
1049
+ {
1050
+ src: "/(favicon\\..*|site\\.webmanifest|robots\\.txt|sitemap\\.xml)",
1051
+ dest: "/$1"
1052
+ },
1053
+ {
1054
+ src: "/(.*)",
1055
+ dest: "/ssr"
1056
+ }
1057
+ ]
1058
+ }, null, 2));
1059
+ }
1060
+ };
1061
+ }
1062
+
1063
+ //#endregion
1064
+ //#region src/adapters/index.ts
1065
+ /**
1066
+ * Resolve the adapter from config.
1067
+ * Returns a built-in adapter or throws if unknown.
1068
+ */
1069
+ function resolveAdapter(config) {
1070
+ const name = config.adapter ?? "node";
1071
+ switch (name) {
1072
+ case "node": return nodeAdapter();
1073
+ case "bun": return bunAdapter();
1074
+ case "static": return staticAdapter();
1075
+ case "vercel": return vercelAdapter();
1076
+ case "cloudflare": return cloudflareAdapter();
1077
+ case "netlify": return netlifyAdapter();
1078
+ default: throw new Error(`[zero] Unknown adapter: "${name}". Use "node", "bun", "static", "vercel", "cloudflare", or "netlify".`);
1079
+ }
1080
+ }
1081
+
1082
+ //#endregion
1083
+ //#region src/middleware.ts
1084
+ /**
1085
+ * Compose multiple middleware into a single middleware function.
1086
+ * Middleware runs sequentially — if any returns a Response, the chain stops.
1087
+ *
1088
+ * @example
1089
+ * import { compose } from "@pyreon/zero/middleware"
1090
+ * import { corsMiddleware } from "@pyreon/zero/cors"
1091
+ * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
1092
+ *
1093
+ * const combined = compose(
1094
+ * corsMiddleware({ origin: "*" }),
1095
+ * rateLimitMiddleware({ max: 100 }),
1096
+ * cacheMiddleware(),
1097
+ * )
1098
+ */
1099
+ function compose(...middlewares) {
1100
+ return async (ctx) => {
1101
+ for (const mw of middlewares) {
1102
+ const result = await mw(ctx);
1103
+ if (result instanceof Response) return result;
1104
+ }
1105
+ };
1106
+ }
1107
+ const ZERO_CTX_KEY = "__zeroCtx";
1108
+ /**
1109
+ * Get the shared Zero context from a middleware context.
1110
+ * Creates one if it doesn't exist. Middleware can use this to
1111
+ * pass data to downstream middleware without polluting `ctx.locals`.
1112
+ *
1113
+ * @example
1114
+ * const authMiddleware: Middleware = (ctx) => {
1115
+ * const zctx = getContext(ctx)
1116
+ * zctx.userId = "user_123"
1117
+ * }
1118
+ *
1119
+ * const loggingMiddleware: Middleware = (ctx) => {
1120
+ * const zctx = getContext(ctx)
1121
+ * console.log("User:", zctx.userId)
1122
+ * }
1123
+ */
1124
+ function getContext(ctx) {
1125
+ let zctx = ctx.locals[ZERO_CTX_KEY];
1126
+ if (!zctx) {
1127
+ zctx = {};
1128
+ ctx.locals[ZERO_CTX_KEY] = zctx;
1129
+ }
1130
+ return zctx;
1131
+ }
1132
+
1133
+ //#endregion
1134
+ //#region src/error-overlay.ts
1135
+ /**
1136
+ * Dev-only error overlay for SSR/loader errors.
1137
+ * Renders a styled HTML page with the error stack trace.
1138
+ */
1139
+ function renderErrorOverlay(error) {
1140
+ return `<!DOCTYPE html>
1141
+ <html lang="en">
1142
+ <head>
1143
+ <meta charset="UTF-8">
1144
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1145
+ <title>SSR Error — Pyreon Zero</title>
1146
+ <style>
1147
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1148
+ body {
1149
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
1150
+ background: #1a1a2e;
1151
+ color: #e0e0e0;
1152
+ min-height: 100vh;
1153
+ padding: 2rem;
1154
+ }
1155
+ .overlay {
1156
+ max-width: 900px;
1157
+ margin: 0 auto;
1158
+ }
1159
+ .header {
1160
+ display: flex;
1161
+ align-items: center;
1162
+ gap: 0.75rem;
1163
+ margin-bottom: 1.5rem;
1164
+ }
1165
+ .badge {
1166
+ background: #e74c3c;
1167
+ color: white;
1168
+ padding: 0.25rem 0.75rem;
1169
+ border-radius: 4px;
1170
+ font-size: 0.75rem;
1171
+ font-weight: 600;
1172
+ text-transform: uppercase;
1173
+ letter-spacing: 0.05em;
1174
+ }
1175
+ .label {
1176
+ color: #888;
1177
+ font-size: 0.85rem;
1178
+ }
1179
+ .message {
1180
+ font-size: 1.25rem;
1181
+ color: #ff6b6b;
1182
+ margin-bottom: 1.5rem;
1183
+ line-height: 1.5;
1184
+ word-break: break-word;
1185
+ }
1186
+ .stack {
1187
+ background: #16213e;
1188
+ border: 1px solid #2a2a4a;
1189
+ border-radius: 8px;
1190
+ padding: 1.25rem;
1191
+ overflow-x: auto;
1192
+ font-size: 0.8rem;
1193
+ line-height: 1.7;
1194
+ white-space: pre-wrap;
1195
+ word-break: break-all;
1196
+ }
1197
+ .stack .at { color: #888; }
1198
+ .stack .file { color: #4ecdc4; }
1199
+ .hint {
1200
+ margin-top: 1.5rem;
1201
+ padding: 1rem;
1202
+ background: #1e2a45;
1203
+ border-radius: 6px;
1204
+ border-left: 3px solid #3498db;
1205
+ font-size: 0.8rem;
1206
+ color: #aaa;
1207
+ line-height: 1.5;
1208
+ }
1209
+ </style>
1210
+ </head>
1211
+ <body>
1212
+ <div class="overlay">
1213
+ <div class="header">
1214
+ <span class="badge">SSR Error</span>
1215
+ <span class="label">Pyreon Zero — Dev Mode</span>
1216
+ </div>
1217
+ <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
1218
+ <pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
1219
+ <div class="hint">
1220
+ This error occurred during server-side rendering. Check the terminal for
1221
+ the full stack trace. This overlay is only shown in development.
1222
+ </div>
1223
+ </div>
1224
+ </body>
1225
+ </html>`;
1226
+ }
1227
+ function escapeHtml(str) {
1228
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1229
+ }
1230
+ function formatStack(stack) {
1231
+ return stack.split("\n").map((line) => {
1232
+ if (line.includes("at ")) {
1233
+ const fileMatch = line.match(/\(([^)]+)\)/);
1234
+ if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
1235
+ }
1236
+ return line;
1237
+ }).join("\n");
1238
+ }
1239
+
1240
+ //#endregion
1241
+ //#region src/vite-plugin.ts
1242
+ /**
1243
+ * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
1244
+ * Returns package names to exclude from Vite's dep optimizer.
1245
+ */
1246
+ function scanPyreonPackages(root) {
1247
+ const pyreonDir = join(root, "node_modules", "@pyreon");
1248
+ if (!existsSync(pyreonDir)) return [];
1249
+ try {
1250
+ return readdirSync(pyreonDir).filter((name) => !name.startsWith(".")).map((name) => `@pyreon/${name}`);
1251
+ } catch {
1252
+ return [];
1253
+ }
1254
+ }
1255
+ const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
1256
+ const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
1257
+ const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
1258
+ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
1259
+ const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
1260
+ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
1261
+ /**
1262
+ * Zero Vite plugin — adds file-based routing and zero-config conventions
1263
+ * on top of @pyreon/vite-plugin.
1264
+ *
1265
+ * @example
1266
+ * // vite.config.ts
1267
+ * import pyreon from "@pyreon/vite-plugin"
1268
+ * import zero from "@pyreon/zero"
1269
+ *
1270
+ * export default {
1271
+ * plugins: [pyreon(), zero()],
1272
+ * }
1273
+ */
1274
+ function zeroPlugin(userConfig = {}) {
1275
+ const config = resolveConfig(userConfig);
1276
+ let routesDir;
1277
+ let root;
1278
+ return {
1279
+ name: "pyreon-zero",
1280
+ enforce: "pre",
1281
+ _zeroConfig: userConfig,
1282
+ configResolved(resolvedConfig) {
1283
+ root = resolvedConfig.root;
1284
+ routesDir = `${root}/src/routes`;
1285
+ },
1286
+ resolveId(id) {
1287
+ if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
1288
+ if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
1289
+ if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
1290
+ },
1291
+ async load(id) {
1292
+ if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
1293
+ return generateRouteModule(await scanRouteFiles(routesDir), routesDir, { staticImports: config.mode === "ssg" });
1294
+ } catch (_err) {
1295
+ return `export const routes = []`;
1296
+ }
1297
+ if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
1298
+ return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
1299
+ } catch (_err) {
1300
+ return `export const routeMiddleware = []`;
1301
+ }
1302
+ if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
1303
+ return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
1304
+ } catch (_err) {
1305
+ return `export const apiRoutes = []`;
1306
+ }
1307
+ },
1308
+ configureServer(server) {
1309
+ server.middlewares.use((req, res, next) => {
1310
+ const accept = req.headers.accept ?? "";
1311
+ if (!accept.includes("text/html") && !accept.includes("*/*")) return next();
1312
+ const pathname = req.url?.split("?")[0] ?? "/";
1313
+ if (pathname.startsWith("/@") || pathname.startsWith("/__")) return next();
1314
+ if (/\.\w+$/.test(pathname)) return next();
1315
+ handle404(server, routesDir, pathname, res).then((handled) => {
1316
+ if (!handled) next();
1317
+ }, (err) => {
1318
+ console.error("[zero] Error in 404 handler:", err);
1319
+ next();
1320
+ });
1321
+ });
1322
+ server.middlewares.use((req, res, next) => {
1323
+ if (!(req.headers.accept ?? "").includes("text/html")) return next();
1324
+ const originalEnd = res.end.bind(res);
1325
+ let errored = false;
1326
+ const handleError = (err) => {
1327
+ if (errored) return;
1328
+ errored = true;
1329
+ const error = err instanceof Error ? err : new Error(String(err));
1330
+ server.ssrFixStacktrace(error);
1331
+ const html = renderErrorOverlay(error);
1332
+ res.statusCode = 500;
1333
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1334
+ res.setHeader("Content-Length", Buffer.byteLength(html));
1335
+ originalEnd(html);
1336
+ };
1337
+ res.on("error", handleError);
1338
+ try {
1339
+ const result = next();
1340
+ if (result && typeof result.catch === "function") result.catch(handleError);
1341
+ } catch (err) {
1342
+ handleError(err);
1343
+ }
1344
+ });
1345
+ server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
1346
+ server.watcher.on("all", (event, path) => {
1347
+ if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
1348
+ for (const resolvedId of [
1349
+ RESOLVED_VIRTUAL_ROUTES_ID,
1350
+ RESOLVED_VIRTUAL_MIDDLEWARE_ID,
1351
+ RESOLVED_VIRTUAL_API_ROUTES_ID
1352
+ ]) {
1353
+ const mod = server.moduleGraph.getModuleById(resolvedId);
1354
+ if (mod) server.moduleGraph.invalidateModule(mod);
1355
+ }
1356
+ server.ws.send({ type: "full-reload" });
1357
+ }
1358
+ });
1359
+ },
1360
+ config(userConfig) {
1361
+ return {
1362
+ resolve: { conditions: ["bun"] },
1363
+ optimizeDeps: { exclude: scanPyreonPackages(userConfig.root ?? process.cwd()) },
1364
+ server: { port: config.port },
1365
+ define: {
1366
+ __ZERO_MODE__: JSON.stringify(config.mode),
1367
+ __ZERO_BASE__: JSON.stringify(config.base)
1368
+ }
1369
+ };
1370
+ }
1371
+ };
1372
+ }
1373
+ /**
1374
+ * Check if the requested path matches any route. If not, render a 404 page.
1375
+ * Returns true if the 404 was handled (response sent), false otherwise.
1376
+ *
1377
+ * In dev mode, the _404.tsx component cannot be SSR-rendered because
1378
+ * the compiler emits _tpl() calls that require `document`. Instead,
1379
+ * we return a static 404 page. The actual component rendering happens
1380
+ * on the client side when the SPA loads.
1381
+ */
1382
+ async function handle404(server, _routesDir, pathname, res) {
1383
+ const routes = (await server.ssrLoadModule(VIRTUAL_ROUTES_ID)).routes;
1384
+ if (flattenRoutePatterns(routes).some((pattern) => matchPattern(pattern, pathname))) return false;
1385
+ const html = await render404Page(void 0);
1386
+ res.statusCode = 404;
1387
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
1388
+ res.setHeader("Content-Length", Buffer.byteLength(html));
1389
+ res.end(html);
1390
+ return true;
1391
+ }
1392
+ /** Extract all URL patterns from a nested route tree. */
1393
+ function flattenRoutePatterns(routes, prefix = "") {
1394
+ const patterns = [];
1395
+ for (const route of routes) {
1396
+ if (!route.path) continue;
1397
+ const fullPath = route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
1398
+ patterns.push(fullPath);
1399
+ if (route.children) patterns.push(...flattenRoutePatterns(route.children, fullPath));
1400
+ }
1401
+ return patterns;
1402
+ }
1403
+
1404
+ //#endregion
1405
+ //#region src/i18n-routing.ts
1406
+ /**
1407
+ * Detect preferred locale from Accept-Language header.
1408
+ */
1409
+ function detectLocaleFromHeader(acceptLanguage, locales, defaultLocale) {
1410
+ if (!acceptLanguage) return defaultLocale;
1411
+ const preferred = acceptLanguage.split(",").map((part) => {
1412
+ const [lang, q] = part.trim().split(";q=");
1413
+ return {
1414
+ lang: lang?.split("-")[0]?.toLowerCase() ?? "",
1415
+ quality: q ? Number.parseFloat(q) : 1
1416
+ };
1417
+ }).sort((a, b) => b.quality - a.quality);
1418
+ for (const { lang } of preferred) if (locales.includes(lang)) return lang;
1419
+ return defaultLocale;
1420
+ }
1421
+ /**
1422
+ * Extract locale from a URL path.
1423
+ * Returns { locale, pathWithoutLocale }.
1424
+ */
1425
+ function extractLocaleFromPath(path, locales, defaultLocale) {
1426
+ const segments = path.split("/").filter(Boolean);
1427
+ const firstSegment = segments[0]?.toLowerCase();
1428
+ if (firstSegment && locales.includes(firstSegment)) return {
1429
+ locale: firstSegment,
1430
+ pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
1431
+ };
1432
+ return {
1433
+ locale: defaultLocale,
1434
+ pathWithoutLocale: path
1435
+ };
1436
+ }
1437
+ /**
1438
+ * Build a localized path.
1439
+ */
1440
+ function buildLocalePath(path, locale, defaultLocale, strategy) {
1441
+ const clean = path === "/" ? "" : path;
1442
+ if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
1443
+ return `/${locale}${clean}`;
1444
+ }
1445
+ /**
1446
+ * Create a LocaleContext for use in components and loaders.
1447
+ */
1448
+ function createLocaleContext(locale, path, config) {
1449
+ const strategy = config.strategy ?? "prefix-except-default";
1450
+ return {
1451
+ locale,
1452
+ locales: config.locales,
1453
+ defaultLocale: config.defaultLocale,
1454
+ localePath(targetPath, targetLocale) {
1455
+ return buildLocalePath(targetPath, targetLocale ?? locale, config.defaultLocale, strategy);
1456
+ },
1457
+ alternates() {
1458
+ const { pathWithoutLocale } = extractLocaleFromPath(path, config.locales, config.defaultLocale);
1459
+ return config.locales.map((loc) => ({
1460
+ locale: loc,
1461
+ url: buildLocalePath(pathWithoutLocale, loc, config.defaultLocale, strategy)
1462
+ }));
1463
+ }
1464
+ };
1465
+ }
1466
+ /**
1467
+ * I18n routing middleware for Zero's server.
1468
+ *
1469
+ * - Detects locale from URL prefix or Accept-Language header
1470
+ * - Redirects root to preferred locale (when detectLocale is true)
1471
+ * - Sets locale context for loaders and components
1472
+ *
1473
+ * @example
1474
+ * ```ts
1475
+ * // zero.config.ts
1476
+ * import { i18nRouting } from "@pyreon/zero"
1477
+ *
1478
+ * export default defineConfig({
1479
+ * plugins: [
1480
+ * i18nRouting({
1481
+ * locales: ["en", "de", "cs"],
1482
+ * defaultLocale: "en",
1483
+ * }),
1484
+ * ],
1485
+ * })
1486
+ * ```
1487
+ */
1488
+ function i18nRouting(config) {
1489
+ const strategy = config.strategy ?? "prefix-except-default";
1490
+ const detectEnabled = config.detectLocale !== false;
1491
+ const cookieName = config.cookieName ?? "locale";
1492
+ return {
1493
+ name: "pyreon-zero-i18n-routing",
1494
+ configResolved() {},
1495
+ configureServer(server) {
1496
+ server.middlewares.use((req, res, next) => {
1497
+ const url = req.url ?? "/";
1498
+ if (url.startsWith("/@") || url.startsWith("/__") || url.includes(".")) return next();
1499
+ const { locale } = extractLocaleFromPath(url, config.locales, config.defaultLocale);
1500
+ if (detectEnabled && url === "/") {
1501
+ const preferredFromCookie = parseCookies(req.headers.cookie)[cookieName];
1502
+ const preferredFromHeader = detectLocaleFromHeader(req.headers["accept-language"], config.locales, config.defaultLocale);
1503
+ const preferred = preferredFromCookie && config.locales.includes(preferredFromCookie) ? preferredFromCookie : preferredFromHeader;
1504
+ if (strategy === "prefix" || preferred !== config.defaultLocale) {
1505
+ res.writeHead(302, { Location: `/${preferred}/` });
1506
+ res.end();
1507
+ return;
1508
+ }
1509
+ }
1510
+ req.__locale = locale;
1511
+ req.__localeContext = createLocaleContext(locale, url, config);
1512
+ localeSignal.set(locale);
1513
+ next();
1514
+ });
1515
+ }
1516
+ };
1517
+ }
1518
+ function parseCookies(header) {
1519
+ if (!header) return {};
1520
+ const result = {};
1521
+ for (const pair of header.split(";")) {
1522
+ const [key, value] = pair.trim().split("=");
1523
+ if (key && value) result[key] = decodeURIComponent(value);
1524
+ }
1525
+ return result;
1526
+ }
1527
+ /** @internal Context for the current locale. */
1528
+ const LocaleCtx = createContext("en");
1529
+ /** Current locale signal — set by the server middleware or client-side detection. */
1530
+ const localeSignal = signal("en");
1531
+
1532
+ //#endregion
1533
+ export { bunAdapter, cloudflareAdapter, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, filePathToUrlPath, generateMiddlewareModule, generateRouteModule, getContext, i18nRouting, netlifyAdapter, nodeAdapter, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanRouteFiles, staticAdapter, vercelAdapter };
1534
+ //# sourceMappingURL=server.js.map