webspresso 0.0.101 → 0.0.102
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/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +99 -2
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +6 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/routes.js +8 -0
- package/core/applySchema.js +18 -1
- package/index.d.ts +156 -1
- package/index.js +71 -16
- package/package.json +5 -5
- package/plugins/admin-panel/index.js +12 -0
- package/plugins/csrf/index.js +1 -1
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/src/api/api-loader.js +17 -1
- package/src/api/validation-i18n.js +231 -0
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +70 -4
- package/src/file-router.js +153 -91
- package/src/njk-frontmatter.js +17 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/route-table.js +1 -1
- package/src/server.js +63 -8
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
package/src/file-router.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const { ZodError } = require('zod');
|
|
9
8
|
const { compileSchema, invalidateSchema } = require('../core/compileSchema');
|
|
10
9
|
const { applySchema } = require('../core/applySchema');
|
|
10
|
+
const { translateValidationIssues, getTranslatorForRequest } = require('./api/validation-i18n');
|
|
11
11
|
const { createHelpers, getAssetManager } = require('./helpers');
|
|
12
12
|
const {
|
|
13
13
|
loadNjkRouteTemplate,
|
|
@@ -494,15 +494,15 @@ function routeRegistrationMeta(routePath) {
|
|
|
494
494
|
* @param {{ routePath: string }} b
|
|
495
495
|
*/
|
|
496
496
|
function compareRouteRegistrationOrder(a, b) {
|
|
497
|
-
const ma = routeRegistrationMeta(a.routePath);
|
|
498
|
-
const mb = routeRegistrationMeta(b.routePath);
|
|
497
|
+
const ma = a._regMeta || (a._regMeta = routeRegistrationMeta(a.routePath));
|
|
498
|
+
const mb = b._regMeta || (b._regMeta = routeRegistrationMeta(b.routePath));
|
|
499
499
|
if (ma.tier !== mb.tier) return ma.tier - mb.tier;
|
|
500
500
|
if (ma.literalSegCount !== mb.literalSegCount) {
|
|
501
501
|
return mb.literalSegCount - ma.literalSegCount;
|
|
502
502
|
}
|
|
503
503
|
if (ma.depth !== mb.depth) return mb.depth - ma.depth;
|
|
504
504
|
if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
|
|
505
|
-
return ma.routePath.
|
|
505
|
+
return ma.routePath < mb.routePath ? -1 : (ma.routePath > mb.routePath ? 1 : 0);
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
/**
|
|
@@ -1133,86 +1133,92 @@ function mountPages(app, options) {
|
|
|
1133
1133
|
// Get absolute path to pages directory
|
|
1134
1134
|
const absolutePagesDir = path.resolve(pagesDir);
|
|
1135
1135
|
|
|
1136
|
-
//
|
|
1137
|
-
function
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
return ':param';
|
|
1143
|
-
}
|
|
1144
|
-
return name;
|
|
1136
|
+
// Single-pass directory scanner with startup collision & case-sensitivity checks (0 statSync calls)
|
|
1137
|
+
function getGenericSegment(name) {
|
|
1138
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
1139
|
+
const inner = name.slice(1, -1);
|
|
1140
|
+
if (inner.startsWith('...')) return '*';
|
|
1141
|
+
return ':param';
|
|
1145
1142
|
}
|
|
1143
|
+
return name;
|
|
1144
|
+
}
|
|
1146
1145
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1146
|
+
function scanAndCheckDir(dirPath, relativeDir = '', isApi = false) {
|
|
1147
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
1148
|
+
let children;
|
|
1149
|
+
try {
|
|
1150
|
+
children = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
1151
|
+
} catch (_) {
|
|
1152
|
+
return [];
|
|
1153
|
+
}
|
|
1154
|
+
const kept = [];
|
|
1155
|
+
const files = [];
|
|
1156
|
+
|
|
1157
|
+
for (const entry of children) {
|
|
1158
|
+
const child = entry.name;
|
|
1159
|
+
if (child.startsWith('_') || child === 'locales') continue;
|
|
1160
|
+
const fullPath = path.join(dirPath, child);
|
|
1161
|
+
const relPath = relativeDir ? path.join(relativeDir, child) : child;
|
|
1162
|
+
|
|
1163
|
+
// 1. Check case-sensitivity issues
|
|
1164
|
+
if (child !== child.toLowerCase()) {
|
|
1165
|
+
console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
|
|
1166
|
+
}
|
|
1157
1167
|
|
|
1158
|
-
|
|
1159
|
-
if (child
|
|
1160
|
-
|
|
1168
|
+
if (entry.isDirectory()) {
|
|
1169
|
+
if (child === 'api' && relativeDir === '') {
|
|
1170
|
+
files.push(...scanAndCheckDir(fullPath, relPath, true));
|
|
1171
|
+
} else {
|
|
1172
|
+
files.push(...scanAndCheckDir(fullPath, relPath, isApi));
|
|
1173
|
+
if (!isApi) {
|
|
1174
|
+
kept.push({ name: child, isDir: true, original: child });
|
|
1175
|
+
}
|
|
1161
1176
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
kept.push({ name: child, isDir: true, original: child });
|
|
1170
|
-
}
|
|
1177
|
+
} else if (entry.isFile()) {
|
|
1178
|
+
files.push(relPath);
|
|
1179
|
+
const ext = path.extname(child);
|
|
1180
|
+
if (isApi) {
|
|
1181
|
+
if (ext === '.js') {
|
|
1182
|
+
const base = path.basename(child, ext);
|
|
1183
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
1171
1184
|
}
|
|
1172
1185
|
} else {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
const base = path.basename(child, ext);
|
|
1177
|
-
kept.push({ name: base, isDir: false, original: child });
|
|
1178
|
-
}
|
|
1179
|
-
} else {
|
|
1180
|
-
if (ext === '.njk') {
|
|
1181
|
-
const base = path.basename(child, ext);
|
|
1182
|
-
kept.push({ name: base, isDir: false, original: child });
|
|
1183
|
-
}
|
|
1186
|
+
if (ext === '.njk') {
|
|
1187
|
+
const base = path.basename(child, ext);
|
|
1188
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
1184
1189
|
}
|
|
1185
1190
|
}
|
|
1186
1191
|
}
|
|
1192
|
+
}
|
|
1187
1193
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
groups.get(normalized).push(item);
|
|
1194
|
+
// 2. Check dynamic route collisions
|
|
1195
|
+
const groups = new Map();
|
|
1196
|
+
for (const item of kept) {
|
|
1197
|
+
const normalized = getGenericSegment(item.name);
|
|
1198
|
+
if (!groups.has(normalized)) {
|
|
1199
|
+
groups.set(normalized, []);
|
|
1196
1200
|
}
|
|
1201
|
+
groups.get(normalized).push(item);
|
|
1202
|
+
}
|
|
1197
1203
|
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
}
|
|
1204
|
+
for (const [norm, items] of groups.entries()) {
|
|
1205
|
+
if (items.length > 1) {
|
|
1206
|
+
const namesList = items.map(item => `"${item.original}"`).join(' and ');
|
|
1207
|
+
console.warn(`[webspresso] WARNING: Dynamic route collision in "pages/${relativeDir.split(path.sep).join('/')}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
|
|
1203
1208
|
}
|
|
1204
1209
|
}
|
|
1205
1210
|
|
|
1206
|
-
|
|
1211
|
+
return files;
|
|
1207
1212
|
}
|
|
1208
1213
|
|
|
1209
|
-
checkRoutesAtStartup();
|
|
1210
|
-
|
|
1211
1214
|
// Load global hooks
|
|
1212
1215
|
const globalHooks = loadGlobalHooks(absolutePagesDir, isDev);
|
|
1213
1216
|
|
|
1214
|
-
//
|
|
1215
|
-
const files =
|
|
1217
|
+
// Single-pass scan for files and checks (bypassed if pre-scanned files provided by discovery phase)
|
|
1218
|
+
const files = (Array.isArray(options.scannedFiles) && options.scannedFiles.length >= 0)
|
|
1219
|
+
? options.scannedFiles
|
|
1220
|
+
: scanAndCheckDir(absolutePagesDir, '');
|
|
1221
|
+
const filesSet = new Set(files);
|
|
1216
1222
|
|
|
1217
1223
|
// Separate SSR pages and API routes
|
|
1218
1224
|
const ssrRoutes = [];
|
|
@@ -1239,7 +1245,9 @@ function mountPages(app, options) {
|
|
|
1239
1245
|
} else if (ext === '.njk') {
|
|
1240
1246
|
// SSR page
|
|
1241
1247
|
const routePath = filePathToRoute(file, '.njk');
|
|
1242
|
-
const
|
|
1248
|
+
const configRel = file.replace(/\.njk$/, '.js');
|
|
1249
|
+
const hasCompanionConfig = filesSet.has(configRel);
|
|
1250
|
+
const configPath = path.join(absolutePagesDir, configRel);
|
|
1243
1251
|
const routeDir = path.dirname(path.join(absolutePagesDir, file));
|
|
1244
1252
|
|
|
1245
1253
|
ssrRoutes.push({
|
|
@@ -1247,6 +1255,7 @@ function mountPages(app, options) {
|
|
|
1247
1255
|
routePath,
|
|
1248
1256
|
fullPath: path.join(absolutePagesDir, file),
|
|
1249
1257
|
configPath,
|
|
1258
|
+
hasCompanionConfig,
|
|
1250
1259
|
routeDir
|
|
1251
1260
|
});
|
|
1252
1261
|
}
|
|
@@ -1280,21 +1289,27 @@ function mountPages(app, options) {
|
|
|
1280
1289
|
if (options.skipExistingRoutes && options.skipExistingRoutes.has(route.method || 'GET', route.routePath)) {
|
|
1281
1290
|
continue;
|
|
1282
1291
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const routeMiddleware = handler.middleware;
|
|
1286
|
-
|
|
1287
|
-
const preResolvedMw = routeMiddleware
|
|
1288
|
-
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
1289
|
-
: EMPTY_ARRAY;
|
|
1290
|
-
|
|
1291
|
-
if (typeof handlerFn !== 'function') {
|
|
1292
|
-
console.warn(`API route ${route.file} does not export a function`);
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1292
|
+
let handlerFn = null;
|
|
1293
|
+
let preResolvedMw = null;
|
|
1296
1294
|
let preCompiledSchema = null;
|
|
1295
|
+
|
|
1296
|
+
// In production, eager-load to compile schemas & fail fast at startup
|
|
1297
1297
|
if (!isDev) {
|
|
1298
|
+
let handler;
|
|
1299
|
+
try {
|
|
1300
|
+
handler = require(route.fullPath);
|
|
1301
|
+
} catch (loadErr) {
|
|
1302
|
+
console.error(`Failed to load API route ${route.file}:`, loadErr);
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
1306
|
+
if (typeof handlerFn !== 'function') {
|
|
1307
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
preResolvedMw = handler.middleware
|
|
1311
|
+
? resolveMiddlewares(handler.middleware, middlewares)
|
|
1312
|
+
: EMPTY_ARRAY;
|
|
1298
1313
|
try {
|
|
1299
1314
|
preCompiledSchema = compileSchema(route.fullPath, handler);
|
|
1300
1315
|
} catch (schemaErr) {
|
|
@@ -1329,11 +1344,29 @@ function mountPages(app, options) {
|
|
|
1329
1344
|
if (require.cache[require.resolve(route.fullPath)]) {
|
|
1330
1345
|
delete require.cache[require.resolve(route.fullPath)];
|
|
1331
1346
|
}
|
|
1332
|
-
|
|
1347
|
+
let currentHandler;
|
|
1348
|
+
try {
|
|
1349
|
+
currentHandler = require(route.fullPath);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
console.error(`Failed to load API route ${route.file}:`, err);
|
|
1352
|
+
return res.status(500).json({ error: 'Internal Server Error', message: `Failed to load API route: ${err.message}` });
|
|
1353
|
+
}
|
|
1333
1354
|
fn = typeof currentHandler === 'function'
|
|
1334
1355
|
? currentHandler
|
|
1335
1356
|
: currentHandler.default || currentHandler.handler;
|
|
1336
1357
|
|
|
1358
|
+
if (typeof fn !== 'function') {
|
|
1359
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1360
|
+
return res.status(500).json({ error: 'Internal Server Error', message: `Route ${route.file} does not export a valid handler function` });
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Stateful middlewares (e.g. rate limiters) must be resolved once and preserved across requests
|
|
1364
|
+
if (preResolvedMw === null) {
|
|
1365
|
+
preResolvedMw = currentHandler.middleware
|
|
1366
|
+
? resolveMiddlewares(currentHandler.middleware, middlewares)
|
|
1367
|
+
: EMPTY_ARRAY;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1337
1370
|
invalidateSchema(route.fullPath);
|
|
1338
1371
|
try {
|
|
1339
1372
|
compiledSchema = compileSchema(route.fullPath, currentHandler);
|
|
@@ -1344,13 +1377,21 @@ function mountPages(app, options) {
|
|
|
1344
1377
|
}
|
|
1345
1378
|
}
|
|
1346
1379
|
|
|
1380
|
+
const routeMw = preResolvedMw || EMPTY_ARRAY;
|
|
1381
|
+
|
|
1347
1382
|
try {
|
|
1348
1383
|
applySchema(req, compiledSchema);
|
|
1349
1384
|
} catch (err) {
|
|
1350
|
-
if (err
|
|
1385
|
+
if (err && (err.name === 'ZodError' || Array.isArray(err.issues))) {
|
|
1386
|
+
const translatedIssues = translateValidationIssues(err.issues, {
|
|
1387
|
+
req,
|
|
1388
|
+
pagesDir: absolutePagesDir,
|
|
1389
|
+
routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
|
|
1390
|
+
isDev,
|
|
1391
|
+
});
|
|
1351
1392
|
return res.status(400).json({
|
|
1352
1393
|
error: 'Validation Error',
|
|
1353
|
-
issues:
|
|
1394
|
+
issues: translatedIssues,
|
|
1354
1395
|
});
|
|
1355
1396
|
}
|
|
1356
1397
|
throw err;
|
|
@@ -1363,11 +1404,18 @@ function mountPages(app, options) {
|
|
|
1363
1404
|
if (res.headersSent) return;
|
|
1364
1405
|
}
|
|
1365
1406
|
|
|
1366
|
-
// Run middleware if defined
|
|
1367
|
-
if (
|
|
1368
|
-
await runMiddlewareChain(
|
|
1407
|
+
// Run middleware if defined
|
|
1408
|
+
if (routeMw.length) {
|
|
1409
|
+
await runMiddlewareChain(routeMw, req, res);
|
|
1369
1410
|
if (res.headersSent) return;
|
|
1370
1411
|
}
|
|
1412
|
+
|
|
1413
|
+
const tr = getTranslatorForRequest(req, {
|
|
1414
|
+
pagesDir: absolutePagesDir,
|
|
1415
|
+
routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
|
|
1416
|
+
isDev,
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1371
1419
|
const apiCtx = Object.assign((err) => next(err), {
|
|
1372
1420
|
req,
|
|
1373
1421
|
res,
|
|
@@ -1376,6 +1424,8 @@ function mountPages(app, options) {
|
|
|
1376
1424
|
query: req.query || {},
|
|
1377
1425
|
body: req.body || {},
|
|
1378
1426
|
input: req.input,
|
|
1427
|
+
locale: tr.locale,
|
|
1428
|
+
t: tr.t,
|
|
1379
1429
|
service: req.service,
|
|
1380
1430
|
db,
|
|
1381
1431
|
sse: res.sse,
|
|
@@ -1404,10 +1454,12 @@ function mountPages(app, options) {
|
|
|
1404
1454
|
if (options.skipExistingRoutes && options.skipExistingRoutes.has('GET', route.routePath)) {
|
|
1405
1455
|
continue;
|
|
1406
1456
|
}
|
|
1407
|
-
const mountConfig =
|
|
1408
|
-
|
|
1457
|
+
const mountConfig = (!isDev && route.hasCompanionConfig)
|
|
1458
|
+
? loadRouteConfig(route.configPath, false)
|
|
1459
|
+
: null;
|
|
1460
|
+
let preResolvedPageMw = mountConfig?.middleware
|
|
1409
1461
|
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
1410
|
-
: EMPTY_ARRAY;
|
|
1462
|
+
: (!isDev ? EMPTY_ARRAY : null);
|
|
1411
1463
|
const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
|
|
1412
1464
|
|
|
1413
1465
|
app.get(route.routePath, async (req, res, next) => {
|
|
@@ -1455,8 +1507,16 @@ function mountPages(app, options) {
|
|
|
1455
1507
|
}
|
|
1456
1508
|
|
|
1457
1509
|
// Load route config
|
|
1458
|
-
const config = isDev
|
|
1510
|
+
const config = isDev
|
|
1511
|
+
? (route.hasCompanionConfig ? loadRouteConfig(route.configPath, true) : null)
|
|
1512
|
+
: mountConfig;
|
|
1459
1513
|
const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
|
|
1514
|
+
if (preResolvedPageMw === null) {
|
|
1515
|
+
preResolvedPageMw = config?.middleware
|
|
1516
|
+
? resolveMiddlewares(config.middleware, middlewares)
|
|
1517
|
+
: EMPTY_ARRAY;
|
|
1518
|
+
}
|
|
1519
|
+
const pageMw = preResolvedPageMw;
|
|
1460
1520
|
|
|
1461
1521
|
// Create context with plugin helpers merged
|
|
1462
1522
|
const baseHelpers = createHelpers({ req, res, locale, t });
|
|
@@ -1521,9 +1581,9 @@ function mountPages(app, options) {
|
|
|
1521
1581
|
if (res.headersSent) return;
|
|
1522
1582
|
}
|
|
1523
1583
|
|
|
1524
|
-
// Run route middleware
|
|
1525
|
-
if (
|
|
1526
|
-
await runMiddlewareChain(
|
|
1584
|
+
// Run route middleware
|
|
1585
|
+
if (pageMw.length) {
|
|
1586
|
+
await runMiddlewareChain(pageMw, req, res);
|
|
1527
1587
|
if (res.headersSent) return;
|
|
1528
1588
|
}
|
|
1529
1589
|
|
|
@@ -1758,5 +1818,7 @@ module.exports = {
|
|
|
1758
1818
|
clearCompiledStringTemplateCache,
|
|
1759
1819
|
translatorCache,
|
|
1760
1820
|
clearTranslatorCache,
|
|
1821
|
+
translateValidationIssues,
|
|
1822
|
+
getTranslatorForRequest,
|
|
1761
1823
|
};
|
|
1762
1824
|
|
package/src/njk-frontmatter.js
CHANGED
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
let _yamlParse = null;
|
|
9
|
+
function getYamlParse() {
|
|
10
|
+
if (!_yamlParse) {
|
|
11
|
+
_yamlParse = require('yaml').parse;
|
|
12
|
+
}
|
|
13
|
+
return _yamlParse;
|
|
14
|
+
}
|
|
8
15
|
|
|
9
16
|
const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
|
|
10
17
|
|
|
@@ -43,6 +50,7 @@ function parseNjkFrontmatter(content) {
|
|
|
43
50
|
return { body, fm: {}, hasDelimiter: true };
|
|
44
51
|
}
|
|
45
52
|
try {
|
|
53
|
+
const parse = getYamlParse();
|
|
46
54
|
const fm = parse(yamlText);
|
|
47
55
|
return {
|
|
48
56
|
body,
|
|
@@ -171,6 +179,13 @@ function clearNjkFrontmatterCaches() {
|
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
function seedProdRouteCache(entries) {
|
|
183
|
+
if (!entries || typeof entries !== 'object') return;
|
|
184
|
+
for (const [absPath, val] of Object.entries(entries)) {
|
|
185
|
+
prodRouteCache.set(absPath, val);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
174
189
|
module.exports = {
|
|
175
190
|
parseNjkFrontmatter,
|
|
176
191
|
frontmatterToPatches,
|
|
@@ -178,4 +193,5 @@ module.exports = {
|
|
|
178
193
|
clearNjkFrontmatterCaches,
|
|
179
194
|
registerClearHook,
|
|
180
195
|
extractFrontmatterBlock,
|
|
196
|
+
seedProdRouteCache,
|
|
181
197
|
};
|
package/src/plugin-manager.js
CHANGED
|
@@ -669,6 +669,18 @@ class PluginManager {
|
|
|
669
669
|
* Get a plugin's API by name
|
|
670
670
|
*/
|
|
671
671
|
getPluginAPI(name) {
|
|
672
|
+
const plugin = this.plugins.get(name);
|
|
673
|
+
if (plugin && plugin.isLazy && typeof plugin.hydrate === 'function' && !plugin.isHydrated) {
|
|
674
|
+
const real = plugin.hydrate();
|
|
675
|
+
if (real && real.api) {
|
|
676
|
+
const boundAPI = {};
|
|
677
|
+
for (const [key, value] of Object.entries(real.api)) {
|
|
678
|
+
boundAPI[key] = typeof value === 'function' ? value.bind(real) : value;
|
|
679
|
+
}
|
|
680
|
+
this.pluginAPIs.set(name, boundAPI);
|
|
681
|
+
}
|
|
682
|
+
return this.pluginAPIs.get(name) || (real && real.api) || plugin.api;
|
|
683
|
+
}
|
|
672
684
|
return this.pluginAPIs.get(name);
|
|
673
685
|
}
|
|
674
686
|
|
|
@@ -63,7 +63,7 @@ function compareRouteOrder(a, b) {
|
|
|
63
63
|
if (ma.literalSegCount !== mb.literalSegCount) return mb.literalSegCount - ma.literalSegCount;
|
|
64
64
|
if (ma.depth !== mb.depth) return mb.depth - ma.depth;
|
|
65
65
|
if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
|
|
66
|
-
return ma.routePath.
|
|
66
|
+
return ma.routePath < mb.routePath ? -1 : (ma.routePath > mb.routePath ? 1 : 0);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
package/src/server.js
CHANGED
|
@@ -24,6 +24,8 @@ const { registerClearHook } = require('./njk-frontmatter');
|
|
|
24
24
|
const { createPluginManager } = require('./plugin-manager');
|
|
25
25
|
const { createServiceRegistry } = require('./services');
|
|
26
26
|
const { scanRoutes } = require('./discovery/scan-routes');
|
|
27
|
+
const { loadRouteManifest } = require('./discovery/route-manifest');
|
|
28
|
+
const { loadPrecompiledTemplates } = require('./template/precompile');
|
|
27
29
|
const { compileRouteTable } = require('./routing/route-table');
|
|
28
30
|
const { mountDiscoveredRoutes } = require('./routing/mount-discovered-routes');
|
|
29
31
|
const { discoverModuleDetails } = require('./modules/module-discovery');
|
|
@@ -106,6 +108,13 @@ function canonicalTemplateKey(name) {
|
|
|
106
108
|
|
|
107
109
|
function configureSafeNunjucks(templateDirs, options = {}) {
|
|
108
110
|
const env = nunjucks.configure(templateDirs, options);
|
|
111
|
+
if (options.precompiledLoader && Array.isArray(env.loaders)) {
|
|
112
|
+
options.precompiledLoader.cache = options.precompiledLoader.cache || {};
|
|
113
|
+
env.loaders.unshift(options.precompiledLoader);
|
|
114
|
+
if (typeof env._initLoaders === 'function') {
|
|
115
|
+
env._initLoaders();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
109
118
|
const origGetTemplate = env.getTemplate.bind(env);
|
|
110
119
|
const origRender = env.render.bind(env);
|
|
111
120
|
const origRenderString = env.renderString.bind(env);
|
|
@@ -699,6 +708,7 @@ function createApp(options = {}) {
|
|
|
699
708
|
|
|
700
709
|
const app = express();
|
|
701
710
|
app.serviceRegistry = serviceRegistry;
|
|
711
|
+
app.pluginManager = pluginManager;
|
|
702
712
|
app.events = eventDispatcher;
|
|
703
713
|
if (options.queue) {
|
|
704
714
|
app.queue = options.queue;
|
|
@@ -1286,12 +1296,29 @@ function createApp(options = {}) {
|
|
|
1286
1296
|
templateDirs.push(path.join(rootDir, 'pages'));
|
|
1287
1297
|
}
|
|
1288
1298
|
|
|
1299
|
+
// Load precompiled Nunjucks templates if enabled or available
|
|
1300
|
+
let precompiledResult = null;
|
|
1301
|
+
const usePrecompiled = options.precompiledTemplates === true ||
|
|
1302
|
+
(typeof options.precompiledTemplates === 'string') ||
|
|
1303
|
+
(options.precompiledTemplates !== false && !isDev);
|
|
1304
|
+
|
|
1305
|
+
if (usePrecompiled) {
|
|
1306
|
+
precompiledResult = loadPrecompiledTemplates({
|
|
1307
|
+
rootDir,
|
|
1308
|
+
bundlePath: typeof options.precompiledTemplates === 'string' ? options.precompiledTemplates : undefined,
|
|
1309
|
+
});
|
|
1310
|
+
if (precompiledResult) {
|
|
1311
|
+
app.precompiledTemplates = precompiledResult.bundle;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1289
1315
|
const nunjucksOpts = options.nunjucks || {};
|
|
1290
1316
|
const nunjucksEnv = configureSafeNunjucks(templateDirs, {
|
|
1291
1317
|
autoescape: true,
|
|
1292
1318
|
express: app,
|
|
1293
1319
|
watch: isDev && !isTest,
|
|
1294
1320
|
noCache: nunjucksOpts.noCache ?? (isDev || isTest),
|
|
1321
|
+
precompiledLoader: precompiledResult ? precompiledResult.loader : null,
|
|
1295
1322
|
...nunjucksOpts,
|
|
1296
1323
|
});
|
|
1297
1324
|
|
|
@@ -1381,13 +1408,35 @@ function createApp(options = {}) {
|
|
|
1381
1408
|
app.use(profilerMiddleware);
|
|
1382
1409
|
}
|
|
1383
1410
|
|
|
1384
|
-
// 1. Scan and compile discovered routes (
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1411
|
+
// 1. Scan and compile discovered routes (AOT route manifest or disk scan)
|
|
1412
|
+
let discoveredDescriptors = null;
|
|
1413
|
+
let loadedManifest = null;
|
|
1414
|
+
|
|
1415
|
+
// In production (or when explicitly requested/enabled via options.routesManifest), attempt loading AOT manifest
|
|
1416
|
+
const useManifest = options.routesManifest === true ||
|
|
1417
|
+
(typeof options.routesManifest === 'string') ||
|
|
1418
|
+
(options.routesManifest !== false && !isDev);
|
|
1419
|
+
|
|
1420
|
+
if (useManifest) {
|
|
1421
|
+
const manifestResult = loadRouteManifest({
|
|
1422
|
+
rootDir,
|
|
1423
|
+
manifestPath: typeof options.routesManifest === 'string' ? options.routesManifest : undefined,
|
|
1424
|
+
});
|
|
1425
|
+
if (manifestResult) {
|
|
1426
|
+
discoveredDescriptors = manifestResult.descriptors;
|
|
1427
|
+
loadedManifest = manifestResult.manifest;
|
|
1428
|
+
app.routesManifest = loadedManifest;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
if (!discoveredDescriptors) {
|
|
1433
|
+
discoveredDescriptors = scanRoutes({
|
|
1434
|
+
rootDir,
|
|
1435
|
+
pages: options.pages !== undefined ? options.pages : (options.pagesDir ? { dir: options.pagesDir } : true),
|
|
1436
|
+
api: options.api,
|
|
1437
|
+
modules: options.modules,
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1391
1440
|
|
|
1392
1441
|
const routeTable = compileRouteTable(discoveredDescriptors, {
|
|
1393
1442
|
explicitRoutes: options.routes || [],
|
|
@@ -1495,12 +1544,18 @@ function createApp(options = {}) {
|
|
|
1495
1544
|
options,
|
|
1496
1545
|
});
|
|
1497
1546
|
|
|
1498
|
-
// 2. Mount classic file-based routes for backwards compatibility
|
|
1547
|
+
// 2. Mount classic file-based routes for backwards compatibility (reuses pre-scanned files from discovery)
|
|
1499
1548
|
let classicRouteMeta = [];
|
|
1500
1549
|
let registerDynamicFileRoutes = () => {};
|
|
1501
1550
|
if (pagesDir && fs.existsSync(path.resolve(pagesDir))) {
|
|
1551
|
+
const resolvedPagesDir = path.resolve(pagesDir);
|
|
1552
|
+
const scannedFiles = (discoveredDescriptors && discoveredDescriptors.scannedPagesDir === resolvedPagesDir)
|
|
1553
|
+
? discoveredDescriptors.scannedPagesFiles
|
|
1554
|
+
: null;
|
|
1555
|
+
|
|
1502
1556
|
const classicMount = mountPages(app, {
|
|
1503
1557
|
pagesDir,
|
|
1558
|
+
scannedFiles,
|
|
1504
1559
|
nunjucks: nunjucksEnv,
|
|
1505
1560
|
middlewares,
|
|
1506
1561
|
pluginManager,
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const os = require('os');
|
|
7
|
-
const { z } = require('zod');
|
|
8
|
-
const { cleanExpiredTokens, createKnexAuthTokensAdapter } = require('../../../core/auth/tokens');
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Format memory usage bytes into human-readable MB
|
|
@@ -29,7 +27,7 @@ function createSystemServices(options = {}) {
|
|
|
29
27
|
|
|
30
28
|
return {
|
|
31
29
|
'system.health': {
|
|
32
|
-
schema: z.object({
|
|
30
|
+
schema: ({ z }) => z.object({
|
|
33
31
|
detailed: z.boolean().default(false),
|
|
34
32
|
}),
|
|
35
33
|
async handler(input, ctx) {
|
|
@@ -81,7 +79,7 @@ function createSystemServices(options = {}) {
|
|
|
81
79
|
},
|
|
82
80
|
|
|
83
81
|
'system.cleanup': {
|
|
84
|
-
schema: z.object({
|
|
82
|
+
schema: ({ z }) => z.object({
|
|
85
83
|
olderThanDays: z.number().int().positive().default(90),
|
|
86
84
|
purgeAuthTokens: z.boolean().default(true),
|
|
87
85
|
purgeAuditLogs: z.boolean().default(true),
|
|
@@ -138,7 +136,7 @@ function createSystemServices(options = {}) {
|
|
|
138
136
|
},
|
|
139
137
|
|
|
140
138
|
'system.cache-flush': {
|
|
141
|
-
schema: z.object({
|
|
139
|
+
schema: ({ z }) => z.object({
|
|
142
140
|
scope: z.enum(['all', 'orm', 'services']).default('all'),
|
|
143
141
|
model: z.string().optional(),
|
|
144
142
|
}),
|
|
@@ -182,7 +180,7 @@ function createSystemServices(options = {}) {
|
|
|
182
180
|
},
|
|
183
181
|
|
|
184
182
|
'system.info': {
|
|
185
|
-
schema: z.object({}),
|
|
183
|
+
schema: ({ z }) => z.object({}),
|
|
186
184
|
auth: 'admin',
|
|
187
185
|
async handler(input, ctx) {
|
|
188
186
|
const activeDb = ctx.db || db;
|
package/src/services/registry.js
CHANGED
|
@@ -125,12 +125,12 @@ class ServiceRegistry {
|
|
|
125
125
|
* @param {Object|Function} definition - Service definition or handler function
|
|
126
126
|
* @returns {ServiceRegistry}
|
|
127
127
|
*/
|
|
128
|
-
register(name, definition) {
|
|
128
|
+
register(name, definition, options = {}) {
|
|
129
129
|
if (!name || typeof name !== 'string') {
|
|
130
130
|
throw new WebspressoError('Service name must be a non-empty string');
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
if (this.services.has(name)) {
|
|
133
|
+
if (this.services.has(name) && !options.overwrite) {
|
|
134
134
|
throw new ConfigurationError(`Duplicate service registered: ${name}`);
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -170,6 +170,16 @@ class ServiceRegistry {
|
|
|
170
170
|
return this;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Unregister a service by name
|
|
175
|
+
* @param {string} name - Service name
|
|
176
|
+
* @returns {boolean} Whether service was found and removed
|
|
177
|
+
*/
|
|
178
|
+
unregister(name) {
|
|
179
|
+
if (!name || typeof name !== 'string') return false;
|
|
180
|
+
return this.services.delete(name);
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
/**
|
|
174
184
|
* Get a service definition by name or alias
|
|
175
185
|
* @param {string} name - Service name
|