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.
@@ -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.localeCompare(mb.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
- // Routing and file-system checks at startup
1137
- function checkRoutesAtStartup() {
1138
- function getGenericSegment(name) {
1139
- if (name.startsWith('[') && name.endsWith(']')) {
1140
- const inner = name.slice(1, -1);
1141
- if (inner.startsWith('...')) return '*';
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
- function checkDir(dirPath, relativeDir = '', isApi = false) {
1148
- if (!fs.existsSync(dirPath)) return;
1149
- const children = fs.readdirSync(dirPath);
1150
- const kept = [];
1151
-
1152
- for (const child of children) {
1153
- if (child.startsWith('_') || child === 'locales') continue;
1154
- const fullPath = path.join(dirPath, child);
1155
- const relPath = relativeDir ? path.join(relativeDir, child) : child;
1156
- const stats = fs.statSync(fullPath);
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
- // 1. Check case-sensitivity issues
1159
- if (child !== child.toLowerCase()) {
1160
- console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
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
- if (stats.isDirectory()) {
1164
- if (child === 'api' && relativeDir === '') {
1165
- checkDir(fullPath, relPath, true);
1166
- } else {
1167
- checkDir(fullPath, relPath, isApi);
1168
- if (!isApi) {
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
- const ext = path.extname(child);
1174
- if (isApi) {
1175
- if (ext === '.js') {
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
- // 2. Check dynamic route collisions
1189
- const groups = new Map();
1190
- for (const item of kept) {
1191
- const normalized = getGenericSegment(item.name);
1192
- if (!groups.has(normalized)) {
1193
- groups.set(normalized, []);
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
- for (const [norm, items] of groups.entries()) {
1199
- if (items.length > 1) {
1200
- const namesList = items.map(item => `"${item.original}"`).join(' and ');
1201
- 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.`);
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
- checkDir(absolutePagesDir, '');
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
- // Scan for files
1215
- const files = scanDirectory(absolutePagesDir);
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 configPath = path.join(absolutePagesDir, file.replace('.njk', '.js'));
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
- const handler = require(route.fullPath);
1284
- const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
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
- const currentHandler = require(route.fullPath);
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 instanceof ZodError) {
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: err.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 (resolved at route registration — required for stateful middleware like express-rate-limit)
1367
- if (preResolvedMw.length) {
1368
- await runMiddlewareChain(preResolvedMw, req, res);
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 = loadRouteConfig(route.configPath, isDev);
1408
- const preResolvedPageMw = mountConfig?.middleware
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 ? loadRouteConfig(route.configPath, true) : mountConfig;
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 (chain fixed at route registration; edit middleware in dev → restart)
1525
- if (preResolvedPageMw.length) {
1526
- await runMiddlewareChain(preResolvedPageMw, req, res);
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
 
@@ -4,7 +4,14 @@
4
4
  */
5
5
 
6
6
  const fs = require('fs');
7
- const { parse } = require('yaml');
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
  };
@@ -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.localeCompare(mb.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 (src/pages, src/api, modules/*)
1385
- const discoveredDescriptors = scanRoutes({
1386
- rootDir,
1387
- pages: options.pages !== undefined ? options.pages : (options.pagesDir ? { dir: options.pagesDir } : true),
1388
- api: options.api,
1389
- modules: options.modules,
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;
@@ -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