loly 0.1.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 +374 -0
- package/bin/loly.js +3015 -0
- package/dist/bootstrap-DgWagZ79.d.ts +16 -0
- package/dist/client.d.ts +101 -0
- package/dist/client.js +727 -0
- package/dist/client.js.map +1 -0
- package/dist/components.d.ts +39 -0
- package/dist/components.js +147 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +458 -0
- package/dist/index.js +3826 -0
- package/dist/index.js.map +1 -0
- package/package.json +108 -0
package/bin/loly.js
ADDED
|
@@ -0,0 +1,3015 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { config } from "dotenv";
|
|
5
|
+
|
|
6
|
+
// src/server/dev-server.ts
|
|
7
|
+
import { join as join3 } from "path";
|
|
8
|
+
import { existsSync as existsSync5 } from "fs";
|
|
9
|
+
import chokidar from "chokidar";
|
|
10
|
+
|
|
11
|
+
// src/router/scanner.ts
|
|
12
|
+
import { glob } from "glob";
|
|
13
|
+
import { relative, dirname, sep } from "path";
|
|
14
|
+
|
|
15
|
+
// src/constants/routes.ts
|
|
16
|
+
var ROUTE_FILE_NAMES = [
|
|
17
|
+
"page.tsx",
|
|
18
|
+
"page.ts",
|
|
19
|
+
"layout.tsx",
|
|
20
|
+
"layout.ts",
|
|
21
|
+
"route.ts",
|
|
22
|
+
"route.tsx"
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// src/constants/directories.ts
|
|
26
|
+
var DIRECTORIES = {
|
|
27
|
+
SRC: "src",
|
|
28
|
+
APP: "app",
|
|
29
|
+
DIST: "dist",
|
|
30
|
+
CLIENT: "client",
|
|
31
|
+
SERVER: "server",
|
|
32
|
+
PUBLIC: "public",
|
|
33
|
+
LOLY: ".loly"
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/constants/files.ts
|
|
37
|
+
var FILE_NAMES = {
|
|
38
|
+
MANIFEST: "manifest.json",
|
|
39
|
+
BOOTSTRAP: "bootstrap.ts",
|
|
40
|
+
GLOBALS_CSS: "globals.css",
|
|
41
|
+
ROUTES_MANIFEST: "routes-manifest.json"
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/constants/http.ts
|
|
45
|
+
var HTTP_STATUS = {
|
|
46
|
+
OK: 200,
|
|
47
|
+
BAD_REQUEST: 400,
|
|
48
|
+
NOT_FOUND: 404,
|
|
49
|
+
METHOD_NOT_ALLOWED: 405,
|
|
50
|
+
REQUEST_TIMEOUT: 408,
|
|
51
|
+
INTERNAL_ERROR: 500
|
|
52
|
+
};
|
|
53
|
+
var HTTP_HEADERS = {
|
|
54
|
+
CONTENT_TYPE_JSON: "application/json",
|
|
55
|
+
CONTENT_TYPE_HTML: "text/html; charset=utf-8"
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/constants/server.ts
|
|
59
|
+
var SERVER = {
|
|
60
|
+
DEFAULT_PORT: 3e3,
|
|
61
|
+
RSC_ENDPOINT: "/__loly/rsc",
|
|
62
|
+
IMAGE_ENDPOINT: "/_loly/image",
|
|
63
|
+
ASYNC_ENDPOINT: "/__loly/async",
|
|
64
|
+
APP_CONTAINER_ID: "app"
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// src/constants/node-builtins.ts
|
|
68
|
+
var NODE_BUILTINS = [
|
|
69
|
+
"fs",
|
|
70
|
+
"path",
|
|
71
|
+
"url",
|
|
72
|
+
"util",
|
|
73
|
+
"stream",
|
|
74
|
+
"events",
|
|
75
|
+
"crypto",
|
|
76
|
+
"http",
|
|
77
|
+
"https",
|
|
78
|
+
"os",
|
|
79
|
+
"querystring",
|
|
80
|
+
"buffer",
|
|
81
|
+
"constants",
|
|
82
|
+
"child_process",
|
|
83
|
+
"module",
|
|
84
|
+
"fs/promises",
|
|
85
|
+
"worker_threads",
|
|
86
|
+
"zlib",
|
|
87
|
+
"inspector",
|
|
88
|
+
"perf_hooks"
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
// src/constants/errors.ts
|
|
92
|
+
var ERROR_MESSAGES = {
|
|
93
|
+
CONTEXT_NOT_INITIALIZED: "[loly-core] Framework context not initialized. Call setContext() first.",
|
|
94
|
+
APP_DIR_NOT_FOUND: (dir) => `[loly-core] src/app/ directory not found in ${dir}. Please ensure your project has a src/app/ directory.`,
|
|
95
|
+
ROUTE_NOT_FOUND: (pathname) => `Route not found: ${pathname}`,
|
|
96
|
+
PAGE_COMPONENT_NOT_FOUND: (path4) => `[loly-core] Page component not found in ${path4}`,
|
|
97
|
+
PAGE_COMPONENT_MUST_BE_FUNCTION: "[loly-core] Page component must be a function",
|
|
98
|
+
COMPONENT_NOT_FOUND: (route) => `Component not found for route: ${route}`,
|
|
99
|
+
APP_CONTAINER_NOT_FOUND: "[loly-core] App container not found (#app)",
|
|
100
|
+
CLIENT_BUILD_DIR_NOT_FOUND: (dir) => `[loly-core] Client build directory not found: ${dir}. Run 'loly build' first or the client scripts won't load.`,
|
|
101
|
+
ROUTES_MANIFEST_NOT_FOUND: (path4) => `[loly-core] Routes manifest not found: ${path4}. Run 'loly build' first.`,
|
|
102
|
+
DIRECTORY_NOT_FOUND: (dir) => `[loly-core] Directory not found: ${dir}`,
|
|
103
|
+
CLIENT_BUILD_FAILED: "Client build failed",
|
|
104
|
+
SERVER_BUILD_FAILED: "Server build failed",
|
|
105
|
+
FAILED_TO_LOAD_MODULE: (path4) => `[loly-core] Failed to load module: ${path4}`,
|
|
106
|
+
FAILED_TO_LOAD_ROUTE_COMPONENT: (path4) => `[bootstrap] Failed to load component for route ${path4}`,
|
|
107
|
+
FAILED_TO_LOAD_NESTED_LAYOUT: (path4) => `[loly-core] Failed to load nested layout at ${path4}`,
|
|
108
|
+
FAILED_TO_READ_CLIENT_MANIFEST: "[loly-core] Failed to read client manifest:",
|
|
109
|
+
FAILED_TO_PARSE_ISLAND_DATA: "[loly-core] Failed to parse island data:",
|
|
110
|
+
FATAL_BOOTSTRAP_ERROR: "[bootstrap] Fatal error during bootstrap:",
|
|
111
|
+
UNEXPECTED_ERROR: "[loly-core] Unexpected error:",
|
|
112
|
+
ERROR_STARTING_DEV_SERVER: "[loly-core] Error starting dev server:",
|
|
113
|
+
ERROR_STARTING_PROD_SERVER: "[loly-core] Error starting production server:",
|
|
114
|
+
ERROR_BUILDING_PROJECT: "[loly-core] Error building project:",
|
|
115
|
+
ERROR_HANDLING_REQUEST: "[loly-core] Error handling request:",
|
|
116
|
+
ERROR_RENDERING_PAGE: "[loly-core] Error rendering page:",
|
|
117
|
+
RSC_ENDPOINT_ERROR: "[loly-core] RSC endpoint error:"
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// src/utils/path.ts
|
|
121
|
+
function normalizePath(path4) {
|
|
122
|
+
if (path4 === "/") return "/";
|
|
123
|
+
return path4.replace(/\/$/, "") || "/";
|
|
124
|
+
}
|
|
125
|
+
function normalizeUrlPath(pathname) {
|
|
126
|
+
return normalizePath(pathname);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/utils/module-loader.ts
|
|
130
|
+
import { resolve } from "path";
|
|
131
|
+
import { existsSync } from "fs";
|
|
132
|
+
var tsxRegistered = false;
|
|
133
|
+
var tsxRegistrationPromise = null;
|
|
134
|
+
async function ensureTsxRegistered() {
|
|
135
|
+
if (tsxRegistered) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (tsxRegistrationPromise) {
|
|
139
|
+
await tsxRegistrationPromise;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
tsxRegistrationPromise = (async () => {
|
|
143
|
+
try {
|
|
144
|
+
await import("tsx/esm");
|
|
145
|
+
tsxRegistered = true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.warn(
|
|
148
|
+
"[loly-core] tsx not available, TypeScript files won't load in development"
|
|
149
|
+
);
|
|
150
|
+
tsxRegistered = true;
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
await tsxRegistrationPromise;
|
|
154
|
+
}
|
|
155
|
+
function resolveModuleUrl(path4) {
|
|
156
|
+
if (path4.startsWith("file://")) {
|
|
157
|
+
return path4;
|
|
158
|
+
}
|
|
159
|
+
const absolutePath = resolve(path4);
|
|
160
|
+
const normalizedPath = absolutePath.replace(/\\/g, "/");
|
|
161
|
+
if (normalizedPath.startsWith("/")) {
|
|
162
|
+
return `file://${normalizedPath}`;
|
|
163
|
+
}
|
|
164
|
+
return `file:///${normalizedPath}`;
|
|
165
|
+
}
|
|
166
|
+
async function loadModule(filePath, compiledPath) {
|
|
167
|
+
let pathToLoad = filePath;
|
|
168
|
+
if (compiledPath && existsSync(compiledPath)) {
|
|
169
|
+
pathToLoad = compiledPath;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
if (pathToLoad.endsWith(".ts") || pathToLoad.endsWith(".tsx")) {
|
|
173
|
+
await ensureTsxRegistered();
|
|
174
|
+
}
|
|
175
|
+
const fileUrl = resolveModuleUrl(pathToLoad);
|
|
176
|
+
const module = await import(fileUrl);
|
|
177
|
+
return module;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error(ERROR_MESSAGES.FAILED_TO_LOAD_MODULE(pathToLoad), error);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/router/scanner.ts
|
|
185
|
+
function parseSegment(segment) {
|
|
186
|
+
if (segment.startsWith("(") && segment.endsWith(")")) {
|
|
187
|
+
return {
|
|
188
|
+
segment,
|
|
189
|
+
// Keep original with parentheses: (admin)
|
|
190
|
+
isDynamic: false,
|
|
191
|
+
isCatchAll: false,
|
|
192
|
+
isOptional: true
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
196
|
+
const paramName = segment.slice(4, -1);
|
|
197
|
+
return {
|
|
198
|
+
segment,
|
|
199
|
+
isDynamic: true,
|
|
200
|
+
isCatchAll: true,
|
|
201
|
+
isOptional: false,
|
|
202
|
+
paramName
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
|
|
206
|
+
const paramName = segment.slice(5, -2);
|
|
207
|
+
return {
|
|
208
|
+
segment,
|
|
209
|
+
isDynamic: true,
|
|
210
|
+
isCatchAll: true,
|
|
211
|
+
isOptional: true,
|
|
212
|
+
paramName
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
216
|
+
const paramName = segment.slice(1, -1);
|
|
217
|
+
return {
|
|
218
|
+
segment,
|
|
219
|
+
isDynamic: true,
|
|
220
|
+
isCatchAll: false,
|
|
221
|
+
isOptional: false,
|
|
222
|
+
paramName
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
segment,
|
|
227
|
+
isDynamic: false,
|
|
228
|
+
isCatchAll: false,
|
|
229
|
+
isOptional: false
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function segmentsToUrlPath(segments) {
|
|
233
|
+
const parts = [];
|
|
234
|
+
for (const seg of segments) {
|
|
235
|
+
if (seg.isOptional) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (seg.isCatchAll) {
|
|
239
|
+
parts.push(`*${seg.paramName || "slug"}`);
|
|
240
|
+
} else if (seg.isDynamic) {
|
|
241
|
+
parts.push(`:${seg.paramName || "param"}`);
|
|
242
|
+
} else {
|
|
243
|
+
parts.push(seg.segment);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const path4 = "/" + parts.join("/");
|
|
247
|
+
return normalizeUrlPath(path4);
|
|
248
|
+
}
|
|
249
|
+
var HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
|
250
|
+
async function analyzeApiRoute(filePath) {
|
|
251
|
+
try {
|
|
252
|
+
const module = await loadModule(filePath);
|
|
253
|
+
const exports = Object.keys(module);
|
|
254
|
+
const httpMethods = [];
|
|
255
|
+
for (const exportName of exports) {
|
|
256
|
+
const upperExport = exportName.toUpperCase();
|
|
257
|
+
if (HTTP_METHODS.includes(upperExport) && typeof module[exportName] === "function") {
|
|
258
|
+
httpMethods.push(upperExport);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return httpMethods;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.warn(`[loly-core] Failed to analyze API route ${filePath}:`, error);
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function scanRoutes(appDir) {
|
|
268
|
+
const routes = [];
|
|
269
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
270
|
+
const apiRouteMap = /* @__PURE__ */ new Map();
|
|
271
|
+
const patterns = ROUTE_FILE_NAMES.map((file) => `**/${file}`);
|
|
272
|
+
const files = await glob(patterns, { cwd: appDir, absolute: true });
|
|
273
|
+
let rootLayout;
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
const absolutePath = file;
|
|
276
|
+
const relativePath = relative(appDir, absolutePath);
|
|
277
|
+
const dir = dirname(relativePath);
|
|
278
|
+
const fileName = file.split(sep).pop() || "";
|
|
279
|
+
let type;
|
|
280
|
+
if (fileName.startsWith("page.")) {
|
|
281
|
+
type = "page";
|
|
282
|
+
} else if (fileName.startsWith("layout.")) {
|
|
283
|
+
type = "layout";
|
|
284
|
+
} else if (fileName.startsWith("route.")) {
|
|
285
|
+
type = "route";
|
|
286
|
+
} else {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const pathSegments = dir === "." ? [] : dir.split(sep).filter(Boolean);
|
|
290
|
+
const segments = pathSegments.map(parseSegment);
|
|
291
|
+
const isRootLayout = type === "layout" && dir === ".";
|
|
292
|
+
let urlPath = "";
|
|
293
|
+
let isApiRoute = false;
|
|
294
|
+
let httpMethods = void 0;
|
|
295
|
+
if (type === "page") {
|
|
296
|
+
urlPath = segmentsToUrlPath(segments);
|
|
297
|
+
} else if (type === "route") {
|
|
298
|
+
httpMethods = await analyzeApiRoute(absolutePath);
|
|
299
|
+
if (httpMethods.length > 0) {
|
|
300
|
+
isApiRoute = true;
|
|
301
|
+
urlPath = segmentsToUrlPath(segments);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const routeFile = {
|
|
305
|
+
filePath: absolutePath,
|
|
306
|
+
segments,
|
|
307
|
+
type,
|
|
308
|
+
isRootLayout,
|
|
309
|
+
urlPath,
|
|
310
|
+
isApiRoute: isApiRoute || void 0,
|
|
311
|
+
httpMethods
|
|
312
|
+
};
|
|
313
|
+
routes.push(routeFile);
|
|
314
|
+
if (isRootLayout) {
|
|
315
|
+
rootLayout = routeFile;
|
|
316
|
+
}
|
|
317
|
+
if (type === "page") {
|
|
318
|
+
routeMap.set(urlPath, routeFile);
|
|
319
|
+
} else if (isApiRoute && urlPath) {
|
|
320
|
+
apiRouteMap.set(urlPath, routeFile);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
routes,
|
|
325
|
+
rootLayout,
|
|
326
|
+
routeMap,
|
|
327
|
+
apiRouteMap
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/utils/route-component-factory.ts
|
|
332
|
+
import { resolve as resolve2, join } from "path";
|
|
333
|
+
import { existsSync as existsSync2 } from "fs";
|
|
334
|
+
var RouteComponentFactory = class {
|
|
335
|
+
constructor() {
|
|
336
|
+
this.componentCache = /* @__PURE__ */ new Map();
|
|
337
|
+
this.layoutCache = /* @__PURE__ */ new Map();
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Load a route component
|
|
341
|
+
* @param route - Route file information
|
|
342
|
+
* @param compiledPath - Optional compiled path for production
|
|
343
|
+
* @returns The loaded component module
|
|
344
|
+
*/
|
|
345
|
+
async loadRouteComponent(route, compiledPath) {
|
|
346
|
+
const cacheKey = compiledPath || route.filePath;
|
|
347
|
+
if (this.componentCache.has(cacheKey)) {
|
|
348
|
+
return this.componentCache.get(cacheKey);
|
|
349
|
+
}
|
|
350
|
+
const sourcePath = resolve2(route.filePath);
|
|
351
|
+
const module = await loadModule(sourcePath, compiledPath);
|
|
352
|
+
const component = module.default;
|
|
353
|
+
if (!component) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`[loly-core] Component not found in ${route.filePath}`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
this.componentCache.set(cacheKey, component);
|
|
359
|
+
return component;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Load a layout component
|
|
363
|
+
* @param layout - Layout route file information
|
|
364
|
+
* @param compiledPath - Optional compiled path for production
|
|
365
|
+
* @returns The loaded layout component module
|
|
366
|
+
*/
|
|
367
|
+
async loadLayoutComponent(layout, compiledPath) {
|
|
368
|
+
const cacheKey = compiledPath || layout.filePath;
|
|
369
|
+
if (this.layoutCache.has(cacheKey)) {
|
|
370
|
+
const cached = this.layoutCache.get(cacheKey);
|
|
371
|
+
return cached?.[0];
|
|
372
|
+
}
|
|
373
|
+
const sourcePath = resolve2(layout.filePath);
|
|
374
|
+
const module = await loadModule(sourcePath, compiledPath);
|
|
375
|
+
const layoutComponent = module.default;
|
|
376
|
+
if (!layoutComponent) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`[loly-core] Layout component not found in ${layout.filePath}`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
this.layoutCache.set(cacheKey, [layoutComponent]);
|
|
382
|
+
return layoutComponent;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Load all layouts from root to route
|
|
386
|
+
* @param route - Target route file
|
|
387
|
+
* @param config - Route configuration
|
|
388
|
+
* @param appDir - Application directory
|
|
389
|
+
* @param manifestRoute - Optional manifest route data
|
|
390
|
+
* @returns Array of layout components in order (root to route)
|
|
391
|
+
*/
|
|
392
|
+
async loadLayouts(route, config2, appDir, manifestRoute) {
|
|
393
|
+
const layouts = [];
|
|
394
|
+
const configWithManifest = config2;
|
|
395
|
+
const routesManifest = configWithManifest.routesManifest;
|
|
396
|
+
if (config2.rootLayout) {
|
|
397
|
+
const rootLayoutManifest = configWithManifest.rootLayoutManifest;
|
|
398
|
+
const layoutSourcePath = resolve2(config2.rootLayout.filePath);
|
|
399
|
+
const layoutCompiledPath = rootLayoutManifest?.serverPath ? resolve2(rootLayoutManifest.serverPath) : void 0;
|
|
400
|
+
const rootLayout = await this.loadLayoutComponent(
|
|
401
|
+
config2.rootLayout,
|
|
402
|
+
layoutCompiledPath
|
|
403
|
+
);
|
|
404
|
+
layouts.push(rootLayout);
|
|
405
|
+
}
|
|
406
|
+
const allRouteSegments = route.segments;
|
|
407
|
+
for (let i = 0; i < allRouteSegments.length; i++) {
|
|
408
|
+
const segmentPath = allRouteSegments.slice(0, i + 1);
|
|
409
|
+
let layoutManifest;
|
|
410
|
+
if (routesManifest) {
|
|
411
|
+
layoutManifest = routesManifest.find((r) => {
|
|
412
|
+
if (r.type !== "layout") return false;
|
|
413
|
+
if (r.isRootLayout) return false;
|
|
414
|
+
if (r.segments.length !== segmentPath.length) return false;
|
|
415
|
+
return r.segments.every((seg, idx) => {
|
|
416
|
+
const routeSeg = segmentPath[idx];
|
|
417
|
+
if (seg.segment === routeSeg.segment) return true;
|
|
418
|
+
if (seg.isDynamic && routeSeg.isDynamic && seg.paramName === routeSeg.paramName) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (seg.isOptional && routeSeg.isOptional && !seg.isDynamic && !routeSeg.isDynamic) {
|
|
422
|
+
const segName = seg.segment.replace(/^\(|\)$/g, "");
|
|
423
|
+
const routeSegName = routeSeg.segment.replace(/^\(|\)$/g, "");
|
|
424
|
+
if (segName === routeSegName) return true;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
if (layoutManifest) {
|
|
431
|
+
const layoutSourcePath = resolve2(layoutManifest.sourcePath);
|
|
432
|
+
const layoutCompiledPath = layoutManifest.serverPath ? resolve2(layoutManifest.serverPath) : void 0;
|
|
433
|
+
try {
|
|
434
|
+
const layoutFile = {
|
|
435
|
+
filePath: layoutSourcePath,
|
|
436
|
+
segments: layoutManifest.segments,
|
|
437
|
+
type: "layout",
|
|
438
|
+
isRootLayout: false,
|
|
439
|
+
urlPath: ""
|
|
440
|
+
};
|
|
441
|
+
const layout = await this.loadLayoutComponent(
|
|
442
|
+
layoutFile,
|
|
443
|
+
layoutCompiledPath
|
|
444
|
+
);
|
|
445
|
+
layouts.push(layout);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.warn(
|
|
448
|
+
`[loly-core] Failed to load nested layout at ${layoutSourcePath}:`,
|
|
449
|
+
error
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
const layoutPathParts = segmentPath.map((s) => s.segment);
|
|
454
|
+
const layoutDir = join(appDir, ...layoutPathParts);
|
|
455
|
+
const layoutFilePath = join(layoutDir, "layout.tsx");
|
|
456
|
+
const layoutFilePathAlt = join(layoutDir, "layout.ts");
|
|
457
|
+
const actualPath = existsSync2(layoutFilePath) ? layoutFilePath : existsSync2(layoutFilePathAlt) ? layoutFilePathAlt : null;
|
|
458
|
+
if (actualPath) {
|
|
459
|
+
try {
|
|
460
|
+
const layoutFile = {
|
|
461
|
+
filePath: actualPath,
|
|
462
|
+
segments: segmentPath,
|
|
463
|
+
type: "layout",
|
|
464
|
+
isRootLayout: false,
|
|
465
|
+
urlPath: ""
|
|
466
|
+
};
|
|
467
|
+
const layout = await this.loadLayoutComponent(layoutFile);
|
|
468
|
+
layouts.push(layout);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.warn(
|
|
471
|
+
`[loly-core] Failed to load nested layout at ${actualPath}:`,
|
|
472
|
+
error
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return layouts;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Clear component cache
|
|
482
|
+
*/
|
|
483
|
+
clearCache() {
|
|
484
|
+
this.componentCache.clear();
|
|
485
|
+
this.layoutCache.clear();
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
// src/server/rendering/strategies.ts
|
|
490
|
+
import { renderToHtmlStream, createHead } from "loly-jsx";
|
|
491
|
+
|
|
492
|
+
// src/server/async-registry.ts
|
|
493
|
+
var asyncTaskRegistry = /* @__PURE__ */ new Map();
|
|
494
|
+
var DEFAULT_MAX_AGE = 5 * 60 * 1e3;
|
|
495
|
+
function registerAsyncTask(id, fn, props) {
|
|
496
|
+
asyncTaskRegistry.set(id, {
|
|
497
|
+
id,
|
|
498
|
+
fn,
|
|
499
|
+
props,
|
|
500
|
+
createdAt: Date.now()
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function resolveAsyncTask(id) {
|
|
504
|
+
const task = asyncTaskRegistry.get(id);
|
|
505
|
+
if (!task) {
|
|
506
|
+
throw new Error(`Async task not found: ${id}`);
|
|
507
|
+
}
|
|
508
|
+
return task.fn();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/server/rendering/strategies.ts
|
|
512
|
+
import { resolve as resolve3 } from "path";
|
|
513
|
+
|
|
514
|
+
// src/router/matcher.ts
|
|
515
|
+
function matchRoute(pathname, config2, loadComponent) {
|
|
516
|
+
const normalized = normalizeUrlPath(pathname);
|
|
517
|
+
if (config2.routeMap.has(normalized)) {
|
|
518
|
+
const route = config2.routeMap.get(normalized);
|
|
519
|
+
return { route, params: {} };
|
|
520
|
+
}
|
|
521
|
+
for (const route of config2.routes) {
|
|
522
|
+
if (route.type !== "page") continue;
|
|
523
|
+
const match = matchRoutePattern(normalized, route);
|
|
524
|
+
if (match) {
|
|
525
|
+
return { route, params: match.params };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
function matchRoutePattern(pathname, route) {
|
|
531
|
+
const pathSegments = pathname === "/" ? [""] : pathname.split("/").filter(Boolean);
|
|
532
|
+
const routeSegments = route.segments.filter((s) => !s.isOptional);
|
|
533
|
+
if (routeSegments.length === 0 && pathSegments.length === 1 && pathSegments[0] === "") {
|
|
534
|
+
return { params: {} };
|
|
535
|
+
}
|
|
536
|
+
const params = {};
|
|
537
|
+
let pathIdx = 0;
|
|
538
|
+
let routeIdx = 0;
|
|
539
|
+
while (pathIdx < pathSegments.length && routeIdx < routeSegments.length) {
|
|
540
|
+
const routeSeg = routeSegments[routeIdx];
|
|
541
|
+
const pathSeg = pathSegments[pathIdx];
|
|
542
|
+
if (routeSeg.isCatchAll) {
|
|
543
|
+
const remaining = pathSegments.slice(pathIdx);
|
|
544
|
+
if (routeSeg.paramName) {
|
|
545
|
+
params[routeSeg.paramName] = remaining.join("/");
|
|
546
|
+
}
|
|
547
|
+
return { params };
|
|
548
|
+
}
|
|
549
|
+
if (routeSeg.isDynamic) {
|
|
550
|
+
if (routeSeg.paramName) {
|
|
551
|
+
params[routeSeg.paramName] = decodeURIComponent(pathSeg);
|
|
552
|
+
}
|
|
553
|
+
pathIdx++;
|
|
554
|
+
routeIdx++;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (routeSeg.segment === pathSeg) {
|
|
558
|
+
pathIdx++;
|
|
559
|
+
routeIdx++;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
if (pathIdx === pathSegments.length && routeIdx === routeSegments.length) {
|
|
565
|
+
return { params };
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/context.ts
|
|
571
|
+
import { join as join2 } from "path";
|
|
572
|
+
var globalContext = null;
|
|
573
|
+
function setContext(context) {
|
|
574
|
+
globalContext = context;
|
|
575
|
+
}
|
|
576
|
+
function getContext() {
|
|
577
|
+
if (!globalContext) {
|
|
578
|
+
throw new Error(ERROR_MESSAGES.CONTEXT_NOT_INITIALIZED);
|
|
579
|
+
}
|
|
580
|
+
return globalContext;
|
|
581
|
+
}
|
|
582
|
+
function getProjectDir() {
|
|
583
|
+
return getContext().projectDir;
|
|
584
|
+
}
|
|
585
|
+
function getAppDir() {
|
|
586
|
+
return getContext().appDir;
|
|
587
|
+
}
|
|
588
|
+
function getSrcDir() {
|
|
589
|
+
return getContext().srcDir;
|
|
590
|
+
}
|
|
591
|
+
function getOutDir() {
|
|
592
|
+
return getContext().outDir;
|
|
593
|
+
}
|
|
594
|
+
function getAppDirPath(projectDir) {
|
|
595
|
+
return join2(projectDir, DIRECTORIES.SRC, DIRECTORIES.APP);
|
|
596
|
+
}
|
|
597
|
+
function getClientDir() {
|
|
598
|
+
return join2(getOutDir(), DIRECTORIES.CLIENT);
|
|
599
|
+
}
|
|
600
|
+
function getServerOutDir() {
|
|
601
|
+
return join2(getOutDir(), DIRECTORIES.SERVER);
|
|
602
|
+
}
|
|
603
|
+
function getManifestPath() {
|
|
604
|
+
return join2(getClientDir(), FILE_NAMES.MANIFEST);
|
|
605
|
+
}
|
|
606
|
+
function getRoutesManifestPath() {
|
|
607
|
+
return join2(getProjectDir(), DIRECTORIES.LOLY, FILE_NAMES.ROUTES_MANIFEST);
|
|
608
|
+
}
|
|
609
|
+
function getBootstrapPath() {
|
|
610
|
+
return join2(getProjectDir(), DIRECTORIES.LOLY, FILE_NAMES.BOOTSTRAP);
|
|
611
|
+
}
|
|
612
|
+
function getGlobalsCssPath() {
|
|
613
|
+
return join2(getClientDir(), FILE_NAMES.GLOBALS_CSS);
|
|
614
|
+
}
|
|
615
|
+
function getPublicDir() {
|
|
616
|
+
return join2(getProjectDir(), DIRECTORIES.PUBLIC);
|
|
617
|
+
}
|
|
618
|
+
function getRouteConfig() {
|
|
619
|
+
return getContext().routeConfig || null;
|
|
620
|
+
}
|
|
621
|
+
function setRouteConfig(config2) {
|
|
622
|
+
const context = getContext();
|
|
623
|
+
context.routeConfig = config2;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/utils/html.ts
|
|
627
|
+
function generateErrorHtml(status, message) {
|
|
628
|
+
const statusText = status === HTTP_STATUS.NOT_FOUND ? "404 - Not Found" : status === HTTP_STATUS.INTERNAL_ERROR ? "500 - Internal Server Error" : `${status} - Error`;
|
|
629
|
+
return `<!doctype html><html><head><title>${statusText}</title></head><body><h1>${statusText}</h1>${message ? `<p>${message}</p>` : ""}</body></html>`;
|
|
630
|
+
}
|
|
631
|
+
function htmlStringToStream(html) {
|
|
632
|
+
const encoder = new TextEncoder();
|
|
633
|
+
return new ReadableStream({
|
|
634
|
+
start(controller) {
|
|
635
|
+
controller.enqueue(encoder.encode(html));
|
|
636
|
+
controller.close();
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
function extractIslandDataFromHtml(html) {
|
|
641
|
+
const islandData = {};
|
|
642
|
+
const islandDataRegex = /<script>window\.__LOLY_ISLAND_DATA__=window\.__LOLY_ISLAND_DATA__\|\|{};window\.__LOLY_ISLAND_DATA__\[([^\]]+)\]=([^<]+);<\/script>/gs;
|
|
643
|
+
const htmlWithoutScripts = html.replace(
|
|
644
|
+
islandDataRegex,
|
|
645
|
+
(match, id, data) => {
|
|
646
|
+
try {
|
|
647
|
+
let islandId;
|
|
648
|
+
const trimmedId = id.trim();
|
|
649
|
+
if (trimmedId.startsWith('"') || trimmedId.startsWith("'")) {
|
|
650
|
+
islandId = JSON.parse(trimmedId);
|
|
651
|
+
} else if (/^\d+$/.test(trimmedId)) {
|
|
652
|
+
islandId = trimmedId;
|
|
653
|
+
} else {
|
|
654
|
+
islandId = trimmedId;
|
|
655
|
+
}
|
|
656
|
+
const trimmedData = data.trim().replace(/;?\s*$/, "");
|
|
657
|
+
const islandProps = JSON.parse(trimmedData);
|
|
658
|
+
islandData[islandId] = islandProps;
|
|
659
|
+
} catch (e) {
|
|
660
|
+
console.warn(
|
|
661
|
+
"[loly-core] Failed to parse island data:",
|
|
662
|
+
e,
|
|
663
|
+
{ id, data: data?.substring(0, 100) }
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return "";
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
return { htmlWithoutScripts, islandData };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/utils/assets.ts
|
|
673
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
674
|
+
function getMainScriptName() {
|
|
675
|
+
const manifestPath = getManifestPath();
|
|
676
|
+
if (!existsSync3(manifestPath)) {
|
|
677
|
+
return "main.js";
|
|
678
|
+
}
|
|
679
|
+
try {
|
|
680
|
+
const manifestContent = readFileSync(manifestPath, "utf-8");
|
|
681
|
+
const manifest = JSON.parse(manifestContent);
|
|
682
|
+
const mainEntry = manifest["main"];
|
|
683
|
+
if (mainEntry?.file) {
|
|
684
|
+
return mainEntry.file;
|
|
685
|
+
}
|
|
686
|
+
} catch (error) {
|
|
687
|
+
console.warn("[loly-core] Failed to read client manifest:", error);
|
|
688
|
+
}
|
|
689
|
+
return "main.js";
|
|
690
|
+
}
|
|
691
|
+
function generateAssetTags(manifestRoute, publicPath = "/") {
|
|
692
|
+
let scripts = "";
|
|
693
|
+
let styles = "";
|
|
694
|
+
let preloads = "";
|
|
695
|
+
const mainScriptName = getMainScriptName();
|
|
696
|
+
const mainScriptPath = mainScriptName.startsWith("/") ? mainScriptName : `${publicPath}${mainScriptName}`;
|
|
697
|
+
preloads = `<link rel="modulepreload" href="${mainScriptPath}" crossorigin>`;
|
|
698
|
+
scripts = `<script type="module" src="${mainScriptPath}"></script>`;
|
|
699
|
+
const globalsCssPath = getGlobalsCssPath();
|
|
700
|
+
if (existsSync3(globalsCssPath)) {
|
|
701
|
+
const cssPath = `${publicPath}globals.css`;
|
|
702
|
+
styles += `
|
|
703
|
+
<link rel="stylesheet" href="${cssPath}">`;
|
|
704
|
+
}
|
|
705
|
+
if (manifestRoute?.clientChunks) {
|
|
706
|
+
const { js = [], css = [] } = manifestRoute.clientChunks;
|
|
707
|
+
for (const jsFile of js) {
|
|
708
|
+
const scriptPath = jsFile.startsWith("/") ? jsFile : `${publicPath}${jsFile}`;
|
|
709
|
+
scripts += `
|
|
710
|
+
<script type="module" src="${scriptPath}"></script>`;
|
|
711
|
+
}
|
|
712
|
+
for (const cssFile of css) {
|
|
713
|
+
const cssPath = cssFile.startsWith("/") ? cssFile : `${publicPath}${cssFile}`;
|
|
714
|
+
styles += `
|
|
715
|
+
<link rel="stylesheet" href="${cssPath}">`;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return { scripts, styles, preloads };
|
|
719
|
+
}
|
|
720
|
+
function getClientAssetsForRoute(manifestRoute) {
|
|
721
|
+
return generateAssetTags(manifestRoute, "/");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/utils/errors.ts
|
|
725
|
+
function handleRouteError(error, pathname) {
|
|
726
|
+
if (error.message.includes("Route not found")) {
|
|
727
|
+
return {
|
|
728
|
+
html: htmlStringToStream(generateErrorHtml(HTTP_STATUS.NOT_FOUND, "")),
|
|
729
|
+
status: HTTP_STATUS.NOT_FOUND
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
console.error(ERROR_MESSAGES.ERROR_RENDERING_PAGE, error);
|
|
733
|
+
return {
|
|
734
|
+
html: htmlStringToStream(generateErrorHtml(HTTP_STATUS.INTERNAL_ERROR, "")),
|
|
735
|
+
status: HTTP_STATUS.INTERNAL_ERROR
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// src/utils/env.ts
|
|
740
|
+
function getPublicEnv() {
|
|
741
|
+
const publicEnv = {};
|
|
742
|
+
if (typeof process !== "undefined" && process.env) {
|
|
743
|
+
for (const key in process.env) {
|
|
744
|
+
if (key.startsWith("LOLY_PUBLIC_")) {
|
|
745
|
+
const clientKey = key.replace(/^LOLY_PUBLIC_/, "");
|
|
746
|
+
publicEnv[clientKey] = process.env[key] || "";
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
return publicEnv;
|
|
751
|
+
}
|
|
752
|
+
function generatePublicEnvScript() {
|
|
753
|
+
const publicEnv = getPublicEnv();
|
|
754
|
+
const envJson = JSON.stringify(publicEnv);
|
|
755
|
+
return `<script>window.__LOLY_ENV__=Object.freeze(${envJson});</script>`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/server/rendering/strategies.ts
|
|
759
|
+
var RSCStrategy = class {
|
|
760
|
+
constructor(routeFactory2) {
|
|
761
|
+
this.routeFactory = routeFactory2;
|
|
762
|
+
}
|
|
763
|
+
async render(context, config2) {
|
|
764
|
+
const { pathname } = context;
|
|
765
|
+
const appDir = getAppDir();
|
|
766
|
+
const configWithManifest = config2;
|
|
767
|
+
const manifestRouteMap = configWithManifest.routeMapManifest;
|
|
768
|
+
const manifestRoute = manifestRouteMap?.get(pathname);
|
|
769
|
+
const match = matchRoute(pathname, config2, async (route2) => {
|
|
770
|
+
const routeManifest = manifestRouteMap?.get(pathname) || Array.from(manifestRouteMap?.values() || []).find(
|
|
771
|
+
(r) => r.urlPath === route2.urlPath
|
|
772
|
+
);
|
|
773
|
+
const compiledPath = routeManifest?.serverPath ? resolve3(routeManifest.serverPath) : void 0;
|
|
774
|
+
return await this.routeFactory.loadRouteComponent(route2, compiledPath);
|
|
775
|
+
});
|
|
776
|
+
if (!match) {
|
|
777
|
+
throw new Error(ERROR_MESSAGES.ROUTE_NOT_FOUND(pathname));
|
|
778
|
+
}
|
|
779
|
+
const { route, params } = match;
|
|
780
|
+
context.params = params;
|
|
781
|
+
const layouts = await this.routeFactory.loadLayouts(
|
|
782
|
+
route,
|
|
783
|
+
config2,
|
|
784
|
+
appDir,
|
|
785
|
+
manifestRoute
|
|
786
|
+
);
|
|
787
|
+
const pageCompiledPath = manifestRoute?.serverPath ? resolve3(manifestRoute.serverPath) : void 0;
|
|
788
|
+
const PageComponent = await this.routeFactory.loadRouteComponent(
|
|
789
|
+
route,
|
|
790
|
+
pageCompiledPath
|
|
791
|
+
);
|
|
792
|
+
const pageProps = {
|
|
793
|
+
params,
|
|
794
|
+
searchParams: context.searchParams
|
|
795
|
+
};
|
|
796
|
+
let pageContent;
|
|
797
|
+
if (typeof PageComponent === "function") {
|
|
798
|
+
const result = PageComponent(pageProps);
|
|
799
|
+
pageContent = result instanceof Promise ? await result : result;
|
|
800
|
+
} else {
|
|
801
|
+
throw new Error(ERROR_MESSAGES.PAGE_COMPONENT_MUST_BE_FUNCTION);
|
|
802
|
+
}
|
|
803
|
+
let content = pageContent;
|
|
804
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
805
|
+
const Layout = layouts[i];
|
|
806
|
+
if (typeof Layout === "function") {
|
|
807
|
+
const layoutResult = Layout({ children: content, params });
|
|
808
|
+
content = layoutResult instanceof Promise ? await layoutResult : layoutResult;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const { renderToString: renderToString2 } = await import("loly-jsx");
|
|
812
|
+
const htmlWithScripts = await renderToString2(content);
|
|
813
|
+
const { htmlWithoutScripts, islandData } = extractIslandDataFromHtml(htmlWithScripts);
|
|
814
|
+
const { scripts, styles, preloads } = getClientAssetsForRoute(manifestRoute);
|
|
815
|
+
return { html: htmlWithoutScripts, scripts, styles, preloads, islandData };
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
var SSRStrategy = class {
|
|
819
|
+
constructor(routeFactory2) {
|
|
820
|
+
this.routeFactory = routeFactory2;
|
|
821
|
+
}
|
|
822
|
+
async render(context, config2) {
|
|
823
|
+
const { pathname } = context;
|
|
824
|
+
const appDir = getAppDir();
|
|
825
|
+
const configWithManifest = config2;
|
|
826
|
+
const manifestRouteMap = configWithManifest.routeMapManifest;
|
|
827
|
+
const manifestRoute = manifestRouteMap?.get(pathname);
|
|
828
|
+
const match = matchRoute(pathname, config2, async (route2) => {
|
|
829
|
+
const routeManifest = manifestRouteMap?.get(pathname) || Array.from(manifestRouteMap?.values() || []).find(
|
|
830
|
+
(r) => r.urlPath === route2.urlPath
|
|
831
|
+
);
|
|
832
|
+
const compiledPath = routeManifest?.serverPath ? resolve3(routeManifest.serverPath) : void 0;
|
|
833
|
+
return await this.routeFactory.loadRouteComponent(route2, compiledPath);
|
|
834
|
+
});
|
|
835
|
+
if (!match) {
|
|
836
|
+
return {
|
|
837
|
+
html: htmlStringToStream(generateErrorHtml(HTTP_STATUS.NOT_FOUND, "")),
|
|
838
|
+
status: HTTP_STATUS.NOT_FOUND
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
const { route, params } = match;
|
|
842
|
+
context.params = params;
|
|
843
|
+
try {
|
|
844
|
+
const layouts = await this.routeFactory.loadLayouts(
|
|
845
|
+
route,
|
|
846
|
+
config2,
|
|
847
|
+
appDir,
|
|
848
|
+
manifestRoute
|
|
849
|
+
);
|
|
850
|
+
const pageCompiledPath = manifestRoute?.serverPath ? resolve3(manifestRoute.serverPath) : void 0;
|
|
851
|
+
const PageComponent = await this.routeFactory.loadRouteComponent(
|
|
852
|
+
route,
|
|
853
|
+
pageCompiledPath
|
|
854
|
+
);
|
|
855
|
+
const pageProps = {
|
|
856
|
+
params,
|
|
857
|
+
searchParams: context.searchParams
|
|
858
|
+
};
|
|
859
|
+
let pageContent;
|
|
860
|
+
if (typeof PageComponent === "function") {
|
|
861
|
+
const result = PageComponent(pageProps);
|
|
862
|
+
pageContent = result instanceof Promise ? await result : result;
|
|
863
|
+
} else {
|
|
864
|
+
throw new Error(ERROR_MESSAGES.PAGE_COMPONENT_MUST_BE_FUNCTION);
|
|
865
|
+
}
|
|
866
|
+
let content = pageContent;
|
|
867
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
868
|
+
const Layout = layouts[i];
|
|
869
|
+
if (typeof Layout === "function") {
|
|
870
|
+
const layoutResult = Layout({ children: content, params });
|
|
871
|
+
content = layoutResult instanceof Promise ? await layoutResult : layoutResult;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const head = createHead();
|
|
875
|
+
head.setTitle("Loly App");
|
|
876
|
+
const { scripts, styles, preloads } = getClientAssetsForRoute(manifestRoute);
|
|
877
|
+
const envScript = generatePublicEnvScript();
|
|
878
|
+
const headParts = [head.toString()];
|
|
879
|
+
if (preloads) headParts.push(preloads);
|
|
880
|
+
headParts.push(envScript);
|
|
881
|
+
const headContent = headParts.join("\n");
|
|
882
|
+
const htmlStream = renderToHtmlStream({
|
|
883
|
+
view: content,
|
|
884
|
+
head: headContent,
|
|
885
|
+
scripts,
|
|
886
|
+
styles,
|
|
887
|
+
appId: SERVER.APP_CONTAINER_ID,
|
|
888
|
+
onAsyncTask: (id, fn, props) => {
|
|
889
|
+
registerAsyncTask(id, fn, props);
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
return {
|
|
893
|
+
html: htmlStream,
|
|
894
|
+
status: HTTP_STATUS.OK
|
|
895
|
+
};
|
|
896
|
+
} catch (error) {
|
|
897
|
+
return handleRouteError(error, context.pathname);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// src/server/ssr.ts
|
|
903
|
+
var routeFactory = new RouteComponentFactory();
|
|
904
|
+
async function renderPageContent(context, config2, appDir) {
|
|
905
|
+
const rscStrategy = new RSCStrategy(routeFactory);
|
|
906
|
+
return await rscStrategy.render(context, config2);
|
|
907
|
+
}
|
|
908
|
+
async function renderPageStream(context, config2, appDir) {
|
|
909
|
+
const ssrStrategy = new SSRStrategy(routeFactory);
|
|
910
|
+
return await ssrStrategy.render(context, config2);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// src/server/base-server.ts
|
|
914
|
+
import express2 from "express";
|
|
915
|
+
|
|
916
|
+
// src/utils/express-setup.ts
|
|
917
|
+
import express from "express";
|
|
918
|
+
import { Readable } from "stream";
|
|
919
|
+
import { existsSync as existsSync4 } from "fs";
|
|
920
|
+
|
|
921
|
+
// src/server/handlers/image.ts
|
|
922
|
+
import crypto2 from "crypto";
|
|
923
|
+
|
|
924
|
+
// src/server/utils/image-optimizer.ts
|
|
925
|
+
import sharp from "sharp";
|
|
926
|
+
import fs2 from "fs";
|
|
927
|
+
import path3 from "path";
|
|
928
|
+
|
|
929
|
+
// src/server/utils/image-validation.ts
|
|
930
|
+
import path from "path";
|
|
931
|
+
function isRemoteUrl(url) {
|
|
932
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
933
|
+
}
|
|
934
|
+
function sanitizeImagePath(imagePath) {
|
|
935
|
+
const normalized = path.normalize(imagePath).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
936
|
+
return normalized.replace(/^[/\\]+/, "");
|
|
937
|
+
}
|
|
938
|
+
function patternToRegex(pattern) {
|
|
939
|
+
const parts = [];
|
|
940
|
+
if (pattern.protocol) {
|
|
941
|
+
parts.push(pattern.protocol === "https" ? "https" : "http");
|
|
942
|
+
} else {
|
|
943
|
+
parts.push("https?");
|
|
944
|
+
}
|
|
945
|
+
parts.push("://");
|
|
946
|
+
let hostnamePattern = pattern.hostname.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^.]*");
|
|
947
|
+
parts.push(hostnamePattern);
|
|
948
|
+
if (pattern.port) {
|
|
949
|
+
parts.push(`:${pattern.port}`);
|
|
950
|
+
}
|
|
951
|
+
if (pattern.pathname) {
|
|
952
|
+
let pathnamePattern = pattern.pathname.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
|
|
953
|
+
parts.push(pathnamePattern);
|
|
954
|
+
} else {
|
|
955
|
+
parts.push(".*");
|
|
956
|
+
}
|
|
957
|
+
const regexSource = `^${parts.join("")}`;
|
|
958
|
+
return new RegExp(regexSource);
|
|
959
|
+
}
|
|
960
|
+
function validateRemoteUrl(url, config2) {
|
|
961
|
+
if (!config2 || !config2.remotePatterns && !config2.domains) {
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const urlObj = new URL(url);
|
|
966
|
+
const protocol = urlObj.protocol.replace(":", "");
|
|
967
|
+
const hostname = urlObj.hostname;
|
|
968
|
+
const port = urlObj.port || "";
|
|
969
|
+
const pathname = urlObj.pathname;
|
|
970
|
+
if (config2.remotePatterns && config2.remotePatterns.length > 0) {
|
|
971
|
+
for (const pattern of config2.remotePatterns) {
|
|
972
|
+
const regex = patternToRegex(pattern);
|
|
973
|
+
const testUrl = `${protocol}://${hostname}${port ? `:${port}` : ""}${pathname}`;
|
|
974
|
+
if (regex.test(testUrl)) {
|
|
975
|
+
if (pattern.protocol && pattern.protocol !== protocol) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (pattern.port && pattern.port !== port) {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
return true;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (config2.domains && config2.domains.length > 0) {
|
|
986
|
+
for (const domain of config2.domains) {
|
|
987
|
+
const domainPattern = domain.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^.]*");
|
|
988
|
+
const regex = new RegExp(`^${domainPattern}$`);
|
|
989
|
+
if (regex.test(hostname)) {
|
|
990
|
+
if (process.env.NODE_ENV === "production" && protocol !== "https") {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return false;
|
|
998
|
+
} catch (error) {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function validateImageDimensions(width, height, config2) {
|
|
1003
|
+
const maxWidth = config2?.maxWidth || 3840;
|
|
1004
|
+
const maxHeight = config2?.maxHeight || 3840;
|
|
1005
|
+
if (width !== void 0 && (width <= 0 || width > maxWidth)) {
|
|
1006
|
+
return {
|
|
1007
|
+
valid: false,
|
|
1008
|
+
error: `Image width must be between 1 and ${maxWidth}, got ${width}`
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
if (height !== void 0 && (height <= 0 || height > maxHeight)) {
|
|
1012
|
+
return {
|
|
1013
|
+
valid: false,
|
|
1014
|
+
error: `Image height must be between 1 and ${maxHeight}, got ${height}`
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
return { valid: true };
|
|
1018
|
+
}
|
|
1019
|
+
function validateQuality(quality) {
|
|
1020
|
+
if (quality === void 0) {
|
|
1021
|
+
return { valid: true };
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof quality !== "number" || quality < 1 || quality > 100) {
|
|
1024
|
+
return {
|
|
1025
|
+
valid: false,
|
|
1026
|
+
error: `Image quality must be between 1 and 100, got ${quality}`
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
return { valid: true };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/server/utils/image-cache.ts
|
|
1033
|
+
import fs from "fs";
|
|
1034
|
+
import path2 from "path";
|
|
1035
|
+
import crypto from "crypto";
|
|
1036
|
+
var ImageLRUCache = class {
|
|
1037
|
+
constructor(maxSize = 50) {
|
|
1038
|
+
this.maxSize = maxSize;
|
|
1039
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Get an image from cache
|
|
1043
|
+
*/
|
|
1044
|
+
get(key) {
|
|
1045
|
+
if (!this.cache.has(key)) {
|
|
1046
|
+
return void 0;
|
|
1047
|
+
}
|
|
1048
|
+
const value = this.cache.get(key);
|
|
1049
|
+
this.cache.delete(key);
|
|
1050
|
+
this.cache.set(key, value);
|
|
1051
|
+
return value;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Set an image in cache
|
|
1055
|
+
*/
|
|
1056
|
+
set(key, value) {
|
|
1057
|
+
if (this.cache.has(key)) {
|
|
1058
|
+
this.cache.delete(key);
|
|
1059
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
1060
|
+
const firstKey = this.cache.keys().next().value;
|
|
1061
|
+
if (firstKey) {
|
|
1062
|
+
this.cache.delete(firstKey);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
this.cache.set(key, value);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Check if key exists in cache
|
|
1069
|
+
*/
|
|
1070
|
+
has(key) {
|
|
1071
|
+
return this.cache.has(key);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Clear the cache
|
|
1075
|
+
*/
|
|
1076
|
+
clear() {
|
|
1077
|
+
this.cache.clear();
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Get cache size
|
|
1081
|
+
*/
|
|
1082
|
+
size() {
|
|
1083
|
+
return this.cache.size;
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
var globalLRUCache = null;
|
|
1087
|
+
function getLRUCache(maxSize) {
|
|
1088
|
+
if (!globalLRUCache) {
|
|
1089
|
+
globalLRUCache = new ImageLRUCache(maxSize);
|
|
1090
|
+
}
|
|
1091
|
+
return globalLRUCache;
|
|
1092
|
+
}
|
|
1093
|
+
function generateCacheKey(src, width, height, quality, format) {
|
|
1094
|
+
const data = `${src}-${width || ""}-${height || ""}-${quality || ""}-${format || ""}`;
|
|
1095
|
+
return crypto.createHash("sha256").update(data).digest("hex");
|
|
1096
|
+
}
|
|
1097
|
+
function getCacheDir() {
|
|
1098
|
+
const projectDir = getProjectDir();
|
|
1099
|
+
return path2.join(projectDir, DIRECTORIES.LOLY, "cache", "images");
|
|
1100
|
+
}
|
|
1101
|
+
function ensureCacheDir(cacheDir) {
|
|
1102
|
+
if (!fs.existsSync(cacheDir)) {
|
|
1103
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
function getCachedImagePath(cacheKey, extension, cacheDir) {
|
|
1107
|
+
return path2.join(cacheDir, `${cacheKey}.${extension}`);
|
|
1108
|
+
}
|
|
1109
|
+
function hasCachedImage(cacheKey, extension, cacheDir) {
|
|
1110
|
+
const cachedPath = getCachedImagePath(cacheKey, extension, cacheDir);
|
|
1111
|
+
return fs.existsSync(cachedPath);
|
|
1112
|
+
}
|
|
1113
|
+
function readCachedImage(cacheKey, extension, cacheDir) {
|
|
1114
|
+
const cachedPath = getCachedImagePath(cacheKey, extension, cacheDir);
|
|
1115
|
+
try {
|
|
1116
|
+
if (fs.existsSync(cachedPath)) {
|
|
1117
|
+
return fs.readFileSync(cachedPath);
|
|
1118
|
+
}
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
console.warn(`[image-optimizer] Failed to read cached image: ${cachedPath}`, error);
|
|
1121
|
+
}
|
|
1122
|
+
return null;
|
|
1123
|
+
}
|
|
1124
|
+
function writeCachedImage(cacheKey, extension, cacheDir, imageBuffer) {
|
|
1125
|
+
ensureCacheDir(cacheDir);
|
|
1126
|
+
const cachedPath = getCachedImagePath(cacheKey, extension, cacheDir);
|
|
1127
|
+
try {
|
|
1128
|
+
fs.writeFileSync(cachedPath, imageBuffer);
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
console.warn(`[image-optimizer] Failed to write cached image: ${cachedPath}`, error);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function getImageMimeType(format) {
|
|
1134
|
+
const formatMap = {
|
|
1135
|
+
webp: "image/webp",
|
|
1136
|
+
avif: "image/avif",
|
|
1137
|
+
jpeg: "image/jpeg",
|
|
1138
|
+
jpg: "image/jpeg",
|
|
1139
|
+
png: "image/png",
|
|
1140
|
+
gif: "image/gif",
|
|
1141
|
+
svg: "image/svg+xml"
|
|
1142
|
+
};
|
|
1143
|
+
const normalized = format.toLowerCase();
|
|
1144
|
+
return formatMap[normalized] || "image/jpeg";
|
|
1145
|
+
}
|
|
1146
|
+
function getImageExtension(format) {
|
|
1147
|
+
const formatMap = {
|
|
1148
|
+
"image/webp": "webp",
|
|
1149
|
+
"image/avif": "avif",
|
|
1150
|
+
"image/jpeg": "jpg",
|
|
1151
|
+
"image/png": "png",
|
|
1152
|
+
"image/gif": "gif",
|
|
1153
|
+
"image/svg+xml": "svg",
|
|
1154
|
+
webp: "webp",
|
|
1155
|
+
avif: "avif",
|
|
1156
|
+
jpeg: "jpg",
|
|
1157
|
+
jpg: "jpg",
|
|
1158
|
+
png: "png",
|
|
1159
|
+
gif: "gif",
|
|
1160
|
+
svg: "svg"
|
|
1161
|
+
};
|
|
1162
|
+
const normalized = format.toLowerCase();
|
|
1163
|
+
return formatMap[normalized] || "jpg";
|
|
1164
|
+
}
|
|
1165
|
+
function cleanupCacheByAge(cacheDir, maxAgeDays = 30) {
|
|
1166
|
+
const result = { deleted: 0, freed: 0 };
|
|
1167
|
+
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1e3;
|
|
1168
|
+
const now = Date.now();
|
|
1169
|
+
try {
|
|
1170
|
+
if (!fs.existsSync(cacheDir)) {
|
|
1171
|
+
return result;
|
|
1172
|
+
}
|
|
1173
|
+
const files = fs.readdirSync(cacheDir);
|
|
1174
|
+
for (const file of files) {
|
|
1175
|
+
const filePath = path2.join(cacheDir, file);
|
|
1176
|
+
try {
|
|
1177
|
+
const stat = fs.statSync(filePath);
|
|
1178
|
+
if (stat.isFile()) {
|
|
1179
|
+
const age = now - stat.mtime.getTime();
|
|
1180
|
+
if (age > maxAgeMs) {
|
|
1181
|
+
const size = stat.size;
|
|
1182
|
+
fs.unlinkSync(filePath);
|
|
1183
|
+
result.deleted++;
|
|
1184
|
+
result.freed += size;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
console.warn(`[image-cache] Failed to cleanup cache by age: ${cacheDir}`, error);
|
|
1193
|
+
}
|
|
1194
|
+
return result;
|
|
1195
|
+
}
|
|
1196
|
+
function cleanupCacheBySize(cacheDir, maxSizeMB = 500) {
|
|
1197
|
+
const result = { deleted: 0, freed: 0 };
|
|
1198
|
+
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
|
1199
|
+
try {
|
|
1200
|
+
if (!fs.existsSync(cacheDir)) {
|
|
1201
|
+
return result;
|
|
1202
|
+
}
|
|
1203
|
+
const files = fs.readdirSync(cacheDir);
|
|
1204
|
+
const fileInfos = [];
|
|
1205
|
+
for (const file of files) {
|
|
1206
|
+
const filePath = path2.join(cacheDir, file);
|
|
1207
|
+
try {
|
|
1208
|
+
const stat = fs.statSync(filePath);
|
|
1209
|
+
if (stat.isFile()) {
|
|
1210
|
+
fileInfos.push({
|
|
1211
|
+
path: filePath,
|
|
1212
|
+
mtime: stat.mtime.getTime(),
|
|
1213
|
+
size: stat.size
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const totalSize = fileInfos.reduce((sum, info) => sum + info.size, 0);
|
|
1221
|
+
if (totalSize <= maxSizeBytes) {
|
|
1222
|
+
return result;
|
|
1223
|
+
}
|
|
1224
|
+
fileInfos.sort((a, b) => a.mtime - b.mtime);
|
|
1225
|
+
let currentSize = totalSize;
|
|
1226
|
+
for (const fileInfo of fileInfos) {
|
|
1227
|
+
if (currentSize <= maxSizeBytes) {
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
try {
|
|
1231
|
+
fs.unlinkSync(fileInfo.path);
|
|
1232
|
+
result.deleted++;
|
|
1233
|
+
result.freed += fileInfo.size;
|
|
1234
|
+
currentSize -= fileInfo.size;
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
console.warn(`[image-cache] Failed to cleanup cache by size: ${cacheDir}`, error);
|
|
1241
|
+
}
|
|
1242
|
+
return result;
|
|
1243
|
+
}
|
|
1244
|
+
function cleanupCache(config2) {
|
|
1245
|
+
const cacheDir = getCacheDir();
|
|
1246
|
+
const maxSizeMB = config2?.maxSizeMB ?? 500;
|
|
1247
|
+
const maxAgeDays = config2?.maxAgeDays ?? 30;
|
|
1248
|
+
const ageResult = cleanupCacheByAge(cacheDir, maxAgeDays);
|
|
1249
|
+
const sizeResult = cleanupCacheBySize(cacheDir, maxSizeMB);
|
|
1250
|
+
return {
|
|
1251
|
+
deleted: ageResult.deleted + sizeResult.deleted,
|
|
1252
|
+
freed: ageResult.freed + sizeResult.freed
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// src/server/utils/image-optimizer.ts
|
|
1257
|
+
var DEFAULT_IMAGE_CONFIG = {
|
|
1258
|
+
maxWidth: 3840,
|
|
1259
|
+
maxHeight: 3840,
|
|
1260
|
+
quality: 70,
|
|
1261
|
+
formats: ["image/avif", "image/webp"],
|
|
1262
|
+
minimumCacheTTL: 31536e3,
|
|
1263
|
+
// 1 año (en lugar de 60)
|
|
1264
|
+
cacheMaxSize: 500,
|
|
1265
|
+
cacheMaxAge: 30,
|
|
1266
|
+
cacheLRUSize: 50,
|
|
1267
|
+
cacheCleanupInterval: 100
|
|
1268
|
+
};
|
|
1269
|
+
var requestCount = 0;
|
|
1270
|
+
async function downloadRemoteImage(url, timeout = 1e4) {
|
|
1271
|
+
const controller = new AbortController();
|
|
1272
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1273
|
+
try {
|
|
1274
|
+
const response = await fetch(url, {
|
|
1275
|
+
signal: controller.signal,
|
|
1276
|
+
headers: {
|
|
1277
|
+
"User-Agent": "Loly-Image-Optimizer/1.0"
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
clearTimeout(timeoutId);
|
|
1281
|
+
if (!response.ok) {
|
|
1282
|
+
throw new Error(`Failed to download image: ${response.status} ${response.statusText}`);
|
|
1283
|
+
}
|
|
1284
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
1285
|
+
return Buffer.from(arrayBuffer);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
clearTimeout(timeoutId);
|
|
1288
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1289
|
+
throw new Error(`Image download timeout after ${timeout}ms`);
|
|
1290
|
+
}
|
|
1291
|
+
throw error;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
function readLocalImage(src, projectRoot) {
|
|
1295
|
+
const sanitized = sanitizeImagePath(src);
|
|
1296
|
+
const publicDir = getPublicDir();
|
|
1297
|
+
const publicPath = path3.join(publicDir, sanitized);
|
|
1298
|
+
if (fs2.existsSync(publicPath)) {
|
|
1299
|
+
return fs2.readFileSync(publicPath);
|
|
1300
|
+
}
|
|
1301
|
+
if (src.startsWith("/")) {
|
|
1302
|
+
const absolutePath = path3.join(projectRoot, sanitized);
|
|
1303
|
+
if (fs2.existsSync(absolutePath)) {
|
|
1304
|
+
return fs2.readFileSync(absolutePath);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
throw new Error(`Image not found: ${src}`);
|
|
1308
|
+
}
|
|
1309
|
+
function determineOutputFormat(sourceFormat, requestedFormat, config2) {
|
|
1310
|
+
if (sourceFormat === "svg") {
|
|
1311
|
+
return "svg";
|
|
1312
|
+
}
|
|
1313
|
+
if (requestedFormat && requestedFormat !== "auto") {
|
|
1314
|
+
return requestedFormat;
|
|
1315
|
+
}
|
|
1316
|
+
const supportedFormats = config2.formats || ["image/webp"];
|
|
1317
|
+
if (supportedFormats.includes("image/avif")) {
|
|
1318
|
+
return "avif";
|
|
1319
|
+
}
|
|
1320
|
+
if (supportedFormats.includes("image/webp")) {
|
|
1321
|
+
return "webp";
|
|
1322
|
+
}
|
|
1323
|
+
return sourceFormat === "svg" ? "jpeg" : sourceFormat;
|
|
1324
|
+
}
|
|
1325
|
+
async function optimizeImage(options, config2) {
|
|
1326
|
+
const imageConfig = config2 || DEFAULT_IMAGE_CONFIG;
|
|
1327
|
+
const projectRoot = getProjectDir();
|
|
1328
|
+
const dimValidation = validateImageDimensions(options.width, options.height, imageConfig);
|
|
1329
|
+
if (!dimValidation.valid) {
|
|
1330
|
+
throw new Error(dimValidation.error);
|
|
1331
|
+
}
|
|
1332
|
+
const qualityValidation = validateQuality(options.quality);
|
|
1333
|
+
if (!qualityValidation.valid) {
|
|
1334
|
+
throw new Error(qualityValidation.error);
|
|
1335
|
+
}
|
|
1336
|
+
if (isRemoteUrl(options.src)) {
|
|
1337
|
+
if (!validateRemoteUrl(options.src, imageConfig)) {
|
|
1338
|
+
throw new Error(`Remote image domain not allowed: ${options.src}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const sourceFormat = path3.extname(options.src).slice(1).toLowerCase() || "jpeg";
|
|
1342
|
+
const outputFormat = determineOutputFormat(sourceFormat, options.format, imageConfig);
|
|
1343
|
+
const cacheKey = generateCacheKey(
|
|
1344
|
+
options.src,
|
|
1345
|
+
options.width,
|
|
1346
|
+
options.height,
|
|
1347
|
+
options.quality || imageConfig.quality || 75,
|
|
1348
|
+
outputFormat
|
|
1349
|
+
);
|
|
1350
|
+
const cacheDir = getCacheDir();
|
|
1351
|
+
const extension = getImageExtension(outputFormat);
|
|
1352
|
+
const fullCacheKey = `${cacheKey}.${extension}`;
|
|
1353
|
+
const lruSize = imageConfig.cacheLRUSize || DEFAULT_IMAGE_CONFIG.cacheLRUSize || 50;
|
|
1354
|
+
const lruCache = getLRUCache(lruSize);
|
|
1355
|
+
const lruCached = lruCache.get(fullCacheKey);
|
|
1356
|
+
if (lruCached) {
|
|
1357
|
+
const metadata2 = await sharp(lruCached).metadata();
|
|
1358
|
+
return {
|
|
1359
|
+
buffer: lruCached,
|
|
1360
|
+
format: outputFormat,
|
|
1361
|
+
mimeType: getImageMimeType(outputFormat),
|
|
1362
|
+
width: metadata2.width || options.width || 0,
|
|
1363
|
+
height: metadata2.height || options.height || 0
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
if (hasCachedImage(cacheKey, extension, cacheDir)) {
|
|
1367
|
+
const cached = readCachedImage(cacheKey, extension, cacheDir);
|
|
1368
|
+
if (cached) {
|
|
1369
|
+
lruCache.set(fullCacheKey, cached);
|
|
1370
|
+
const metadata2 = await sharp(cached).metadata();
|
|
1371
|
+
return {
|
|
1372
|
+
buffer: cached,
|
|
1373
|
+
format: outputFormat,
|
|
1374
|
+
mimeType: getImageMimeType(outputFormat),
|
|
1375
|
+
width: metadata2.width || options.width || 0,
|
|
1376
|
+
height: metadata2.height || options.height || 0
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
let imageBuffer;
|
|
1381
|
+
if (isRemoteUrl(options.src)) {
|
|
1382
|
+
imageBuffer = await downloadRemoteImage(options.src);
|
|
1383
|
+
} else {
|
|
1384
|
+
imageBuffer = readLocalImage(options.src, projectRoot);
|
|
1385
|
+
}
|
|
1386
|
+
if (outputFormat === "svg" || sourceFormat === "svg") {
|
|
1387
|
+
if (!imageConfig.dangerouslyAllowSVG) {
|
|
1388
|
+
throw new Error("SVG images are not allowed. Set images.dangerouslyAllowSVG to true to enable.");
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
buffer: imageBuffer,
|
|
1392
|
+
format: "svg",
|
|
1393
|
+
mimeType: "image/svg+xml",
|
|
1394
|
+
width: options.width || 0,
|
|
1395
|
+
height: options.height || 0
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
let sharpInstance = sharp(imageBuffer);
|
|
1399
|
+
const metadata = await sharpInstance.metadata();
|
|
1400
|
+
if (options.width || options.height) {
|
|
1401
|
+
const fit = options.fit || "cover";
|
|
1402
|
+
sharpInstance = sharpInstance.resize(options.width, options.height, {
|
|
1403
|
+
fit,
|
|
1404
|
+
withoutEnlargement: true
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
const quality = options.quality || imageConfig.quality || 75;
|
|
1408
|
+
switch (outputFormat) {
|
|
1409
|
+
case "webp":
|
|
1410
|
+
sharpInstance = sharpInstance.webp({ quality });
|
|
1411
|
+
break;
|
|
1412
|
+
case "avif":
|
|
1413
|
+
sharpInstance = sharpInstance.avif({ quality });
|
|
1414
|
+
break;
|
|
1415
|
+
case "jpeg":
|
|
1416
|
+
case "jpg":
|
|
1417
|
+
sharpInstance = sharpInstance.jpeg({ quality });
|
|
1418
|
+
break;
|
|
1419
|
+
case "png":
|
|
1420
|
+
sharpInstance = sharpInstance.png({ quality: Math.round(quality / 100 * 9) });
|
|
1421
|
+
break;
|
|
1422
|
+
default:
|
|
1423
|
+
sharpInstance = sharpInstance.jpeg({ quality });
|
|
1424
|
+
}
|
|
1425
|
+
const optimizedBuffer = await sharpInstance.toBuffer();
|
|
1426
|
+
const finalMetadata = await sharp(optimizedBuffer).metadata();
|
|
1427
|
+
lruCache.set(fullCacheKey, optimizedBuffer);
|
|
1428
|
+
writeCachedImage(cacheKey, extension, cacheDir, optimizedBuffer);
|
|
1429
|
+
requestCount++;
|
|
1430
|
+
const cleanupInterval = imageConfig.cacheCleanupInterval || DEFAULT_IMAGE_CONFIG.cacheCleanupInterval || 100;
|
|
1431
|
+
if (requestCount >= cleanupInterval) {
|
|
1432
|
+
requestCount = 0;
|
|
1433
|
+
setImmediate(() => {
|
|
1434
|
+
try {
|
|
1435
|
+
const cleanupResult = cleanupCache({
|
|
1436
|
+
maxSizeMB: imageConfig.cacheMaxSize || DEFAULT_IMAGE_CONFIG.cacheMaxSize,
|
|
1437
|
+
maxAgeDays: imageConfig.cacheMaxAge || DEFAULT_IMAGE_CONFIG.cacheMaxAge
|
|
1438
|
+
});
|
|
1439
|
+
if (cleanupResult.deleted > 0) {
|
|
1440
|
+
console.log(
|
|
1441
|
+
`[image-cache] Cleaned up ${cleanupResult.deleted} files, freed ${(cleanupResult.freed / 1024 / 1024).toFixed(2)} MB`
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
console.warn("[image-cache] Cleanup failed:", error);
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
buffer: optimizedBuffer,
|
|
1451
|
+
format: outputFormat,
|
|
1452
|
+
mimeType: getImageMimeType(outputFormat),
|
|
1453
|
+
width: finalMetadata.width || options.width || metadata.width || 0,
|
|
1454
|
+
height: finalMetadata.height || options.height || metadata.height || 0
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/server/handlers/image.ts
|
|
1459
|
+
async function handleImageRequest(options) {
|
|
1460
|
+
const { req, res, config: config2 } = options;
|
|
1461
|
+
try {
|
|
1462
|
+
const src = req.query.src;
|
|
1463
|
+
const width = req.query.w ? parseInt(req.query.w, 10) : void 0;
|
|
1464
|
+
const height = req.query.h ? parseInt(req.query.h, 10) : void 0;
|
|
1465
|
+
const quality = req.query.q ? parseInt(req.query.q, 10) : void 0;
|
|
1466
|
+
const format = req.query.format;
|
|
1467
|
+
const fit = req.query.fit;
|
|
1468
|
+
if (!src) {
|
|
1469
|
+
res.status(400).json({
|
|
1470
|
+
error: "Missing required parameter: src"
|
|
1471
|
+
});
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (typeof src !== "string") {
|
|
1475
|
+
res.status(400).json({
|
|
1476
|
+
error: "Parameter 'src' must be a string"
|
|
1477
|
+
});
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const etagInput = `${src}-${width || ""}-${height || ""}-${quality || ""}-${format || ""}-${fit || ""}`;
|
|
1481
|
+
const etag = `"${crypto2.createHash("md5").update(etagInput).digest("hex")}"`;
|
|
1482
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
1483
|
+
if (ifNoneMatch === etag) {
|
|
1484
|
+
res.status(304).end();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const result = await optimizeImage(
|
|
1488
|
+
{
|
|
1489
|
+
src,
|
|
1490
|
+
width,
|
|
1491
|
+
height,
|
|
1492
|
+
quality,
|
|
1493
|
+
format,
|
|
1494
|
+
fit
|
|
1495
|
+
},
|
|
1496
|
+
config2
|
|
1497
|
+
);
|
|
1498
|
+
const imageConfig = config2 || {};
|
|
1499
|
+
const cacheTTL = imageConfig.minimumCacheTTL ?? DEFAULT_IMAGE_CONFIG.minimumCacheTTL;
|
|
1500
|
+
res.setHeader("Content-Type", result.mimeType);
|
|
1501
|
+
res.setHeader("Content-Length", result.buffer.length);
|
|
1502
|
+
res.setHeader("Cache-Control", `public, max-age=${cacheTTL}, immutable`);
|
|
1503
|
+
res.setHeader("ETag", etag);
|
|
1504
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1505
|
+
res.send(result.buffer);
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (error instanceof Error) {
|
|
1508
|
+
if (error.message.includes("not allowed")) {
|
|
1509
|
+
res.status(403).json({
|
|
1510
|
+
error: "Forbidden",
|
|
1511
|
+
message: error.message
|
|
1512
|
+
});
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (error.message.includes("not found") || error.message.includes("Image not found")) {
|
|
1516
|
+
res.status(404).json({
|
|
1517
|
+
error: "Not Found",
|
|
1518
|
+
message: error.message
|
|
1519
|
+
});
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (error.message.includes("must be")) {
|
|
1523
|
+
res.status(400).json({
|
|
1524
|
+
error: "Bad Request",
|
|
1525
|
+
message: error.message
|
|
1526
|
+
});
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (error.message.includes("timeout") || error.message.includes("download")) {
|
|
1530
|
+
res.status(504).json({
|
|
1531
|
+
error: "Gateway Timeout",
|
|
1532
|
+
message: error.message
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
console.error("[image-optimizer] Error processing image:", error);
|
|
1538
|
+
res.status(500).json({
|
|
1539
|
+
error: "Internal Server Error",
|
|
1540
|
+
message: "Failed to process image"
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/router/api-matcher.ts
|
|
1546
|
+
function matchRoutePattern2(pathname, route) {
|
|
1547
|
+
const pathSegments = pathname === "/" ? [""] : pathname.split("/").filter(Boolean);
|
|
1548
|
+
const routeSegments = route.segments.filter((s) => !s.isOptional);
|
|
1549
|
+
if (routeSegments.length === 0 && pathSegments.length === 1 && pathSegments[0] === "") {
|
|
1550
|
+
return { params: {} };
|
|
1551
|
+
}
|
|
1552
|
+
const params = {};
|
|
1553
|
+
let pathIdx = 0;
|
|
1554
|
+
let routeIdx = 0;
|
|
1555
|
+
while (pathIdx < pathSegments.length && routeIdx < routeSegments.length) {
|
|
1556
|
+
const routeSeg = routeSegments[routeIdx];
|
|
1557
|
+
const pathSeg = pathSegments[pathIdx];
|
|
1558
|
+
if (routeSeg.isCatchAll) {
|
|
1559
|
+
const remaining = pathSegments.slice(pathIdx);
|
|
1560
|
+
if (routeSeg.paramName) {
|
|
1561
|
+
params[routeSeg.paramName] = remaining.join("/");
|
|
1562
|
+
}
|
|
1563
|
+
return { params };
|
|
1564
|
+
}
|
|
1565
|
+
if (routeSeg.isDynamic) {
|
|
1566
|
+
if (routeSeg.paramName) {
|
|
1567
|
+
params[routeSeg.paramName] = decodeURIComponent(pathSeg);
|
|
1568
|
+
}
|
|
1569
|
+
pathIdx++;
|
|
1570
|
+
routeIdx++;
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
if (routeSeg.segment === pathSeg) {
|
|
1574
|
+
pathIdx++;
|
|
1575
|
+
routeIdx++;
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
if (pathIdx === pathSegments.length && routeIdx === routeSegments.length) {
|
|
1581
|
+
return { params };
|
|
1582
|
+
}
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
function matchApiRoute(pathname, config2) {
|
|
1586
|
+
const normalized = normalizeUrlPath(pathname);
|
|
1587
|
+
if (config2.apiRouteMap && config2.apiRouteMap.has(normalized)) {
|
|
1588
|
+
const route = config2.apiRouteMap.get(normalized);
|
|
1589
|
+
return { route, params: {} };
|
|
1590
|
+
}
|
|
1591
|
+
if (config2.apiRouteMap) {
|
|
1592
|
+
for (const route of config2.apiRouteMap.values()) {
|
|
1593
|
+
const match = matchRoutePattern2(normalized, route);
|
|
1594
|
+
if (match) {
|
|
1595
|
+
return { route, params: match.params };
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
for (const route of config2.routes) {
|
|
1600
|
+
if (!route.isApiRoute || !route.urlPath) continue;
|
|
1601
|
+
const match = matchRoutePattern2(normalized, route);
|
|
1602
|
+
if (match) {
|
|
1603
|
+
return { route, params: match.params };
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// src/utils/api-route-loader.ts
|
|
1610
|
+
var HTTP_METHODS2 = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
|
1611
|
+
async function loadApiRoute(route, compiledPath) {
|
|
1612
|
+
if (!route.isApiRoute) {
|
|
1613
|
+
throw new Error(`[loly-core] Route ${route.filePath} is not an API route`);
|
|
1614
|
+
}
|
|
1615
|
+
const sourcePath = route.filePath;
|
|
1616
|
+
const module = await loadModule(sourcePath, compiledPath);
|
|
1617
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
1618
|
+
for (const exportName of Object.keys(module)) {
|
|
1619
|
+
const upperExport = exportName.toUpperCase();
|
|
1620
|
+
if (HTTP_METHODS2.includes(upperExport) && typeof module[exportName] === "function") {
|
|
1621
|
+
handlers.set(upperExport, module[exportName]);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
if (handlers.size === 0) {
|
|
1625
|
+
throw new Error(
|
|
1626
|
+
`[loly-core] No valid HTTP method handlers found in API route ${route.filePath}`
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
return { handlers };
|
|
1630
|
+
}
|
|
1631
|
+
function hasMethod(loadedRoute, method) {
|
|
1632
|
+
return loadedRoute.handlers.has(method.toUpperCase());
|
|
1633
|
+
}
|
|
1634
|
+
function getHandler(loadedRoute, method) {
|
|
1635
|
+
return loadedRoute.handlers.get(method.toUpperCase());
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/server/handlers/api-route.ts
|
|
1639
|
+
function createApiRouteHandler() {
|
|
1640
|
+
return async (pathname, method, req, res, config2) => {
|
|
1641
|
+
try {
|
|
1642
|
+
const match = matchApiRoute(pathname, config2);
|
|
1643
|
+
if (!match) {
|
|
1644
|
+
return false;
|
|
1645
|
+
}
|
|
1646
|
+
const { route, params } = match;
|
|
1647
|
+
const loadedRoute = await loadApiRoute(route);
|
|
1648
|
+
const upperMethod = method.toUpperCase();
|
|
1649
|
+
if (!hasMethod(loadedRoute, upperMethod)) {
|
|
1650
|
+
res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: `Method ${method} not allowed` });
|
|
1651
|
+
return true;
|
|
1652
|
+
}
|
|
1653
|
+
const handler = getHandler(loadedRoute, upperMethod);
|
|
1654
|
+
if (!handler) {
|
|
1655
|
+
res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: `Method ${method} not allowed` });
|
|
1656
|
+
return true;
|
|
1657
|
+
}
|
|
1658
|
+
req.params = params;
|
|
1659
|
+
await handler(req, res);
|
|
1660
|
+
return true;
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
console.error("[loly-core] Error handling API route:", error);
|
|
1663
|
+
if (!res.headersSent) {
|
|
1664
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).json({
|
|
1665
|
+
error: "Internal Server Error",
|
|
1666
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1667
|
+
});
|
|
1668
|
+
return true;
|
|
1669
|
+
}
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// src/server/handlers/async-handler.ts
|
|
1676
|
+
import { renderToString } from "loly-jsx";
|
|
1677
|
+
async function handleAsyncRequest(req, res) {
|
|
1678
|
+
try {
|
|
1679
|
+
const asyncId = req.params.id;
|
|
1680
|
+
if (!asyncId) {
|
|
1681
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1682
|
+
html: null,
|
|
1683
|
+
error: "Missing async ID"
|
|
1684
|
+
});
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1688
|
+
setTimeout(() => reject(new Error("Timeout")), 3e4);
|
|
1689
|
+
});
|
|
1690
|
+
try {
|
|
1691
|
+
const resolvedVNode = await Promise.race([resolveAsyncTask(asyncId), timeoutPromise]);
|
|
1692
|
+
const html = await renderToString(resolvedVNode);
|
|
1693
|
+
res.status(HTTP_STATUS.OK).json({
|
|
1694
|
+
html,
|
|
1695
|
+
error: null
|
|
1696
|
+
});
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
if (error instanceof Error && error.message === "Timeout") {
|
|
1699
|
+
res.status(HTTP_STATUS.REQUEST_TIMEOUT).json({
|
|
1700
|
+
html: null,
|
|
1701
|
+
error: "Async task timeout"
|
|
1702
|
+
});
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (error instanceof Error && error.message.includes("not found")) {
|
|
1706
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
1707
|
+
html: null,
|
|
1708
|
+
error: error.message
|
|
1709
|
+
});
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
console.error("[loly] Error resolving async task:", error);
|
|
1713
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).json({
|
|
1714
|
+
html: null,
|
|
1715
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
console.error("[loly] Error handling async request:", error);
|
|
1720
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).json({
|
|
1721
|
+
html: null,
|
|
1722
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// src/utils/express-setup.ts
|
|
1728
|
+
import compression from "compression";
|
|
1729
|
+
function setupExpressMiddleware(app) {
|
|
1730
|
+
app.use(compression({
|
|
1731
|
+
level: 6,
|
|
1732
|
+
// Compression level (1-9, 6 is good balance)
|
|
1733
|
+
threshold: 1024,
|
|
1734
|
+
// Only compress responses > 1KB
|
|
1735
|
+
filter: (req, res) => {
|
|
1736
|
+
if (req.headers["x-no-compression"]) {
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
return compression.filter(req, res);
|
|
1740
|
+
}
|
|
1741
|
+
}));
|
|
1742
|
+
app.use((req, res, next) => {
|
|
1743
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1744
|
+
req.query = Object.fromEntries(url.searchParams);
|
|
1745
|
+
next();
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
function setupStaticFiles(app, clientDir, publicDir) {
|
|
1749
|
+
if (existsSync4(clientDir)) {
|
|
1750
|
+
app.use(express.static(clientDir));
|
|
1751
|
+
} else {
|
|
1752
|
+
console.warn(ERROR_MESSAGES.CLIENT_BUILD_DIR_NOT_FOUND(clientDir));
|
|
1753
|
+
}
|
|
1754
|
+
if (existsSync4(publicDir)) {
|
|
1755
|
+
app.use(express.static(publicDir));
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function setupRSCEndpoint(app, handler) {
|
|
1759
|
+
app.get(SERVER.RSC_ENDPOINT, async (req, res) => {
|
|
1760
|
+
try {
|
|
1761
|
+
const pathname = req.query.path || "/";
|
|
1762
|
+
const search = req.query.search || "";
|
|
1763
|
+
const searchParams = search ? Object.fromEntries(new URLSearchParams(search)) : {};
|
|
1764
|
+
const result = await handler(pathname, searchParams);
|
|
1765
|
+
res.setHeader("Content-Type", HTTP_HEADERS.CONTENT_TYPE_JSON);
|
|
1766
|
+
res.json({
|
|
1767
|
+
pathname,
|
|
1768
|
+
params: result.params,
|
|
1769
|
+
searchParams,
|
|
1770
|
+
html: result.html,
|
|
1771
|
+
scripts: result.scripts,
|
|
1772
|
+
styles: result.styles,
|
|
1773
|
+
preloads: result.preloads,
|
|
1774
|
+
islandData: result.islandData
|
|
1775
|
+
});
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
console.error(ERROR_MESSAGES.RSC_ENDPOINT_ERROR, error);
|
|
1778
|
+
if (error.message.includes("Route not found")) {
|
|
1779
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: "Route not found" });
|
|
1780
|
+
} else {
|
|
1781
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).json({
|
|
1782
|
+
error: "Internal Server Error"
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
function setupImageEndpoint(app, config2) {
|
|
1789
|
+
app.get(SERVER.IMAGE_ENDPOINT, async (req, res) => {
|
|
1790
|
+
await handleImageRequest({ req, res, config: config2 });
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
function setupAsyncEndpoint(app) {
|
|
1794
|
+
app.get(`${SERVER.ASYNC_ENDPOINT}/:id`, async (req, res) => {
|
|
1795
|
+
await handleAsyncRequest(req, res);
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
function setupApiRoutes(app, getConfig) {
|
|
1799
|
+
app.use(express.json({ limit: "10mb" }));
|
|
1800
|
+
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
|
1801
|
+
const apiHandler = createApiRouteHandler();
|
|
1802
|
+
app.use(async (req, res, next) => {
|
|
1803
|
+
const config2 = getConfig();
|
|
1804
|
+
if (!config2) {
|
|
1805
|
+
return next();
|
|
1806
|
+
}
|
|
1807
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1808
|
+
const pathname = url.pathname;
|
|
1809
|
+
const method = req.method;
|
|
1810
|
+
if (config2.apiRouteMap && config2.apiRouteMap.size > 0) {
|
|
1811
|
+
try {
|
|
1812
|
+
const handled = await apiHandler(pathname, method, req, res, config2);
|
|
1813
|
+
if (handled) {
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
if (!res.headersSent) {
|
|
1818
|
+
return next();
|
|
1819
|
+
}
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
next();
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
function setupSSREndpoint(app, handler) {
|
|
1827
|
+
app.get("*", async (req, res) => {
|
|
1828
|
+
try {
|
|
1829
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1830
|
+
const result = await handler(
|
|
1831
|
+
url.pathname,
|
|
1832
|
+
Object.fromEntries(url.searchParams)
|
|
1833
|
+
);
|
|
1834
|
+
res.status(result.status);
|
|
1835
|
+
res.setHeader("Content-Type", HTTP_HEADERS.CONTENT_TYPE_HTML);
|
|
1836
|
+
const nodeStream = Readable.fromWeb(result.html);
|
|
1837
|
+
nodeStream.pipe(res);
|
|
1838
|
+
nodeStream.on("error", (err) => {
|
|
1839
|
+
if (!res.headersSent) {
|
|
1840
|
+
console.error(ERROR_MESSAGES.ERROR_HANDLING_REQUEST, err);
|
|
1841
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).send("Internal Server Error");
|
|
1842
|
+
}
|
|
1843
|
+
});
|
|
1844
|
+
} catch (error) {
|
|
1845
|
+
console.error(ERROR_MESSAGES.ERROR_HANDLING_REQUEST, error);
|
|
1846
|
+
res.status(HTTP_STATUS.INTERNAL_ERROR).send("Internal Server Error");
|
|
1847
|
+
}
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// src/server/base-server.ts
|
|
1852
|
+
var BaseServer = class {
|
|
1853
|
+
constructor(port = SERVER.DEFAULT_PORT) {
|
|
1854
|
+
this.app = express2();
|
|
1855
|
+
this.port = port;
|
|
1856
|
+
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Template method - defines the algorithm structure
|
|
1859
|
+
*/
|
|
1860
|
+
async setup() {
|
|
1861
|
+
this.setupMiddleware();
|
|
1862
|
+
this.setupStaticFiles();
|
|
1863
|
+
this.setupImageEndpoint();
|
|
1864
|
+
this.setupAsyncEndpoint();
|
|
1865
|
+
this.setupApiRoutes();
|
|
1866
|
+
this.setupRSCEndpoint();
|
|
1867
|
+
this.setupSSREndpoint();
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Setup Express middleware
|
|
1871
|
+
*/
|
|
1872
|
+
setupMiddleware() {
|
|
1873
|
+
setupExpressMiddleware(this.app);
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Setup static file serving
|
|
1877
|
+
*/
|
|
1878
|
+
setupStaticFiles() {
|
|
1879
|
+
setupStaticFiles(this.app, getClientDir(), getPublicDir());
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Setup image optimization endpoint
|
|
1883
|
+
*/
|
|
1884
|
+
setupImageEndpoint() {
|
|
1885
|
+
setupImageEndpoint(this.app);
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Setup async component endpoint
|
|
1889
|
+
*/
|
|
1890
|
+
setupAsyncEndpoint() {
|
|
1891
|
+
setupAsyncEndpoint(this.app);
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Setup API routes
|
|
1895
|
+
*/
|
|
1896
|
+
setupApiRoutes() {
|
|
1897
|
+
setupApiRoutes(this.app, () => {
|
|
1898
|
+
try {
|
|
1899
|
+
return getRouteConfig();
|
|
1900
|
+
} catch {
|
|
1901
|
+
return null;
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Setup RSC endpoint
|
|
1907
|
+
*/
|
|
1908
|
+
setupRSCEndpoint() {
|
|
1909
|
+
setupRSCEndpoint(this.app, this.createRSCHandler());
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Setup SSR endpoint
|
|
1913
|
+
*/
|
|
1914
|
+
setupSSREndpoint() {
|
|
1915
|
+
setupSSREndpoint(this.app, this.createSSRHandler());
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Start the server
|
|
1919
|
+
*/
|
|
1920
|
+
async start() {
|
|
1921
|
+
await this.setup();
|
|
1922
|
+
this.app.listen(this.port, () => {
|
|
1923
|
+
console.log(
|
|
1924
|
+
`[loly-core] Server running at http://localhost:${this.port}`
|
|
1925
|
+
);
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Get the Express app instance
|
|
1930
|
+
*/
|
|
1931
|
+
getApp() {
|
|
1932
|
+
return this.app;
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
|
|
1936
|
+
// src/server/dev-server.ts
|
|
1937
|
+
var routeConfig = null;
|
|
1938
|
+
var routeConfigPromise = null;
|
|
1939
|
+
async function reloadRoutes(appDir) {
|
|
1940
|
+
const config2 = await scanRoutes(appDir);
|
|
1941
|
+
routeConfig = config2;
|
|
1942
|
+
return config2;
|
|
1943
|
+
}
|
|
1944
|
+
async function getRouteConfig2(appDir) {
|
|
1945
|
+
if (routeConfig) {
|
|
1946
|
+
return routeConfig;
|
|
1947
|
+
}
|
|
1948
|
+
if (routeConfigPromise) {
|
|
1949
|
+
return routeConfigPromise;
|
|
1950
|
+
}
|
|
1951
|
+
routeConfigPromise = reloadRoutes(appDir);
|
|
1952
|
+
return routeConfigPromise;
|
|
1953
|
+
}
|
|
1954
|
+
var DevServer = class extends BaseServer {
|
|
1955
|
+
constructor(options) {
|
|
1956
|
+
super(options.port);
|
|
1957
|
+
this.watcher = null;
|
|
1958
|
+
this.projectDir = options.projectDir;
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* Initialize server context and load routes
|
|
1962
|
+
*/
|
|
1963
|
+
async initialize() {
|
|
1964
|
+
const appDir = getAppDirPath(this.projectDir);
|
|
1965
|
+
const srcDir = join3(this.projectDir, DIRECTORIES.SRC);
|
|
1966
|
+
const outDir = join3(this.projectDir, DIRECTORIES.LOLY, DIRECTORIES.DIST);
|
|
1967
|
+
if (!existsSync5(appDir)) {
|
|
1968
|
+
throw new Error(ERROR_MESSAGES.APP_DIR_NOT_FOUND(this.projectDir));
|
|
1969
|
+
}
|
|
1970
|
+
setContext({
|
|
1971
|
+
projectDir: this.projectDir,
|
|
1972
|
+
appDir,
|
|
1973
|
+
srcDir,
|
|
1974
|
+
outDir,
|
|
1975
|
+
buildMode: "development",
|
|
1976
|
+
isDev: true,
|
|
1977
|
+
serverPort: this.port
|
|
1978
|
+
});
|
|
1979
|
+
const initialConfig = await reloadRoutes(getAppDir());
|
|
1980
|
+
setRouteConfig(initialConfig);
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* Create RSC handler
|
|
1984
|
+
*/
|
|
1985
|
+
createRSCHandler() {
|
|
1986
|
+
return async (pathname, searchParams) => {
|
|
1987
|
+
const config2 = await getRouteConfig2(getAppDir());
|
|
1988
|
+
const context = {
|
|
1989
|
+
pathname,
|
|
1990
|
+
searchParams,
|
|
1991
|
+
params: {}
|
|
1992
|
+
};
|
|
1993
|
+
const result = await renderPageContent(context, config2, getAppDir());
|
|
1994
|
+
return {
|
|
1995
|
+
...result,
|
|
1996
|
+
params: context.params
|
|
1997
|
+
};
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Create SSR handler
|
|
2002
|
+
*/
|
|
2003
|
+
createSSRHandler() {
|
|
2004
|
+
return async (pathname, searchParams) => {
|
|
2005
|
+
const config2 = await getRouteConfig2(getAppDir());
|
|
2006
|
+
const context = {
|
|
2007
|
+
pathname,
|
|
2008
|
+
searchParams,
|
|
2009
|
+
params: {}
|
|
2010
|
+
};
|
|
2011
|
+
return await renderPageStream(context, config2, getAppDir());
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Load route configuration
|
|
2016
|
+
*/
|
|
2017
|
+
async loadRouteConfig() {
|
|
2018
|
+
return await getRouteConfig2(getAppDir());
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Setup hot reload watcher
|
|
2022
|
+
*/
|
|
2023
|
+
setupHotReload() {
|
|
2024
|
+
this.watcher = chokidar.watch(getAppDir(), {
|
|
2025
|
+
ignored: /node_modules/,
|
|
2026
|
+
persistent: true
|
|
2027
|
+
});
|
|
2028
|
+
const reloadHandler = async () => {
|
|
2029
|
+
routeConfig = null;
|
|
2030
|
+
routeConfigPromise = null;
|
|
2031
|
+
await reloadRoutes(getAppDir());
|
|
2032
|
+
};
|
|
2033
|
+
this.watcher.on("change", reloadHandler);
|
|
2034
|
+
this.watcher.on("add", reloadHandler);
|
|
2035
|
+
this.watcher.on("unlink", reloadHandler);
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Start the development server with hot reload
|
|
2039
|
+
*/
|
|
2040
|
+
async start() {
|
|
2041
|
+
await this.initialize();
|
|
2042
|
+
await this.setup();
|
|
2043
|
+
this.setupHotReload();
|
|
2044
|
+
this.getApp().listen(this.port, () => {
|
|
2045
|
+
console.log(
|
|
2046
|
+
`[loly-core] Dev server running at http://localhost:${this.port}`
|
|
2047
|
+
);
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Stop the server and cleanup
|
|
2052
|
+
*/
|
|
2053
|
+
async stop() {
|
|
2054
|
+
if (this.watcher) {
|
|
2055
|
+
await this.watcher.close();
|
|
2056
|
+
this.watcher = null;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
};
|
|
2060
|
+
async function startDevServer(options) {
|
|
2061
|
+
const server = new DevServer(options);
|
|
2062
|
+
await server.start();
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/server/prod-server.ts
|
|
2066
|
+
import { join as join4 } from "path";
|
|
2067
|
+
import { existsSync as existsSync6, readFileSync as readFileSync2 } from "fs";
|
|
2068
|
+
function loadRouteConfigFromManifest() {
|
|
2069
|
+
const manifestPath = getRoutesManifestPath();
|
|
2070
|
+
if (!existsSync6(manifestPath)) {
|
|
2071
|
+
throw new Error(ERROR_MESSAGES.ROUTES_MANIFEST_NOT_FOUND(manifestPath));
|
|
2072
|
+
}
|
|
2073
|
+
const manifestContent = readFileSync2(manifestPath, "utf-8");
|
|
2074
|
+
const manifest = JSON.parse(manifestContent);
|
|
2075
|
+
const routes = manifest.routes.map((route) => ({
|
|
2076
|
+
filePath: route.serverPath || route.sourcePath,
|
|
2077
|
+
// Prefer compiled path
|
|
2078
|
+
segments: route.segments,
|
|
2079
|
+
type: route.type,
|
|
2080
|
+
isRootLayout: route.isRootLayout,
|
|
2081
|
+
urlPath: route.urlPath,
|
|
2082
|
+
isApiRoute: route.isApiRoute || void 0,
|
|
2083
|
+
httpMethods: route.httpMethods || void 0
|
|
2084
|
+
}));
|
|
2085
|
+
const rootLayout = manifest.rootLayout ? {
|
|
2086
|
+
filePath: manifest.rootLayout.serverPath || manifest.rootLayout.sourcePath,
|
|
2087
|
+
segments: manifest.rootLayout.segments,
|
|
2088
|
+
type: manifest.rootLayout.type,
|
|
2089
|
+
isRootLayout: manifest.rootLayout.isRootLayout,
|
|
2090
|
+
urlPath: manifest.rootLayout.urlPath
|
|
2091
|
+
} : void 0;
|
|
2092
|
+
const routeMap = /* @__PURE__ */ new Map();
|
|
2093
|
+
const apiRouteMap = /* @__PURE__ */ new Map();
|
|
2094
|
+
for (const routeEntry of manifest.routeMap || []) {
|
|
2095
|
+
const route = {
|
|
2096
|
+
filePath: routeEntry.serverPath || routeEntry.sourcePath,
|
|
2097
|
+
segments: routeEntry.segments,
|
|
2098
|
+
type: routeEntry.type,
|
|
2099
|
+
isRootLayout: routeEntry.isRootLayout,
|
|
2100
|
+
urlPath: routeEntry.urlPath,
|
|
2101
|
+
isApiRoute: routeEntry.isApiRoute || void 0,
|
|
2102
|
+
httpMethods: routeEntry.httpMethods || void 0
|
|
2103
|
+
};
|
|
2104
|
+
if (routeEntry.type === "page") {
|
|
2105
|
+
routeMap.set(routeEntry.path, route);
|
|
2106
|
+
} else if (routeEntry.isApiRoute && routeEntry.urlPath) {
|
|
2107
|
+
apiRouteMap.set(routeEntry.urlPath, route);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
const routeMapManifest = /* @__PURE__ */ new Map();
|
|
2111
|
+
for (const routeEntry of manifest.routeMap || []) {
|
|
2112
|
+
routeMapManifest.set(routeEntry.path, routeEntry);
|
|
2113
|
+
}
|
|
2114
|
+
return {
|
|
2115
|
+
routes,
|
|
2116
|
+
rootLayout,
|
|
2117
|
+
routeMap,
|
|
2118
|
+
apiRouteMap,
|
|
2119
|
+
routeMapManifest,
|
|
2120
|
+
// Include manifest data for accessing compiled paths and chunks
|
|
2121
|
+
rootLayoutManifest: manifest.rootLayout,
|
|
2122
|
+
// Include root layout manifest
|
|
2123
|
+
routesManifest: manifest.routes
|
|
2124
|
+
// Include all routes from manifest for layout lookup
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
var ProdServer = class extends BaseServer {
|
|
2128
|
+
constructor(options) {
|
|
2129
|
+
super(options.port);
|
|
2130
|
+
this.config = null;
|
|
2131
|
+
this.projectDir = options.projectDir;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* Initialize server context and load routes from manifest
|
|
2135
|
+
*/
|
|
2136
|
+
async initialize() {
|
|
2137
|
+
const appDir = getAppDirPath(this.projectDir);
|
|
2138
|
+
const srcDir = join4(this.projectDir, DIRECTORIES.SRC);
|
|
2139
|
+
const outDir = join4(this.projectDir, DIRECTORIES.LOLY, DIRECTORIES.DIST);
|
|
2140
|
+
if (!existsSync6(appDir)) {
|
|
2141
|
+
throw new Error(ERROR_MESSAGES.APP_DIR_NOT_FOUND(this.projectDir));
|
|
2142
|
+
}
|
|
2143
|
+
setContext({
|
|
2144
|
+
projectDir: this.projectDir,
|
|
2145
|
+
appDir,
|
|
2146
|
+
srcDir,
|
|
2147
|
+
outDir,
|
|
2148
|
+
buildMode: "production",
|
|
2149
|
+
isDev: false,
|
|
2150
|
+
serverPort: this.port
|
|
2151
|
+
});
|
|
2152
|
+
this.config = loadRouteConfigFromManifest();
|
|
2153
|
+
setRouteConfig(this.config);
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Create RSC handler
|
|
2157
|
+
*/
|
|
2158
|
+
createRSCHandler() {
|
|
2159
|
+
return async (pathname, searchParams) => {
|
|
2160
|
+
if (!this.config) {
|
|
2161
|
+
this.config = await this.loadRouteConfig();
|
|
2162
|
+
}
|
|
2163
|
+
const context = {
|
|
2164
|
+
pathname,
|
|
2165
|
+
searchParams,
|
|
2166
|
+
params: {}
|
|
2167
|
+
};
|
|
2168
|
+
const result = await renderPageContent(context, this.config, getAppDir());
|
|
2169
|
+
return {
|
|
2170
|
+
...result,
|
|
2171
|
+
params: context.params
|
|
2172
|
+
};
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Create SSR handler
|
|
2177
|
+
*/
|
|
2178
|
+
createSSRHandler() {
|
|
2179
|
+
return async (pathname, searchParams) => {
|
|
2180
|
+
if (!this.config) {
|
|
2181
|
+
this.config = await this.loadRouteConfig();
|
|
2182
|
+
}
|
|
2183
|
+
const context = {
|
|
2184
|
+
pathname,
|
|
2185
|
+
searchParams,
|
|
2186
|
+
params: {}
|
|
2187
|
+
};
|
|
2188
|
+
return await renderPageStream(context, this.config, getAppDir());
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Load route configuration from manifest
|
|
2193
|
+
*/
|
|
2194
|
+
async loadRouteConfig() {
|
|
2195
|
+
return loadRouteConfigFromManifest();
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Start the production server
|
|
2199
|
+
*/
|
|
2200
|
+
async start() {
|
|
2201
|
+
await this.initialize();
|
|
2202
|
+
await this.setup();
|
|
2203
|
+
this.getApp().listen(this.port, () => {
|
|
2204
|
+
console.log(
|
|
2205
|
+
`[loly-core] Production server running at http://localhost:${this.port}`
|
|
2206
|
+
);
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
async function startProdServer(options) {
|
|
2211
|
+
const server = new ProdServer(options);
|
|
2212
|
+
await server.start();
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// src/build/index.ts
|
|
2216
|
+
import rspackBuild from "@rspack/core";
|
|
2217
|
+
import { join as join9 } from "path";
|
|
2218
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2219
|
+
|
|
2220
|
+
// src/build/manifest.ts
|
|
2221
|
+
import { writeFileSync, mkdirSync, existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
|
|
2222
|
+
import { join as join5, dirname as dirname2, relative as relative2 } from "path";
|
|
2223
|
+
function getServerCompiledPath(sourcePath, appDir) {
|
|
2224
|
+
const relativePath = relative2(appDir, sourcePath);
|
|
2225
|
+
const relativeWithoutExt = relativePath.replace(/\.(tsx?|jsx?)$/, ".js");
|
|
2226
|
+
const serverDistDir = getServerOutDir();
|
|
2227
|
+
return join5(serverDistDir, relativeWithoutExt).replace(/\\/g, "/");
|
|
2228
|
+
}
|
|
2229
|
+
function getClientChunksForRoute(routeIndex, clientManifestPath) {
|
|
2230
|
+
if (!existsSync7(clientManifestPath)) {
|
|
2231
|
+
return null;
|
|
2232
|
+
}
|
|
2233
|
+
try {
|
|
2234
|
+
const manifestContent = readFileSync3(clientManifestPath, "utf-8");
|
|
2235
|
+
const manifest = JSON.parse(manifestContent);
|
|
2236
|
+
const chunkName = `route-${routeIndex}`;
|
|
2237
|
+
const chunk = manifest[chunkName];
|
|
2238
|
+
if (!chunk) {
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
const js = [];
|
|
2242
|
+
const css = [];
|
|
2243
|
+
if (chunk.file) {
|
|
2244
|
+
js.push(chunk.file);
|
|
2245
|
+
}
|
|
2246
|
+
if (chunk.css && chunk.css.length > 0) {
|
|
2247
|
+
css.push(...chunk.css);
|
|
2248
|
+
}
|
|
2249
|
+
if (chunk.imports && chunk.imports.length > 0) {
|
|
2250
|
+
for (const importName of chunk.imports) {
|
|
2251
|
+
const importChunk = manifest[importName];
|
|
2252
|
+
if (importChunk?.file) {
|
|
2253
|
+
js.push(importChunk.file);
|
|
2254
|
+
}
|
|
2255
|
+
if (importChunk?.css && importChunk.css.length > 0) {
|
|
2256
|
+
css.push(...importChunk.css);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return { js, css };
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
function generateRoutesManifest(config2, projectDir, appDir) {
|
|
2266
|
+
const manifestDir = join5(getProjectDir(), DIRECTORIES.LOLY);
|
|
2267
|
+
if (!existsSync7(manifestDir)) {
|
|
2268
|
+
mkdirSync(manifestDir, { recursive: true });
|
|
2269
|
+
}
|
|
2270
|
+
const clientManifestPath = getManifestPath();
|
|
2271
|
+
const hasClientManifest = existsSync7(clientManifestPath);
|
|
2272
|
+
const pageRoutes = config2.routes.filter((r) => r.type === "page");
|
|
2273
|
+
let routeIndex = 0;
|
|
2274
|
+
const routesData = config2.routes.map((route) => {
|
|
2275
|
+
const sourcePath = route.filePath;
|
|
2276
|
+
const serverPath = getServerCompiledPath(sourcePath, appDir);
|
|
2277
|
+
let clientChunks = null;
|
|
2278
|
+
if (route.type === "page" && hasClientManifest) {
|
|
2279
|
+
clientChunks = getClientChunksForRoute(routeIndex, clientManifestPath);
|
|
2280
|
+
routeIndex++;
|
|
2281
|
+
}
|
|
2282
|
+
return {
|
|
2283
|
+
sourcePath,
|
|
2284
|
+
// Original TSX path (for development)
|
|
2285
|
+
serverPath,
|
|
2286
|
+
// Compiled server path (for production)
|
|
2287
|
+
segments: route.segments,
|
|
2288
|
+
type: route.type,
|
|
2289
|
+
isRootLayout: route.isRootLayout,
|
|
2290
|
+
urlPath: route.urlPath,
|
|
2291
|
+
isApiRoute: route.isApiRoute || void 0,
|
|
2292
|
+
httpMethods: route.httpMethods || void 0,
|
|
2293
|
+
clientChunks: clientChunks || void 0
|
|
2294
|
+
// Client chunks for this route
|
|
2295
|
+
};
|
|
2296
|
+
});
|
|
2297
|
+
const rootLayoutData = config2.rootLayout ? {
|
|
2298
|
+
sourcePath: config2.rootLayout.filePath,
|
|
2299
|
+
serverPath: getServerCompiledPath(
|
|
2300
|
+
config2.rootLayout.filePath,
|
|
2301
|
+
appDir
|
|
2302
|
+
),
|
|
2303
|
+
segments: config2.rootLayout.segments,
|
|
2304
|
+
type: config2.rootLayout.type,
|
|
2305
|
+
isRootLayout: config2.rootLayout.isRootLayout,
|
|
2306
|
+
urlPath: config2.rootLayout.urlPath
|
|
2307
|
+
} : void 0;
|
|
2308
|
+
routeIndex = 0;
|
|
2309
|
+
const routeMapData = Array.from(config2.routeMap.entries()).map(
|
|
2310
|
+
([path4, route]) => {
|
|
2311
|
+
const sourcePath = route.filePath;
|
|
2312
|
+
const serverPath = getServerCompiledPath(sourcePath, appDir);
|
|
2313
|
+
let clientChunks = null;
|
|
2314
|
+
if (hasClientManifest) {
|
|
2315
|
+
clientChunks = getClientChunksForRoute(routeIndex, clientManifestPath);
|
|
2316
|
+
routeIndex++;
|
|
2317
|
+
}
|
|
2318
|
+
return {
|
|
2319
|
+
path: path4,
|
|
2320
|
+
sourcePath,
|
|
2321
|
+
serverPath,
|
|
2322
|
+
segments: route.segments,
|
|
2323
|
+
type: route.type,
|
|
2324
|
+
isRootLayout: route.isRootLayout,
|
|
2325
|
+
urlPath: route.urlPath,
|
|
2326
|
+
isApiRoute: route.isApiRoute || void 0,
|
|
2327
|
+
httpMethods: route.httpMethods || void 0,
|
|
2328
|
+
clientChunks: clientChunks || void 0
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
);
|
|
2332
|
+
if (config2.apiRouteMap) {
|
|
2333
|
+
for (const [path4, route] of config2.apiRouteMap.entries()) {
|
|
2334
|
+
const sourcePath = route.filePath;
|
|
2335
|
+
const serverPath = getServerCompiledPath(sourcePath, appDir);
|
|
2336
|
+
routeMapData.push({
|
|
2337
|
+
path: path4,
|
|
2338
|
+
sourcePath,
|
|
2339
|
+
serverPath,
|
|
2340
|
+
segments: route.segments,
|
|
2341
|
+
type: route.type,
|
|
2342
|
+
isRootLayout: route.isRootLayout,
|
|
2343
|
+
urlPath: route.urlPath,
|
|
2344
|
+
isApiRoute: route.isApiRoute || void 0,
|
|
2345
|
+
httpMethods: route.httpMethods || void 0,
|
|
2346
|
+
clientChunks: void 0
|
|
2347
|
+
// API routes don't have client chunks
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
const manifest = {
|
|
2352
|
+
routes: routesData,
|
|
2353
|
+
rootLayout: rootLayoutData,
|
|
2354
|
+
routeMap: routeMapData
|
|
2355
|
+
};
|
|
2356
|
+
const manifestPath = getRoutesManifestPath();
|
|
2357
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
2358
|
+
}
|
|
2359
|
+
function generateBootstrap(projectDir, routes) {
|
|
2360
|
+
const bootstrapDir = join5(getProjectDir(), DIRECTORIES.LOLY);
|
|
2361
|
+
if (!existsSync7(bootstrapDir)) {
|
|
2362
|
+
mkdirSync(bootstrapDir, { recursive: true });
|
|
2363
|
+
}
|
|
2364
|
+
const routeImports = routes.map((route, index) => {
|
|
2365
|
+
const routePath = route.filePath.replace(/\\/g, "/");
|
|
2366
|
+
const projectPath = getProjectDir().replace(/\\/g, "/");
|
|
2367
|
+
let relativePath = routePath.replace(projectPath, "").replace(/^\//, "");
|
|
2368
|
+
relativePath = `../${relativePath}`;
|
|
2369
|
+
return ` {
|
|
2370
|
+
path: "${route.urlPath}",
|
|
2371
|
+
component: async () => {
|
|
2372
|
+
const mod = await import(/* webpackChunkName: "route-${index}" */ "${relativePath}");
|
|
2373
|
+
return mod.default || mod;
|
|
2374
|
+
},
|
|
2375
|
+
}`;
|
|
2376
|
+
}).join(",\n");
|
|
2377
|
+
const bootstrapContent = `import { bootstrapClient } from "loly/client";
|
|
2378
|
+
|
|
2379
|
+
const routes = [
|
|
2380
|
+
${routeImports}
|
|
2381
|
+
];
|
|
2382
|
+
|
|
2383
|
+
const notFoundRoute = null;
|
|
2384
|
+
const errorRoute = null;
|
|
2385
|
+
|
|
2386
|
+
try {
|
|
2387
|
+
bootstrapClient({
|
|
2388
|
+
routes,
|
|
2389
|
+
notFoundRoute,
|
|
2390
|
+
errorRoute,
|
|
2391
|
+
});
|
|
2392
|
+
} catch (error) {
|
|
2393
|
+
console.error("[bootstrap] Fatal error during bootstrap:", error);
|
|
2394
|
+
throw error;
|
|
2395
|
+
}
|
|
2396
|
+
`;
|
|
2397
|
+
const bootstrapPath = getBootstrapPath();
|
|
2398
|
+
writeFileSync(bootstrapPath, bootstrapContent);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// src/build/client.ts
|
|
2402
|
+
import { join as join6, normalize as normalize2, relative as relative3 } from "path";
|
|
2403
|
+
|
|
2404
|
+
// src/build/server-only.ts
|
|
2405
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
2406
|
+
import { glob as glob2 } from "glob";
|
|
2407
|
+
import { normalize } from "path";
|
|
2408
|
+
function hasUseServerDirective(filePath) {
|
|
2409
|
+
try {
|
|
2410
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
2411
|
+
const trimmed = content.trim();
|
|
2412
|
+
return trimmed.startsWith('"use server"') || trimmed.startsWith("'use server'") || trimmed.startsWith('"use server";') || trimmed.startsWith("'use server';");
|
|
2413
|
+
} catch {
|
|
2414
|
+
return false;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
async function scanServerOnlyFiles(srcDir) {
|
|
2418
|
+
const serverOnlyFiles = /* @__PURE__ */ new Set();
|
|
2419
|
+
if (!srcDir) {
|
|
2420
|
+
return serverOnlyFiles;
|
|
2421
|
+
}
|
|
2422
|
+
try {
|
|
2423
|
+
const patterns = ["**/*.{ts,tsx,js,jsx}"];
|
|
2424
|
+
const files = await glob2(patterns, {
|
|
2425
|
+
cwd: srcDir,
|
|
2426
|
+
absolute: true,
|
|
2427
|
+
ignore: ["**/node_modules/**", "**/.loly/**", "**/dist/**", "**/.next/**"]
|
|
2428
|
+
});
|
|
2429
|
+
for (const file of files) {
|
|
2430
|
+
if (hasUseServerDirective(file)) {
|
|
2431
|
+
const normalizedPath = normalize(file);
|
|
2432
|
+
serverOnlyFiles.add(normalizedPath);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
} catch (error) {
|
|
2436
|
+
console.warn("[loly-core] Failed to scan for server-only files:", error);
|
|
2437
|
+
}
|
|
2438
|
+
return serverOnlyFiles;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
// src/build/client.ts
|
|
2442
|
+
function generateServerOnlyStubCode() {
|
|
2443
|
+
return `
|
|
2444
|
+
const SERVER_ONLY_ERROR = "This module is marked with 'use server' and can only be imported in server components.";
|
|
2445
|
+
|
|
2446
|
+
function createErrorFunction(propName) {
|
|
2447
|
+
const errorFn = function (...args) {
|
|
2448
|
+
throw new Error(SERVER_ONLY_ERROR + " Attempted to call: " + propName);
|
|
2449
|
+
};
|
|
2450
|
+
errorFn.toString = () => "[ServerOnlyStub:" + propName + "]";
|
|
2451
|
+
errorFn[Symbol.toStringTag] = "Function";
|
|
2452
|
+
errorFn.then = () => Promise.reject(new Error(SERVER_ONLY_ERROR + " Attempted to await: " + propName));
|
|
2453
|
+
errorFn.catch = () => Promise.reject(new Error(SERVER_ONLY_ERROR + " Attempted to catch: " + propName));
|
|
2454
|
+
return errorFn;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function createServerOnlyProxy() {
|
|
2458
|
+
return new Proxy({}, {
|
|
2459
|
+
get(_target, prop) {
|
|
2460
|
+
return createErrorFunction(String(prop));
|
|
2461
|
+
},
|
|
2462
|
+
set() {
|
|
2463
|
+
throw new Error(SERVER_ONLY_ERROR);
|
|
2464
|
+
},
|
|
2465
|
+
has() {
|
|
2466
|
+
return true;
|
|
2467
|
+
},
|
|
2468
|
+
ownKeys() {
|
|
2469
|
+
return [];
|
|
2470
|
+
},
|
|
2471
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
2472
|
+
return {
|
|
2473
|
+
enumerable: true,
|
|
2474
|
+
configurable: true,
|
|
2475
|
+
get: () => createErrorFunction(String(prop)),
|
|
2476
|
+
};
|
|
2477
|
+
},
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
const serverOnlyStub = createServerOnlyProxy();
|
|
2482
|
+
export default serverOnlyStub;
|
|
2483
|
+
`;
|
|
2484
|
+
}
|
|
2485
|
+
function matchServerOnlyFile(request, serverOnlyFiles, srcDir) {
|
|
2486
|
+
const normalizedSrcDir = normalize2(srcDir).replace(/\\/g, "/");
|
|
2487
|
+
const normalizedRequest = request.replace(/\\/g, "/");
|
|
2488
|
+
for (const serverOnlyPath of serverOnlyFiles) {
|
|
2489
|
+
const normalizedPath = normalize2(serverOnlyPath).replace(/\\/g, "/");
|
|
2490
|
+
if (normalizedRequest === normalizedPath) {
|
|
2491
|
+
return serverOnlyPath;
|
|
2492
|
+
}
|
|
2493
|
+
const relativeFromSrc = normalizedPath.replace(normalizedSrcDir + "/", "").replace(/^\/+/, "");
|
|
2494
|
+
const relativeWithoutExt = relativeFromSrc.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
2495
|
+
if (normalizedRequest === relativeFromSrc || normalizedRequest === relativeWithoutExt) {
|
|
2496
|
+
return serverOnlyPath;
|
|
2497
|
+
}
|
|
2498
|
+
if (normalizedRequest.endsWith("/" + relativeFromSrc) || normalizedRequest.endsWith("/" + relativeWithoutExt)) {
|
|
2499
|
+
return serverOnlyPath;
|
|
2500
|
+
}
|
|
2501
|
+
try {
|
|
2502
|
+
const relativePath = relative3(srcDir, serverOnlyPath).replace(/\\/g, "/");
|
|
2503
|
+
const relativePathWithoutExt = relativePath.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
2504
|
+
if (normalizedRequest === relativePath || normalizedRequest === relativePathWithoutExt) {
|
|
2505
|
+
return serverOnlyPath;
|
|
2506
|
+
}
|
|
2507
|
+
if (normalizedRequest.endsWith("/" + relativePath) || normalizedRequest.endsWith("/" + relativePathWithoutExt)) {
|
|
2508
|
+
return serverOnlyPath;
|
|
2509
|
+
}
|
|
2510
|
+
const requestParts = normalizedRequest.split("/").filter((p) => p && p !== ".");
|
|
2511
|
+
const relativeParts = relativePathWithoutExt.split("/").filter((p) => p && p !== ".");
|
|
2512
|
+
if (relativeParts.length > 0 && requestParts.length >= relativeParts.length) {
|
|
2513
|
+
const requestEnd = requestParts.slice(-relativeParts.length).join("/");
|
|
2514
|
+
const relativeEnd = relativeParts.join("/");
|
|
2515
|
+
if (requestEnd === relativeEnd) {
|
|
2516
|
+
return serverOnlyPath;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
} catch {
|
|
2520
|
+
}
|
|
2521
|
+
const filename = normalizedPath.split("/").pop();
|
|
2522
|
+
if (filename) {
|
|
2523
|
+
const filenameWithoutExt = filename.replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
2524
|
+
if (normalizedRequest === filename || normalizedRequest === filenameWithoutExt) {
|
|
2525
|
+
return serverOnlyPath;
|
|
2526
|
+
}
|
|
2527
|
+
if (normalizedRequest.endsWith("/" + filename) || normalizedRequest.endsWith("/" + filenameWithoutExt)) {
|
|
2528
|
+
return serverOnlyPath;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
try {
|
|
2532
|
+
if (!normalizedRequest.startsWith("/") && !normalizedRequest.startsWith(".")) {
|
|
2533
|
+
if (normalizedRequest === relativeWithoutExt || normalizedRequest === relativeFromSrc) {
|
|
2534
|
+
return serverOnlyPath;
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
} catch {
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
return null;
|
|
2541
|
+
}
|
|
2542
|
+
async function createClientConfig(options) {
|
|
2543
|
+
const { mode = "production" } = options;
|
|
2544
|
+
const bootstrapEntry = getBootstrapPath();
|
|
2545
|
+
const srcDir = getSrcDir();
|
|
2546
|
+
const serverOnlyFiles = await scanServerOnlyFiles(srcDir);
|
|
2547
|
+
const stubCode = generateServerOnlyStubCode();
|
|
2548
|
+
const entries = {
|
|
2549
|
+
main: bootstrapEntry
|
|
2550
|
+
};
|
|
2551
|
+
return {
|
|
2552
|
+
mode,
|
|
2553
|
+
entry: entries,
|
|
2554
|
+
output: {
|
|
2555
|
+
path: getClientDir(),
|
|
2556
|
+
filename: "[name].[contenthash].js",
|
|
2557
|
+
chunkFilename: "[name].[contenthash].js",
|
|
2558
|
+
clean: true,
|
|
2559
|
+
publicPath: "/"
|
|
2560
|
+
},
|
|
2561
|
+
resolve: {
|
|
2562
|
+
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
|
2563
|
+
alias: {
|
|
2564
|
+
"loly": join6(
|
|
2565
|
+
getProjectDir(),
|
|
2566
|
+
"node_modules",
|
|
2567
|
+
"loly",
|
|
2568
|
+
"dist",
|
|
2569
|
+
"client.js"
|
|
2570
|
+
)
|
|
2571
|
+
}
|
|
2572
|
+
},
|
|
2573
|
+
externals: [
|
|
2574
|
+
"express",
|
|
2575
|
+
"chokidar",
|
|
2576
|
+
"glob",
|
|
2577
|
+
"@rspack/core",
|
|
2578
|
+
"@rspack/cli",
|
|
2579
|
+
"tsx",
|
|
2580
|
+
({ request, contextInfo }) => {
|
|
2581
|
+
if (!request) return false;
|
|
2582
|
+
if (request.startsWith("node:")) return request;
|
|
2583
|
+
if (NODE_BUILTINS.includes(request)) return `node:${request}`;
|
|
2584
|
+
return false;
|
|
2585
|
+
}
|
|
2586
|
+
],
|
|
2587
|
+
externalsType: "module",
|
|
2588
|
+
module: {
|
|
2589
|
+
rules: [
|
|
2590
|
+
{
|
|
2591
|
+
test: /\.tsx?$/,
|
|
2592
|
+
use: [
|
|
2593
|
+
{
|
|
2594
|
+
loader: "builtin:swc-loader",
|
|
2595
|
+
options: {
|
|
2596
|
+
jsc: {
|
|
2597
|
+
parser: {
|
|
2598
|
+
syntax: "typescript",
|
|
2599
|
+
tsx: true,
|
|
2600
|
+
decorators: false,
|
|
2601
|
+
dynamicImport: true
|
|
2602
|
+
},
|
|
2603
|
+
transform: {
|
|
2604
|
+
react: {
|
|
2605
|
+
runtime: "automatic",
|
|
2606
|
+
importSource: "loly-jsx",
|
|
2607
|
+
throwIfNamespace: false
|
|
2608
|
+
}
|
|
2609
|
+
},
|
|
2610
|
+
target: "es2020"
|
|
2611
|
+
},
|
|
2612
|
+
module: {
|
|
2613
|
+
type: "es6"
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
]
|
|
2618
|
+
},
|
|
2619
|
+
{
|
|
2620
|
+
test: /\.css$/,
|
|
2621
|
+
type: "css"
|
|
2622
|
+
}
|
|
2623
|
+
]
|
|
2624
|
+
},
|
|
2625
|
+
optimization: {
|
|
2626
|
+
minimize: mode === "production",
|
|
2627
|
+
splitChunks: {
|
|
2628
|
+
chunks: "all"
|
|
2629
|
+
},
|
|
2630
|
+
usedExports: true,
|
|
2631
|
+
sideEffects: false,
|
|
2632
|
+
// More aggressive tree shaking - assume no side effects
|
|
2633
|
+
providedExports: true,
|
|
2634
|
+
concatenateModules: true,
|
|
2635
|
+
// Better for tree shaking
|
|
2636
|
+
removeEmptyChunks: true,
|
|
2637
|
+
mergeDuplicateChunks: true
|
|
2638
|
+
},
|
|
2639
|
+
plugins: [
|
|
2640
|
+
{
|
|
2641
|
+
name: "server-only-plugin",
|
|
2642
|
+
apply(compiler) {
|
|
2643
|
+
compiler.hooks.normalModuleFactory.tap("server-only-plugin", (factory) => {
|
|
2644
|
+
factory.hooks.beforeResolve.tap("server-only-plugin", (data) => {
|
|
2645
|
+
if (!data.request) return;
|
|
2646
|
+
const request = data.request;
|
|
2647
|
+
if (request.startsWith("data:")) {
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
const matchedFile = matchServerOnlyFile(request, serverOnlyFiles, srcDir);
|
|
2651
|
+
if (matchedFile) {
|
|
2652
|
+
const encodedStub = Buffer.from(stubCode.trim()).toString("base64");
|
|
2653
|
+
data.request = `data:text/javascript;base64,${encodedStub}`;
|
|
2654
|
+
}
|
|
2655
|
+
});
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
},
|
|
2659
|
+
{
|
|
2660
|
+
name: "manifest-plugin",
|
|
2661
|
+
apply(compiler) {
|
|
2662
|
+
compiler.hooks.emit.tap("manifest-plugin", (compilation) => {
|
|
2663
|
+
const manifest = {};
|
|
2664
|
+
const entrypoints = compilation.entrypoints || /* @__PURE__ */ new Map();
|
|
2665
|
+
const entryChunkNames = /* @__PURE__ */ new Set();
|
|
2666
|
+
for (const [name, entrypoint] of entrypoints) {
|
|
2667
|
+
entryChunkNames.add(name);
|
|
2668
|
+
const chunks2 = entrypoint.chunks || [];
|
|
2669
|
+
for (const chunk of chunks2) {
|
|
2670
|
+
const chunkName = chunk.name || chunk.id || "unknown";
|
|
2671
|
+
entryChunkNames.add(chunkName);
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
const chunks = compilation.chunks || [];
|
|
2675
|
+
for (const chunk of chunks) {
|
|
2676
|
+
const name = chunk.name || chunk.id || "unknown";
|
|
2677
|
+
const files = [];
|
|
2678
|
+
const cssFiles = [];
|
|
2679
|
+
const chunkFiles = chunk.files || [];
|
|
2680
|
+
for (const file of chunkFiles) {
|
|
2681
|
+
if (file.endsWith(".css")) {
|
|
2682
|
+
cssFiles.push(file);
|
|
2683
|
+
} else {
|
|
2684
|
+
files.push(file);
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
const isEntry = entryChunkNames.has(name) || chunk.hasEntryModule && chunk.hasEntryModule() || name === "main";
|
|
2688
|
+
manifest[name] = {
|
|
2689
|
+
file: files[0] || "",
|
|
2690
|
+
isEntry,
|
|
2691
|
+
imports: [],
|
|
2692
|
+
css: cssFiles.length > 0 ? cssFiles : void 0
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
const manifestJson = JSON.stringify(manifest, null, 2);
|
|
2696
|
+
compilation.emitAsset("manifest.json", {
|
|
2697
|
+
source: () => manifestJson,
|
|
2698
|
+
size: () => Buffer.byteLength(manifestJson, "utf8")
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
],
|
|
2704
|
+
devtool: mode === "production" ? false : "source-map"
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// src/build/server.ts
|
|
2709
|
+
import { join as join7 } from "path";
|
|
2710
|
+
import { existsSync as existsSync8, readdirSync } from "fs";
|
|
2711
|
+
function collectAppSources(appDir) {
|
|
2712
|
+
const entries = [];
|
|
2713
|
+
function walk(dir) {
|
|
2714
|
+
if (!existsSync8(dir)) return;
|
|
2715
|
+
const items = readdirSync(dir, { withFileTypes: true });
|
|
2716
|
+
for (const item of items) {
|
|
2717
|
+
const full = join7(dir, item.name);
|
|
2718
|
+
if (item.isDirectory()) {
|
|
2719
|
+
if (item.name === "node_modules" || item.name.startsWith(".")) {
|
|
2720
|
+
continue;
|
|
2721
|
+
}
|
|
2722
|
+
walk(full);
|
|
2723
|
+
continue;
|
|
2724
|
+
}
|
|
2725
|
+
if (item.isFile()) {
|
|
2726
|
+
const isRouteFile = ROUTE_FILE_NAMES.some(
|
|
2727
|
+
(routeFile) => item.name === routeFile
|
|
2728
|
+
);
|
|
2729
|
+
if (isRouteFile && (full.endsWith(".ts") || full.endsWith(".tsx"))) {
|
|
2730
|
+
if (full.endsWith(".d.ts")) continue;
|
|
2731
|
+
entries.push(full);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
walk(appDir);
|
|
2737
|
+
return entries;
|
|
2738
|
+
}
|
|
2739
|
+
async function buildServerFiles(options) {
|
|
2740
|
+
const { mode = "production" } = options;
|
|
2741
|
+
const appDir = getAppDir();
|
|
2742
|
+
const serverOutDir = getServerOutDir();
|
|
2743
|
+
if (existsSync8(serverOutDir)) {
|
|
2744
|
+
const { rmSync } = await import("fs");
|
|
2745
|
+
rmSync(serverOutDir, { recursive: true, force: true });
|
|
2746
|
+
}
|
|
2747
|
+
const entryPoints = collectAppSources(appDir);
|
|
2748
|
+
if (entryPoints.length === 0) {
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
try {
|
|
2752
|
+
const { build } = await import("esbuild");
|
|
2753
|
+
await build({
|
|
2754
|
+
entryPoints,
|
|
2755
|
+
outdir: serverOutDir,
|
|
2756
|
+
outbase: appDir,
|
|
2757
|
+
// Preserve directory structure
|
|
2758
|
+
format: "esm",
|
|
2759
|
+
platform: "node",
|
|
2760
|
+
target: "es2020",
|
|
2761
|
+
bundle: true,
|
|
2762
|
+
// Bundle to resolve relative imports
|
|
2763
|
+
packages: "external",
|
|
2764
|
+
// Externalize node_modules packages
|
|
2765
|
+
external: [
|
|
2766
|
+
// Explicitly externalize loly-jsx to avoid bundling it
|
|
2767
|
+
"loly-jsx",
|
|
2768
|
+
"loly-jsx/*",
|
|
2769
|
+
"loly-jsx/jsx-runtime",
|
|
2770
|
+
"loly-jsx/render/*",
|
|
2771
|
+
"loly-jsx/router"
|
|
2772
|
+
],
|
|
2773
|
+
sourcemap: true,
|
|
2774
|
+
jsx: "automatic",
|
|
2775
|
+
jsxImportSource: "loly-jsx",
|
|
2776
|
+
logLevel: mode === "production" ? "warning" : "info"
|
|
2777
|
+
});
|
|
2778
|
+
} catch (error) {
|
|
2779
|
+
console.error(`[loly-core] Failed to build server files:`, error);
|
|
2780
|
+
throw error;
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// src/build/css-processor.ts
|
|
2785
|
+
import { existsSync as existsSync9, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, copyFileSync } from "fs";
|
|
2786
|
+
import { join as join8, dirname as dirname3 } from "path";
|
|
2787
|
+
import { pathToFileURL } from "url";
|
|
2788
|
+
import { createRequire } from "module";
|
|
2789
|
+
async function processCssWithPostCSS(options) {
|
|
2790
|
+
const { inputPath, outputPath, projectDir } = options;
|
|
2791
|
+
let postcss;
|
|
2792
|
+
try {
|
|
2793
|
+
const postcssModule = await import("postcss");
|
|
2794
|
+
postcss = postcssModule.default || postcssModule;
|
|
2795
|
+
} catch {
|
|
2796
|
+
mkdirSync2(dirname3(outputPath), { recursive: true });
|
|
2797
|
+
copyFileSync(inputPath, outputPath);
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
const configPaths = [
|
|
2801
|
+
join8(projectDir, "postcss.config.js"),
|
|
2802
|
+
join8(projectDir, "postcss.config.mjs"),
|
|
2803
|
+
join8(projectDir, "postcss.config.cjs")
|
|
2804
|
+
];
|
|
2805
|
+
const configPath = configPaths.find((p) => existsSync9(p));
|
|
2806
|
+
if (!configPath) {
|
|
2807
|
+
mkdirSync2(dirname3(outputPath), { recursive: true });
|
|
2808
|
+
copyFileSync(inputPath, outputPath);
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
2812
|
+
const configModule = await import(configUrl);
|
|
2813
|
+
const config2 = configModule.default || configModule;
|
|
2814
|
+
const css = readFileSync5(inputPath, "utf-8");
|
|
2815
|
+
let plugins = config2.plugins || [];
|
|
2816
|
+
const projectRequire = createRequire(join8(projectDir, "package.json"));
|
|
2817
|
+
if (plugins && typeof plugins === "object" && !Array.isArray(plugins)) {
|
|
2818
|
+
const pluginArray = [];
|
|
2819
|
+
for (const [name, options2] of Object.entries(plugins)) {
|
|
2820
|
+
try {
|
|
2821
|
+
const pluginPath = projectRequire.resolve(name);
|
|
2822
|
+
const pluginUrl = pathToFileURL(pluginPath).href;
|
|
2823
|
+
const pluginModule = await import(pluginUrl);
|
|
2824
|
+
const plugin = pluginModule.default || pluginModule;
|
|
2825
|
+
const pluginOptions = options2 && typeof options2 === "object" && Object.keys(options2).length > 0 ? options2 : void 0;
|
|
2826
|
+
pluginArray.push(pluginOptions ? plugin(pluginOptions) : plugin());
|
|
2827
|
+
} catch (err) {
|
|
2828
|
+
console.warn(`[loly-core] Failed to load PostCSS plugin "${name}":`, err);
|
|
2829
|
+
throw err;
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
plugins = pluginArray;
|
|
2833
|
+
}
|
|
2834
|
+
const result = await postcss(plugins).process(css, {
|
|
2835
|
+
from: inputPath,
|
|
2836
|
+
to: outputPath
|
|
2837
|
+
});
|
|
2838
|
+
mkdirSync2(dirname3(outputPath), { recursive: true });
|
|
2839
|
+
writeFileSync2(outputPath, result.css);
|
|
2840
|
+
if (result.map) {
|
|
2841
|
+
writeFileSync2(outputPath + ".map", result.map.toString());
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// src/build/index.ts
|
|
2846
|
+
async function buildProject(options) {
|
|
2847
|
+
const {
|
|
2848
|
+
projectDir,
|
|
2849
|
+
outDir = join9(projectDir, DIRECTORIES.LOLY, DIRECTORIES.DIST)
|
|
2850
|
+
} = options;
|
|
2851
|
+
const appDir = getAppDirPath(projectDir);
|
|
2852
|
+
const srcDir = join9(projectDir, DIRECTORIES.SRC);
|
|
2853
|
+
if (!existsSync10(appDir)) {
|
|
2854
|
+
throw new Error(ERROR_MESSAGES.APP_DIR_NOT_FOUND(projectDir));
|
|
2855
|
+
}
|
|
2856
|
+
setContext({
|
|
2857
|
+
projectDir,
|
|
2858
|
+
appDir,
|
|
2859
|
+
srcDir,
|
|
2860
|
+
outDir,
|
|
2861
|
+
buildMode: options.mode || "production",
|
|
2862
|
+
isDev: options.mode === "development"
|
|
2863
|
+
});
|
|
2864
|
+
const routeConfig2 = await scanRoutes(getAppDir());
|
|
2865
|
+
setRouteConfig(routeConfig2);
|
|
2866
|
+
const pageRoutes = routeConfig2.routes.filter((r) => r.type === "page");
|
|
2867
|
+
generateBootstrap(getProjectDir(), pageRoutes);
|
|
2868
|
+
const clientConfig = await createClientConfig(options);
|
|
2869
|
+
try {
|
|
2870
|
+
await new Promise((resolve6, reject) => {
|
|
2871
|
+
const compiler = rspackBuild(clientConfig);
|
|
2872
|
+
compiler.run((err, stats) => {
|
|
2873
|
+
if (err) {
|
|
2874
|
+
console.error("[loly-core] Client build error:", err);
|
|
2875
|
+
reject(err);
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
if (stats && stats.hasErrors()) {
|
|
2879
|
+
console.error("[loly-core] Client build has errors");
|
|
2880
|
+
console.error(stats.compilation.errors);
|
|
2881
|
+
reject(new Error(ERROR_MESSAGES.CLIENT_BUILD_FAILED));
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
resolve6();
|
|
2885
|
+
});
|
|
2886
|
+
});
|
|
2887
|
+
} catch (error) {
|
|
2888
|
+
console.error(ERROR_MESSAGES.CLIENT_BUILD_FAILED, error);
|
|
2889
|
+
throw error;
|
|
2890
|
+
}
|
|
2891
|
+
const globalsCssPath = existsSync10(join9(getSrcDir(), FILE_NAMES.GLOBALS_CSS)) ? join9(getSrcDir(), FILE_NAMES.GLOBALS_CSS) : existsSync10(join9(getAppDir(), FILE_NAMES.GLOBALS_CSS)) ? join9(getAppDir(), FILE_NAMES.GLOBALS_CSS) : null;
|
|
2892
|
+
const clientOutDir = getClientDir();
|
|
2893
|
+
if (globalsCssPath && existsSync10(globalsCssPath)) {
|
|
2894
|
+
const destCssPath = join9(clientOutDir, FILE_NAMES.GLOBALS_CSS);
|
|
2895
|
+
await processCssWithPostCSS({
|
|
2896
|
+
inputPath: globalsCssPath,
|
|
2897
|
+
outputPath: destCssPath,
|
|
2898
|
+
projectDir: getProjectDir()
|
|
2899
|
+
});
|
|
2900
|
+
}
|
|
2901
|
+
try {
|
|
2902
|
+
await buildServerFiles(options);
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
console.error(ERROR_MESSAGES.SERVER_BUILD_FAILED, error);
|
|
2905
|
+
throw error;
|
|
2906
|
+
}
|
|
2907
|
+
generateRoutesManifest(routeConfig2, getProjectDir(), getAppDir());
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// src/cli/index.ts
|
|
2911
|
+
import { resolve as resolve5 } from "path";
|
|
2912
|
+
import { existsSync as existsSync11 } from "fs";
|
|
2913
|
+
function loadEnvFile(projectDir) {
|
|
2914
|
+
const envPath = resolve5(projectDir, ".env");
|
|
2915
|
+
if (existsSync11(envPath)) {
|
|
2916
|
+
config({ path: envPath });
|
|
2917
|
+
}
|
|
2918
|
+
const envLocalPath = resolve5(projectDir, ".env.local");
|
|
2919
|
+
if (existsSync11(envLocalPath)) {
|
|
2920
|
+
config({ path: envLocalPath, override: true });
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
var commands = {
|
|
2924
|
+
dev: async (args) => {
|
|
2925
|
+
const projectDir = args[0] ? resolve5(args[0]) : process.cwd();
|
|
2926
|
+
if (!existsSync11(projectDir)) {
|
|
2927
|
+
console.error(`[loly-core] Directory not found: ${projectDir}`);
|
|
2928
|
+
process.exit(1);
|
|
2929
|
+
}
|
|
2930
|
+
loadEnvFile(projectDir);
|
|
2931
|
+
const port = args[1] ? parseInt(args[1], 10) : void 0;
|
|
2932
|
+
try {
|
|
2933
|
+
await startDevServer({
|
|
2934
|
+
projectDir,
|
|
2935
|
+
port
|
|
2936
|
+
});
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
console.error("[loly-core] Error starting dev server:", error);
|
|
2939
|
+
process.exit(1);
|
|
2940
|
+
}
|
|
2941
|
+
},
|
|
2942
|
+
build: async (args) => {
|
|
2943
|
+
const projectDir = args[0] ? resolve5(args[0]) : process.cwd();
|
|
2944
|
+
if (!existsSync11(projectDir)) {
|
|
2945
|
+
console.error(`[loly-core] Directory not found: ${projectDir}`);
|
|
2946
|
+
process.exit(1);
|
|
2947
|
+
}
|
|
2948
|
+
loadEnvFile(projectDir);
|
|
2949
|
+
const mode = args.includes("--dev") ? "development" : "production";
|
|
2950
|
+
const outDir = args.find((arg) => arg.startsWith("--out-dir="))?.split("=")[1];
|
|
2951
|
+
try {
|
|
2952
|
+
await buildProject({
|
|
2953
|
+
projectDir,
|
|
2954
|
+
outDir: outDir ? resolve5(outDir) : void 0,
|
|
2955
|
+
mode
|
|
2956
|
+
});
|
|
2957
|
+
} catch (error) {
|
|
2958
|
+
console.error("[loly-core] Error building project:", error);
|
|
2959
|
+
process.exit(1);
|
|
2960
|
+
}
|
|
2961
|
+
},
|
|
2962
|
+
start: async (args) => {
|
|
2963
|
+
const projectDir = args[0] ? resolve5(args[0]) : process.cwd();
|
|
2964
|
+
if (!existsSync11(projectDir)) {
|
|
2965
|
+
console.error(`[loly-core] Directory not found: ${projectDir}`);
|
|
2966
|
+
process.exit(1);
|
|
2967
|
+
}
|
|
2968
|
+
loadEnvFile(projectDir);
|
|
2969
|
+
const port = args[1] ? parseInt(args[1], 10) : void 0;
|
|
2970
|
+
try {
|
|
2971
|
+
await startProdServer({
|
|
2972
|
+
projectDir,
|
|
2973
|
+
port
|
|
2974
|
+
});
|
|
2975
|
+
} catch (error) {
|
|
2976
|
+
console.error("[loly-core] Error starting production server:", error);
|
|
2977
|
+
process.exit(1);
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
};
|
|
2981
|
+
async function main() {
|
|
2982
|
+
const args = process.argv.slice(2);
|
|
2983
|
+
const command = args[0];
|
|
2984
|
+
if (!command || !(command in commands)) {
|
|
2985
|
+
console.log(`
|
|
2986
|
+
Usage: loly <command> [options]
|
|
2987
|
+
|
|
2988
|
+
Commands:
|
|
2989
|
+
dev [dir] [port] Start development server
|
|
2990
|
+
--stream Use streaming SSR
|
|
2991
|
+
|
|
2992
|
+
build [dir] Build for production
|
|
2993
|
+
--dev Build in development mode
|
|
2994
|
+
--out-dir=<path> Output directory
|
|
2995
|
+
|
|
2996
|
+
start [dir] [port] Start production server
|
|
2997
|
+
--stream Use streaming SSR
|
|
2998
|
+
|
|
2999
|
+
Examples:
|
|
3000
|
+
loly dev
|
|
3001
|
+
loly dev . 3000
|
|
3002
|
+
loly build
|
|
3003
|
+
loly build . --out-dir=dist
|
|
3004
|
+
loly start
|
|
3005
|
+
loly start . 3000
|
|
3006
|
+
`);
|
|
3007
|
+
process.exit(1);
|
|
3008
|
+
}
|
|
3009
|
+
const commandArgs = args.slice(1);
|
|
3010
|
+
await commands[command](commandArgs);
|
|
3011
|
+
}
|
|
3012
|
+
main().catch((error) => {
|
|
3013
|
+
console.error("[loly-core] Unexpected error:", error);
|
|
3014
|
+
process.exit(1);
|
|
3015
|
+
});
|