@pyreon/zero 0.1.1 → 0.3.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 (57) hide show
  1. package/README.md +17 -6
  2. package/lib/fs-router-BkbIWqek.js.map +1 -1
  3. package/lib/{fs-router-jfd1QGLB.js → fs-router-n4VA4lxu.js} +29 -4
  4. package/lib/fs-router-n4VA4lxu.js.map +1 -0
  5. package/lib/image.js +50 -1
  6. package/lib/image.js.map +1 -1
  7. package/lib/index.js +651 -11
  8. package/lib/index.js.map +1 -1
  9. package/lib/link.js +49 -1
  10. package/lib/link.js.map +1 -1
  11. package/lib/script.js +49 -1
  12. package/lib/script.js.map +1 -1
  13. package/lib/theme.js +50 -1
  14. package/lib/theme.js.map +1 -1
  15. package/lib/types/actions.d.ts +57 -0
  16. package/lib/types/actions.d.ts.map +1 -0
  17. package/lib/types/api-routes.d.ts +66 -0
  18. package/lib/types/api-routes.d.ts.map +1 -0
  19. package/lib/types/compression.d.ts +33 -0
  20. package/lib/types/compression.d.ts.map +1 -0
  21. package/lib/types/cors.d.ts +32 -0
  22. package/lib/types/cors.d.ts.map +1 -0
  23. package/lib/types/entry-server.d.ts +10 -2
  24. package/lib/types/entry-server.d.ts.map +1 -1
  25. package/lib/types/error-overlay.d.ts +6 -0
  26. package/lib/types/error-overlay.d.ts.map +1 -0
  27. package/lib/types/fs-router.d.ts +5 -0
  28. package/lib/types/fs-router.d.ts.map +1 -1
  29. package/lib/types/image.d.ts +1 -1
  30. package/lib/types/image.d.ts.map +1 -1
  31. package/lib/types/index.d.ts +12 -2
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/rate-limit.d.ts +34 -0
  34. package/lib/types/rate-limit.d.ts.map +1 -0
  35. package/lib/types/script.d.ts +1 -1
  36. package/lib/types/script.d.ts.map +1 -1
  37. package/lib/types/testing.d.ts +85 -0
  38. package/lib/types/testing.d.ts.map +1 -0
  39. package/lib/types/theme.d.ts +1 -1
  40. package/lib/types/theme.d.ts.map +1 -1
  41. package/lib/types/types.d.ts +5 -0
  42. package/lib/types/types.d.ts.map +1 -1
  43. package/lib/types/vite-plugin.d.ts.map +1 -1
  44. package/package.json +40 -9
  45. package/src/actions.ts +168 -0
  46. package/src/api-routes.ts +233 -0
  47. package/src/compression.ts +107 -0
  48. package/src/cors.ts +102 -0
  49. package/src/entry-server.ts +62 -7
  50. package/src/error-overlay.ts +121 -0
  51. package/src/fs-router.ts +34 -2
  52. package/src/index.ts +37 -0
  53. package/src/rate-limit.ts +122 -0
  54. package/src/testing.ts +150 -0
  55. package/src/types.ts +8 -0
  56. package/src/vite-plugin.ts +75 -10
  57. package/lib/fs-router-jfd1QGLB.js.map +0 -1
package/lib/index.js CHANGED
@@ -1,10 +1,9 @@
1
- import { a as scanRouteFiles, i as parseFileRoutes, r as generateRouteModule, t as filePathToUrlPath } from "./fs-router-jfd1QGLB.js";
1
+ import { a as parseFileRoutes, i as generateRouteModule, o as scanRouteFiles, r as generateMiddlewareModule, t as filePathToUrlPath } from "./fs-router-n4VA4lxu.js";
2
2
  import { Fragment, createRef, h, onMount, onUnmount } from "@pyreon/core";
3
3
  import { HeadProvider } from "@pyreon/head";
4
4
  import { RouterProvider, RouterView, createRouter, useRouter } from "@pyreon/router";
5
5
  import { createHandler } from "@pyreon/server";
6
6
  import { effect, signal } from "@pyreon/reactivity";
7
- import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
8
7
  import { existsSync } from "node:fs";
9
8
  import { mkdir, readFile, writeFile } from "node:fs/promises";
