@pyreon/zero 0.12.3 → 0.12.4
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/index.js +314 -4047
- package/lib/index.js.map +1 -1
- package/lib/meta.js +22 -48
- package/lib/meta.js.map +1 -1
- package/lib/server.js +1534 -0
- package/lib/server.js.map +1 -0
- package/lib/types/index.d.ts +173 -1540
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/meta.d.ts +11 -46
- package/lib/types/meta.d.ts.map +1 -1
- package/lib/types/server.d.ts +455 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +15 -10
- package/src/index.ts +19 -182
- package/src/meta.tsx +37 -3
- package/src/server.ts +70 -0
- package/lib/fs-router-Dil4IKZR.js +0 -290
- package/lib/fs-router-Dil4IKZR.js.map +0 -1
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|