@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.
- package/README.md +17 -6
- package/lib/fs-router-BkbIWqek.js.map +1 -1
- package/lib/{fs-router-jfd1QGLB.js → fs-router-n4VA4lxu.js} +29 -4
- package/lib/fs-router-n4VA4lxu.js.map +1 -0
- package/lib/image.js +50 -1
- package/lib/image.js.map +1 -1
- package/lib/index.js +651 -11
- package/lib/index.js.map +1 -1
- package/lib/link.js +49 -1
- package/lib/link.js.map +1 -1
- package/lib/script.js +49 -1
- package/lib/script.js.map +1 -1
- package/lib/theme.js +50 -1
- package/lib/theme.js.map +1 -1
- package/lib/types/actions.d.ts +57 -0
- package/lib/types/actions.d.ts.map +1 -0
- package/lib/types/api-routes.d.ts +66 -0
- package/lib/types/api-routes.d.ts.map +1 -0
- package/lib/types/compression.d.ts +33 -0
- package/lib/types/compression.d.ts.map +1 -0
- package/lib/types/cors.d.ts +32 -0
- package/lib/types/cors.d.ts.map +1 -0
- package/lib/types/entry-server.d.ts +10 -2
- package/lib/types/entry-server.d.ts.map +1 -1
- package/lib/types/error-overlay.d.ts +6 -0
- package/lib/types/error-overlay.d.ts.map +1 -0
- package/lib/types/fs-router.d.ts +5 -0
- package/lib/types/fs-router.d.ts.map +1 -1
- package/lib/types/image.d.ts +1 -1
- package/lib/types/image.d.ts.map +1 -1
- package/lib/types/index.d.ts +12 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/rate-limit.d.ts +34 -0
- package/lib/types/rate-limit.d.ts.map +1 -0
- package/lib/types/script.d.ts +1 -1
- package/lib/types/script.d.ts.map +1 -1
- package/lib/types/testing.d.ts +85 -0
- package/lib/types/testing.d.ts.map +1 -0
- package/lib/types/theme.d.ts +1 -1
- package/lib/types/theme.d.ts.map +1 -1
- package/lib/types/types.d.ts +5 -0
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/vite-plugin.d.ts.map +1 -1
- package/package.json +40 -9
- package/src/actions.ts +168 -0
- package/src/api-routes.ts +233 -0
- package/src/compression.ts +107 -0
- package/src/cors.ts +102 -0
- package/src/entry-server.ts +62 -7
- package/src/error-overlay.ts +121 -0
- package/src/fs-router.ts +34 -2
- package/src/index.ts +37 -0
- package/src/rate-limit.ts +122 -0
- package/src/testing.ts +150 -0
- package/src/types.ts +8 -0
- package/src/vite-plugin.ts +75 -10
- package/lib/fs-router-jfd1QGLB.js.map +0 -1
package/lib/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { a as
|
|
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 = [
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|