10
9
  import { basename, extname, join } from "node:path";
@@ -35,20 +34,183 @@ function DefaultLayout(props) {
35
34
  return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
36
35
  }
37
36
 
37
+ //#endregion
38
+ //#region src/api-routes.ts
39
+ /**
40
+ * Match a URL path against an API route pattern.
41
+ * Returns extracted params or null if no match.
42
+ */
43
+ function matchApiRoute(pattern, path) {
44
+ const patternParts = pattern.split("/").filter(Boolean);
45
+ const pathParts = path.split("/").filter(Boolean);
46
+ const params = {};
47
+ for (let i = 0; i < patternParts.length; i++) {
48
+ const pp = patternParts[i];
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 fullPath = `${routesDir}/${apiFiles[i]}`;
156
+ const pattern = apiFilePathToPattern(apiFiles[i]);
157
+ imports.push(`import * as ${name} from "${fullPath}"`);
158
+ entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
159
+ }
160
+ return [
161
+ ...imports,
162
+ "",
163
+ "export const apiRoutes = [",
164
+ entries.join(",\n"),
165
+ "]"
166
+ ].join("\n");
167
+ }
168
+
38
169
  //#endregion
39
170
  //#region src/entry-server.ts
40
171
  /**
172
+ * Create a middleware that dispatches per-route middleware based on URL pattern matching.
173
+ */
174
+ function createRouteMiddlewareDispatcher(entries) {
175
+ return async (ctx) => {
176
+ for (const entry of entries) if (matchPattern(entry.pattern, ctx.path)) {
177
+ const mw = Array.isArray(entry.middleware) ? entry.middleware : [entry.middleware];
178
+ for (const fn of mw) {
179
+ const result = await fn(ctx);
180
+ if (result) return result;
181
+ }
182
+ }
183
+ };
184
+ }
185
+ /** Simple URL pattern matcher supporting :param and :param* segments. */
186
+ function matchPattern(pattern, path) {
187
+ const patternParts = pattern.split("/").filter(Boolean);
188
+ const pathParts = path.split("/").filter(Boolean);
189
+ for (let i = 0; i < patternParts.length; i++) {
190
+ const pp = patternParts[i];
191
+ if (pp.endsWith("*")) return true;
192
+ if (pp.startsWith(":")) continue;
193
+ if (pp !== pathParts[i]) return false;
194
+ }
195
+ return patternParts.length === pathParts.length;
196
+ }
197
+ /**
41
198
  * Create the SSR request handler for production.
42
199
  *
43
200
  * @example
44
201
  * import { routes } from "virtual:zero/routes"
202
+ * import { routeMiddleware } from "virtual:zero/route-middleware"
45
203
  * import { createServer } from "@pyreon/zero"
46
204
  *
47
- * export default createServer({ routes })
205
+ * export default createServer({ routes, routeMiddleware, apiRoutes })
48
206
  */
49
207
  function createServer(options) {
50
208
  const config = options.config ?? {};
51
- const allMiddleware = [...config.middleware ?? [], ...options.middleware ?? []];
209
+ const allMiddleware = [];
210
+ if (options.apiRoutes?.length) allMiddleware.push(createApiMiddleware(options.apiRoutes));
211
+ if (options.routeMiddleware?.length) allMiddleware.push(createRouteMiddlewareDispatcher(options.routeMiddleware));
212
+ allMiddleware.push(...config.middleware ?? []);
213
+ allMiddleware.push(...options.middleware ?? []);
52
214
  const { App } = createApp({
53
215
  routes: options.routes,
54
216
  routerMode: "history"
@@ -96,10 +258,121 @@ function resolveConfig(userConfig = {}) {
96
258
  };
97
259
  }
98
260
 
261
+ //#endregion
262
+ //#region src/error-overlay.ts
263
+ /**
264
+ * Dev-only error overlay for SSR/loader errors.
265
+ * Renders a styled HTML page with the error stack trace.
266
+ */
267
+ function renderErrorOverlay(error) {
268
+ return `<!DOCTYPE html>
269
+ <html lang="en">
270
+ <head>
271
+ <meta charset="UTF-8">
272
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
273
+ <title>SSR Error — Pyreon Zero</title>
274
+ <style>
275
+ * { margin: 0; padding: 0; box-sizing: border-box; }
276
+ body {
277
+ font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
278
+ background: #1a1a2e;
279
+ color: #e0e0e0;
280
+ min-height: 100vh;
281
+ padding: 2rem;
282
+ }
283
+ .overlay {
284
+ max-width: 900px;
285
+ margin: 0 auto;
286
+ }
287
+ .header {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 0.75rem;
291
+ margin-bottom: 1.5rem;
292
+ }
293
+ .badge {
294
+ background: #e74c3c;
295
+ color: white;
296
+ padding: 0.25rem 0.75rem;
297
+ border-radius: 4px;
298
+ font-size: 0.75rem;
299
+ font-weight: 600;
300
+ text-transform: uppercase;
301
+ letter-spacing: 0.05em;
302
+ }
303
+ .label {
304
+ color: #888;
305
+ font-size: 0.85rem;
306
+ }
307
+ .message {
308
+ font-size: 1.25rem;
309
+ color: #ff6b6b;
310
+ margin-bottom: 1.5rem;
311
+ line-height: 1.5;
312
+ word-break: break-word;
313
+ }
314
+ .stack {
315
+ background: #16213e;
316
+ border: 1px solid #2a2a4a;
317
+ border-radius: 8px;
318
+ padding: 1.25rem;
319
+ overflow-x: auto;
320
+ font-size: 0.8rem;
321
+ line-height: 1.7;
322
+ white-space: pre-wrap;
323
+ word-break: break-all;
324
+ }
325
+ .stack .at { color: #888; }
326
+ .stack .file { color: #4ecdc4; }
327
+ .hint {
328
+ margin-top: 1.5rem;
329
+ padding: 1rem;
330
+ background: #1e2a45;
331
+ border-radius: 6px;
332
+ border-left: 3px solid #3498db;
333
+ font-size: 0.8rem;
334
+ color: #aaa;
335
+ line-height: 1.5;
336
+ }
337
+ </style>
338
+ </head>
339
+ <body>
340
+ <div class="overlay">
341
+ <div class="header">
342
+ <span class="badge">SSR Error</span>
343
+ <span class="label">Pyreon Zero — Dev Mode</span>
344
+ </div>
345
+ <div class="message">${escapeHtml(error.message || "Unknown error")}</div>
346
+ <pre class="stack">${formatStack(escapeHtml(error.stack || ""))}</pre>
347
+ <div class="hint">
348
+ This error occurred during server-side rendering. Check the terminal for
349
+ the full stack trace. This overlay is only shown in development.
350
+ </div>
351
+ </div>
352
+ </body>
353
+ </html>`;
354
+ }
355
+ function escapeHtml(str) {
356
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
357
+ }
358
+ function formatStack(stack) {
359
+ return stack.split("\n").map((line) => {
360
+ if (line.includes("at ")) {
361
+ const fileMatch = line.match(/\(([^)]+)\)/);
362
+ if (fileMatch) return line.replace(fileMatch[0], `(<span class="file">${fileMatch[1]}</span>)`);
363
+ }
364
+ return line;
365
+ }).join("\n");
366
+ }
367
+
99
368
  //#endregion
100
369
  //#region src/vite-plugin.ts
101
370
  const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
102
371
  const RESOLVED_VIRTUAL_ROUTES_ID = `\0${VIRTUAL_ROUTES_ID}`;
372
+ const VIRTUAL_MIDDLEWARE_ID = "virtual:zero/route-middleware";
373
+ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
374
+ const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
375
+ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
103
376
  /**
104
377
  * Zero Vite plugin — adds file-based routing and zero-config conventions
105
378
  * on top of @pyreon/vite-plugin.
@@ -127,6 +400,8 @@ function zeroPlugin(userConfig = {}) {
127
400
  },
128
401
  resolveId(id) {
129
402
  if (id === VIRTUAL_ROUTES_ID) return RESOLVED_VIRTUAL_ROUTES_ID;
403
+ if (id === VIRTUAL_MIDDLEWARE_ID) return RESOLVED_VIRTUAL_MIDDLEWARE_ID;
404
+ if (id === VIRTUAL_API_ROUTES_ID) return RESOLVED_VIRTUAL_API_ROUTES_ID;
130
405
  },
131
406
  async load(id) {
132
407
  if (id === RESOLVED_VIRTUAL_ROUTES_ID) try {
@@ -134,16 +409,52 @@ function zeroPlugin(userConfig = {}) {
134
409
  } catch (_err) {
135
410
  return `export const routes = []`;
136
411
  }
412
+ if (id === RESOLVED_VIRTUAL_MIDDLEWARE_ID) try {
413
+ return generateMiddlewareModule(await scanRouteFiles(routesDir), routesDir);
414
+ } catch (_err) {
415
+ return `export const routeMiddleware = []`;
416
+ }
417
+ if (id === RESOLVED_VIRTUAL_API_ROUTES_ID) try {
418
+ return generateApiRouteModule(await scanRouteFiles(routesDir), routesDir);
419
+ } catch (_err) {
420
+ return `export const apiRoutes = []`;
421
+ }
137
422
  },
138
423
  configureServer(server) {
424
+ server.middlewares.use((req, res, next) => {
425
+ if (!(req.headers.accept ?? "").includes("text/html")) return next();
426
+ const originalEnd = res.end.bind(res);
427
+ let errored = false;
428
+ const handleError = (err) => {
429
+ if (errored) return;
430
+ errored = true;
431
+ const error = err instanceof Error ? err : new Error(String(err));
432
+ server.ssrFixStacktrace(error);
433
+ const html = renderErrorOverlay(error);
434
+ res.statusCode = 500;
435
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
436
+ res.setHeader("Content-Length", Buffer.byteLength(html));
437
+ originalEnd(html);
438
+ };
439
+ res.on("error", handleError);
440
+ try {
441
+ next();
442
+ } catch (err) {
443
+ handleError(err);
444
+ }
445
+ });
139
446
  server.watcher.add(`${routesDir}/**/*.{tsx,jsx,ts,js}`);
140
447
  server.watcher.on("all", (event, path) => {
141
448
  if (path.startsWith(routesDir) && (event === "add" || event === "unlink")) {
142
- const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ROUTES_ID);
143
- if (mod) {
144
- server.moduleGraph.invalidateModule(mod);
145
- server.ws.send({ type: "full-reload" });
449
+ for (const resolvedId of [
450
+ RESOLVED_VIRTUAL_ROUTES_ID,
451
+ RESOLVED_VIRTUAL_MIDDLEWARE_ID,
452
+ RESOLVED_VIRTUAL_API_ROUTES_ID
453
+ ]) {
454
+ const mod = server.moduleGraph.getModuleById(resolvedId);
455
+ if (mod) server.moduleGraph.invalidateModule(mod);
146
456
  }
457
+ server.ws.send({ type: "full-reload" });
147
458
  }
148
459
  });
149
460
  },
@@ -448,6 +759,56 @@ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px")
448
759
  });
449
760
  }
450
761
 
762
+ //#endregion
763
+ //#region ../../node_modules/.bun/@pyreon+core@0.7.0/node_modules/@pyreon/core/lib/jsx-runtime.js
764
+ /**
765
+ * Hyperscript function — the compiled output of JSX.
766
+ * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
767
+ *
768
+ * Generic on P so TypeScript validates props match the component's signature
769
+ * at the call site, then stores the result in the loosely-typed VNode.
770
+ */
771
+ /** Shared empty props sentinel — identity-checked in mountElement to skip applyProps. */
772
+ const EMPTY_PROPS = {};
773
+ function h$1(type, props, ...children) {
774
+ return {
775
+ type,
776
+ props: props ?? EMPTY_PROPS,
777
+ children: normalizeChildren(children),
778
+ key: props?.key ?? null
779
+ };
780
+ }
781
+ function normalizeChildren(children) {
782
+ for (let i = 0; i < children.length; i++) if (Array.isArray(children[i])) return flattenChildren(children);
783
+ return children;
784
+ }
785
+ function flattenChildren(children) {
786
+ const result = [];
787
+ for (const child of children) if (Array.isArray(child)) result.push(...flattenChildren(child));
788
+ else result.push(child);
789
+ return result;
790
+ }
791
+ /**
792
+ * JSX automatic runtime.
793
+ *
794
+ * When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
795
+ * rewrites JSX to imports from this file automatically:
796
+ * <div class="x" /> → jsx("div", { class: "x" })
797
+ */
798
+ function jsx(type, props, key) {
799
+ const { children, ...rest } = props;
800
+ const propsWithKey = key != null ? {
801
+ ...rest,
802
+ key
803
+ } : rest;
804
+ if (typeof type === "function") return h$1(type, children !== void 0 ? {
805
+ ...propsWithKey,
806
+ children
807
+ } : propsWithKey);
808
+ return h$1(type, propsWithKey, ...children === void 0 ? [] : Array.isArray(children) ? children : [children]);
809
+ }
810
+ const jsxs = jsx;
811
+
451
812
  //#endregion
452
813
  //#region src/image.tsx
453
814
  /**
@@ -1623,7 +1984,7 @@ function seoPlugin(config = {}) {
1623
1984
  apply: "build",
1624
1985
  async generateBundle(_, _bundle) {
1625
1986
  if (config.sitemap) {
1626
- const { scanRouteFiles } = await import("./fs-router-jfd1QGLB.js").then((n) => n.n);
1987
+ const { scanRouteFiles } = await import("./fs-router-n4VA4lxu.js").then((n) => n.n);
1627
1988
  const routesDir = `${process.cwd()}/src/routes`;
1628
1989
  try {
1629
1990
  const sitemap = generateSitemap(await scanRouteFiles(routesDir), config.sitemap);
@@ -1653,7 +2014,7 @@ function seoMiddleware(config = {}) {
1653
2014
  return async (ctx) => {
1654
2015
  if (ctx.url.pathname === "/robots.txt" && config.robots) return new Response(generateRobots(config.robots), { headers: { "Content-Type": "text/plain" } });
1655
2016
  if (ctx.url.pathname === "/sitemap.xml" && config.sitemap) try {
1656
- const { scanRouteFiles } = await import("./fs-router-jfd1QGLB.js").then((n) => n.n);
2017
+ const { scanRouteFiles } = await import("./fs-router-n4VA4lxu.js").then((n) => n.n);
1657
2018
  const sitemap = generateSitemap(await scanRouteFiles(`${process.cwd()}/src/routes`), config.sitemap);
1658
2019
  return new Response(sitemap, { headers: { "Content-Type": "application/xml" } });
1659
2020
  } catch {}
@@ -1661,5 +2022,284 @@ function seoMiddleware(config = {}) {
1661
2022
  }
1662
2023
 
1663
2024
  //#endregion
1664
- export { Image, Link, Script, ThemeToggle, bunAdapter, cacheMiddleware, createApp, createISRHandler, createLink, createServer, zeroPlugin as default, defineConfig, filePathToUrlPath, fontPlugin, fontVariables, generateRobots, generateRouteModule, generateSitemap, imagePlugin, initTheme, jsonLd, nodeAdapter, parseFileRoutes, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, varyEncoding };
2025
+ //#region src/cors.ts
2026
+ const DEFAULT_METHODS = [
2027
+ "GET",
2028
+ "POST",
2029
+ "PUT",
2030
+ "PATCH",
2031
+ "DELETE",
2032
+ "OPTIONS"
2033
+ ];
2034
+ const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
2035
+ /**
2036
+ * CORS middleware — handles preflight requests and sets appropriate
2037
+ * Access-Control headers on all responses.
2038
+ *
2039
+ * @example
2040
+ * import { corsMiddleware } from "@pyreon/zero/cors"
2041
+ *
2042
+ * corsMiddleware({ origin: "https://example.com", credentials: true })
2043
+ *
2044
+ * // Allow any origin
2045
+ * corsMiddleware({ origin: "*" })
2046
+ *
2047
+ * // Multiple origins
2048
+ * corsMiddleware({ origin: ["https://app.com", "https://admin.com"] })
2049
+ */
2050
+ function corsMiddleware(config = {}) {
2051
+ const { origin = "*", methods = DEFAULT_METHODS, allowedHeaders = DEFAULT_HEADERS, exposedHeaders = [], credentials = false, maxAge = 86400 } = config;
2052
+ return (ctx) => {
2053
+ const resolvedOrigin = resolveOrigin(origin, ctx.req.headers.get("origin") ?? "");
2054
+ if (!resolvedOrigin) return;
2055
+ ctx.headers.set("Access-Control-Allow-Origin", resolvedOrigin);
2056
+ if (credentials) ctx.headers.set("Access-Control-Allow-Credentials", "true");
2057
+ if (exposedHeaders.length > 0) ctx.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
2058
+ if (resolvedOrigin !== "*") ctx.headers.append("Vary", "Origin");
2059
+ if (ctx.req.method === "OPTIONS") return new Response(null, {
2060
+ status: 204,
2061
+ headers: {
2062
+ "Access-Control-Allow-Origin": resolvedOrigin,
2063
+ "Access-Control-Allow-Methods": methods.join(", "),
2064
+ "Access-Control-Allow-Headers": allowedHeaders.join(", "),
2065
+ "Access-Control-Max-Age": String(maxAge),
2066
+ ...credentials ? { "Access-Control-Allow-Credentials": "true" } : {}
2067
+ }
2068
+ });
2069
+ };
2070
+ }
2071
+ function resolveOrigin(config, requestOrigin) {
2072
+ if (config === "*") return "*";
2073
+ if (typeof config === "string") return config === requestOrigin ? config : null;
2074
+ if (typeof config === "function") return config(requestOrigin) ? requestOrigin : null;
2075
+ if (Array.isArray(config)) return config.includes(requestOrigin) ? requestOrigin : null;
2076
+ return null;
2077
+ }
2078
+
2079
+ //#endregion
2080
+ //#region src/rate-limit.ts
2081
+ /**
2082
+ * Rate limiting middleware — limits requests per client within a time window.
2083
+ * Uses an in-memory store (suitable for single-instance deployments).
2084
+ *
2085
+ * @example
2086
+ * import { rateLimitMiddleware } from "@pyreon/zero/rate-limit"
2087
+ *
2088
+ * // 100 requests per minute (default)
2089
+ * rateLimitMiddleware()
2090
+ *
2091
+ * // Strict API rate limiting
2092
+ * rateLimitMiddleware({
2093
+ * max: 20,
2094
+ * window: 60,
2095
+ * include: ["/api/*"],
2096
+ * })
2097
+ */
2098
+ function rateLimitMiddleware(config = {}) {
2099
+ const { max = 100, window: windowSec = 60, keyFn = defaultKeyFn, onLimit, include, exclude } = config;
2100
+ const windowMs = windowSec * 1e3;
2101
+ const store = /* @__PURE__ */ new Map();
2102
+ const cleanupInterval = setInterval(() => {
2103
+ const now = Date.now();
2104
+ for (const [key, entry] of store) if (entry.resetAt <= now) store.delete(key);
2105
+ }, windowMs);
2106
+ if (typeof cleanupInterval === "object" && "unref" in cleanupInterval) cleanupInterval.unref();
2107
+ return (ctx) => {
2108
+ if (include && !include.some((p) => matchSimpleGlob(p, ctx.path))) return;
2109
+ if (exclude?.some((p) => matchSimpleGlob(p, ctx.path))) return;
2110
+ const key = keyFn(ctx);
2111
+ const now = Date.now();
2112
+ let entry = store.get(key);
2113
+ if (!entry || entry.resetAt <= now) {
2114
+ entry = {
2115
+ count: 0,
2116
+ resetAt: now + windowMs
2117
+ };
2118
+ store.set(key, entry);
2119
+ }
2120
+ entry.count++;
2121
+ const remaining = Math.max(0, max - entry.count);
2122
+ const resetSeconds = Math.ceil((entry.resetAt - now) / 1e3);
2123
+ ctx.headers.set("X-RateLimit-Limit", String(max));
2124
+ ctx.headers.set("X-RateLimit-Remaining", String(remaining));
2125
+ ctx.headers.set("X-RateLimit-Reset", String(resetSeconds));
2126
+ if (entry.count > max) {
2127
+ if (onLimit) return onLimit(ctx);
2128
+ return new Response(JSON.stringify({ error: "Too many requests" }), {
2129
+ status: 429,
2130
+ headers: {
2131
+ "Content-Type": "application/json",
2132
+ "Retry-After": String(resetSeconds),
2133
+ "X-RateLimit-Limit": String(max),
2134
+ "X-RateLimit-Remaining": "0",
2135
+ "X-RateLimit-Reset": String(resetSeconds)
2136
+ }
2137
+ });
2138
+ }
2139
+ };
2140
+ }
2141
+ function defaultKeyFn(ctx) {
2142
+ return ctx.req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? ctx.req.headers.get("x-real-ip") ?? "unknown";
2143
+ }
2144
+ /** Simple glob matching for path patterns. Supports trailing `*`. */
2145
+ function matchSimpleGlob(pattern, path) {
2146
+ if (pattern.endsWith("/*")) return path.startsWith(pattern.slice(0, -1));
2147
+ return pattern === path;
2148
+ }
2149
+
2150
+ //#endregion
2151
+ //#region src/compression.ts
2152
+ /**
2153
+ * Compression middleware — compresses responses using gzip or deflate
2154
+ * based on the client's Accept-Encoding header.
2155
+ *
2156
+ * Only compresses text-based content types (HTML, JSON, JS, CSS, XML, SVG).
2157
+ * Skips responses below the size threshold and already-encoded responses.
2158
+ *
2159
+ * @example
2160
+ * import { compressionMiddleware } from "@pyreon/zero/compression"
2161
+ *
2162
+ * compressionMiddleware() // gzip with 1KB threshold
2163
+ * compressionMiddleware({ threshold: 512, encodings: ["gzip"] })
2164
+ */
2165
+ function compressionMiddleware(config = {}) {
2166
+ const { threshold = 1024, encodings = ["gzip", "deflate"] } = config;
2167
+ return (ctx) => {
2168
+ const acceptEncoding = ctx.req.headers.get("accept-encoding") ?? "";
2169
+ const encoding = encodings.find((enc) => acceptEncoding.includes(enc));
2170
+ if (!encoding) return;
2171
+ ctx.locals.__compressionEncoding = encoding;
2172
+ ctx.locals.__compressionThreshold = threshold;
2173
+ ctx.headers.append("Vary", "Accept-Encoding");
2174
+ };
2175
+ }
2176
+ /**
2177
+ * Compress a Response body if it meets the criteria.
2178
+ * Use this to post-process responses after the handler runs.
2179
+ *
2180
+ * @example
2181
+ * const response = await handler(request)
2182
+ * const compressed = await compressResponse(response, 'gzip', 1024)
2183
+ */
2184
+ async function compressResponse(response, encoding, threshold) {
2185
+ if (!isCompressible(response.headers.get("content-type") ?? "")) return response;
2186
+ if (response.headers.get("content-encoding")) return response;
2187
+ const body = await response.arrayBuffer();
2188
+ if (body.byteLength < threshold) return response;
2189
+ const compressed = await compress(body, encoding);
2190
+ const headers = new Headers(response.headers);
2191
+ headers.set("Content-Encoding", encoding);
2192
+ headers.delete("Content-Length");
2193
+ headers.append("Vary", "Accept-Encoding");
2194
+ return new Response(compressed, {
2195
+ status: response.status,
2196
+ statusText: response.statusText,
2197
+ headers
2198
+ });
2199
+ }
2200
+ const COMPRESSIBLE_TYPES = [
2201
+ "text/",
2202
+ "application/json",
2203
+ "application/javascript",
2204
+ "application/xml",
2205
+ "application/xhtml+xml",
2206
+ "image/svg+xml"
2207
+ ];
2208
+ /** Check if a content type is compressible. Exported for testing. */
2209
+ function isCompressible(contentType) {
2210
+ return COMPRESSIBLE_TYPES.some((t) => contentType.includes(t));
2211
+ }
2212
+ async function compress(data, encoding) {
2213
+ const format = encoding === "gzip" ? "gzip" : "deflate";
2214
+ const stream = new Blob([data]).stream().pipeThrough(new CompressionStream(format));
2215
+ return new Response(stream).arrayBuffer();
2216
+ }
2217
+
2218
+ //#endregion
2219
+ //#region src/actions.ts
2220
+ const actionRegistry = /* @__PURE__ */ new Map();
2221
+ let actionCounter = 0;
2222
+ /**
2223
+ * Define a server action. Returns a callable function that:
2224
+ * - On the **client**: sends a POST request to `/_zero/actions/<id>`
2225
+ * - On the **server** (SSR): executes the handler directly (no fetch)
2226
+ *
2227
+ * @example
2228
+ * // In a route file or module:
2229
+ * export const createPost = defineAction(async (ctx) => {
2230
+ * const data = ctx.json as { title: string; body: string }
2231
+ * // ... save to database
2232
+ * return { success: true, id: 123 }
2233
+ * })
2234
+ *
2235
+ * // In a component:
2236
+ * const result = await createPost({ title: 'Hello', body: '...' })
2237
+ */
2238
+ function defineAction(handler) {
2239
+ const id = `action_${actionCounter++}`;
2240
+ actionRegistry.set(id, {
2241
+ id,
2242
+ handler
2243
+ });
2244
+ const callable = async (data) => {
2245
+ if (typeof globalThis.window === "undefined") return handler({
2246
+ request: new Request(`http://localhost/_zero/actions/${id}`, {
2247
+ method: "POST",
2248
+ headers: { "Content-Type": "application/json" },
2249
+ body: JSON.stringify(data ?? null)
2250
+ }),
2251
+ formData: null,
2252
+ json: data ?? null,
2253
+ headers: new Headers({ "Content-Type": "application/json" })
2254
+ });
2255
+ const response = await fetch(`/_zero/actions/${id}`, {
2256
+ method: "POST",
2257
+ headers: { "Content-Type": "application/json" },
2258
+ body: JSON.stringify(data ?? null)
2259
+ });
2260
+ if (!response.ok) {
2261
+ const body = await response.json().catch(() => ({}));
2262
+ throw new Error(body.error ?? `Action failed: ${response.statusText}`);
2263
+ }
2264
+ return response.json();
2265
+ };
2266
+ callable.actionId = id;
2267
+ return callable;
2268
+ }
2269
+ /**
2270
+ * Create a middleware that handles action requests at `/_zero/actions/*`.
2271
+ * Mount this before the SSR handler in the server entry.
2272
+ */
2273
+ function createActionMiddleware() {
2274
+ return async (ctx) => {
2275
+ if (!ctx.path.startsWith("/_zero/actions/")) return;
2276
+ const actionId = ctx.path.slice(15);
2277
+ const action = actionRegistry.get(actionId);
2278
+ if (!action) return Response.json({ error: "Action not found" }, { status: 404 });
2279
+ if (ctx.req.method !== "POST") return Response.json({ error: "Method not allowed" }, { status: 405 });
2280
+ return executeAction(action, ctx.req);
2281
+ };
2282
+ }
2283
+ async function executeAction(action, req) {
2284
+ try {
2285
+ const contentType = req.headers.get("content-type") ?? "";
2286
+ let formData = null;
2287
+ let json = null;
2288
+ if (contentType.includes("application/json")) json = await req.json();
2289
+ else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) formData = await req.formData();
2290
+ const result = await action.handler({
2291
+ request: req,
2292
+ formData,
2293
+ json,
2294
+ headers: req.headers
2295
+ });
2296
+ return Response.json(result ?? null);
2297
+ } catch (err) {
2298
+ const message = err instanceof Error ? err.message : "Internal server error";
2299
+ return Response.json({ error: message }, { status: 500 });
2300
+ }
2301
+ }
2302
+
2303
+ //#endregion
2304
+ export { Image, Link, Script, ThemeToggle, bunAdapter, cacheMiddleware, compressResponse, compressionMiddleware, corsMiddleware, createActionMiddleware, createApiMiddleware, createApp, createISRHandler, createLink, createServer, zeroPlugin as default, defineAction, defineConfig, filePathToUrlPath, fontPlugin, fontVariables, generateApiRouteModule, generateMiddlewareModule, generateRobots, generateRouteModule, generateSitemap, imagePlugin, initTheme, isCompressible, jsonLd, nodeAdapter, parseFileRoutes, rateLimitMiddleware, resolveAdapter, resolveConfig, resolvedTheme, scanRouteFiles, securityHeaders, seoMiddleware, seoPlugin, setTheme, staticAdapter, theme, themeScript, toggleTheme, useLink, varyEncoding };
1665
2305
  //# sourceMappingURL=index.js.map