webspresso 0.0.91 → 0.0.93

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.
Files changed (60) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +8 -2
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +2 -0
  12. package/core/compileSchema.js +1 -5
  13. package/core/compression/compressible.js +17 -5
  14. package/core/compression/compression-stream.js +1 -1
  15. package/core/compression/middleware.js +9 -0
  16. package/core/compression/negotiate.js +23 -11
  17. package/core/kernel/app.js +2 -1
  18. package/core/kernel/index.js +5 -1
  19. package/core/orm/cache/fingerprint.js +23 -5
  20. package/core/orm/eager-loader.js +54 -9
  21. package/core/orm/index.js +38 -2
  22. package/core/orm/model.js +11 -2
  23. package/core/queue/adapters/database.js +46 -13
  24. package/core/queue/adapters/memory.js +21 -0
  25. package/core/queue/adapters/redis.js +21 -0
  26. package/core/queue/manager.js +46 -20
  27. package/core/validation/index.js +70 -0
  28. package/index.d.ts +99 -5
  29. package/index.js +7 -1
  30. package/package.json +9 -1
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  32. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  33. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  34. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  35. package/plugins/admin-panel/index.js +65 -6
  36. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  37. package/plugins/admin-panel/modules/dashboard.js +8 -1
  38. package/plugins/data-exchange/export-xlsx.js +28 -6
  39. package/plugins/data-exchange/import.js +45 -20
  40. package/plugins/data-exchange/index.js +5 -0
  41. package/plugins/data-exchange/parse-table.js +14 -5
  42. package/plugins/data-exchange/record-selection.js +2 -0
  43. package/plugins/index.js +4 -0
  44. package/plugins/mcp/auth.js +104 -0
  45. package/plugins/mcp/discovery.js +88 -0
  46. package/plugins/mcp/index.js +208 -0
  47. package/plugins/mcp/prompts.js +149 -0
  48. package/plugins/mcp/providers/orm.js +325 -0
  49. package/plugins/mcp/providers/services.js +190 -0
  50. package/plugins/mcp/providers/system.js +87 -0
  51. package/plugins/mcp/server.js +472 -0
  52. package/plugins/mcp/transports/sse.js +139 -0
  53. package/plugins/mcp/transports/stdio.js +108 -0
  54. package/src/file-router.js +184 -123
  55. package/src/njk-frontmatter.js +24 -14
  56. package/src/server.js +30 -12
  57. package/src/services/discovery.js +2 -1
  58. package/src/services/registry.js +2 -0
  59. package/src/services/validator.js +2 -53
  60. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -367,9 +367,31 @@ function scanDirectory(dir, baseDir = dir) {
367
367
  /**
368
368
  * Load i18n JSON file with caching
369
369
  * @param {string} filePath - Path to JSON file
370
+ * @param {boolean} [isDev=false] - Whether in development mode
370
371
  * @returns {Object} Parsed JSON or empty object
371
372
  */
372
- function loadI18nFile(filePath) {
373
+ function loadI18nFile(filePath, isDev = (process.env.NODE_ENV !== 'production')) {
374
+ if (!isDev) {
375
+ const cached = i18nCache.get(filePath);
376
+ if (cached) {
377
+ return cached.data;
378
+ }
379
+ if (!fs.existsSync(filePath)) {
380
+ i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
381
+ return EMPTY_OBJECT;
382
+ }
383
+ try {
384
+ const content = fs.readFileSync(filePath, 'utf-8');
385
+ const data = JSON.parse(content);
386
+ i18nCache.set(filePath, { mtime: 0, data });
387
+ return data;
388
+ } catch (err) {
389
+ console.error('Error loading i18n file:', filePath, err.message);
390
+ i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
391
+ return EMPTY_OBJECT;
392
+ }
393
+ }
394
+
373
395
  if (!fs.existsSync(filePath)) {
374
396
  return EMPTY_OBJECT;
375
397
  }
@@ -398,25 +420,43 @@ function loadI18nFile(filePath) {
398
420
  * @param {string} pagesDir - Pages directory path
399
421
  * @param {string} routeDir - Route directory path
400
422
  * @param {string} locale - Locale code
423
+ * @param {boolean} [isDev=false] - Whether in development mode
401
424
  * @returns {Object} Merged translations
402
425
  */
403
- function loadI18n(pagesDir, routeDir, locale) {
426
+ function loadI18n(pagesDir, routeDir, locale, isDev = (process.env.NODE_ENV !== 'production')) {
427
+ const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
428
+
429
+ if (!isDev) {
430
+ const mergedCached = mergedI18nCache.get(cacheKey);
431
+ if (mergedCached) {
432
+ return mergedCached.data;
433
+ }
434
+ }
435
+
404
436
  // Load global translations
405
437
  const globalPath = path.join(pagesDir, 'locales', `${locale}.json`);
406
- const globalTranslations = loadI18nFile(globalPath);
438
+ const globalTranslations = loadI18nFile(globalPath, isDev);
407
439
 
408
440
  // Load route-specific translations
409
441
  const routePath = path.join(routeDir, 'locales', `${locale}.json`);
410
- const routeTranslations = loadI18nFile(routePath);
442
+ const routeTranslations = loadI18nFile(routePath, isDev);
411
443
 
412
444
  const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
413
445
  const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
414
446
 
415
- if (gEmpty && rEmpty) return EMPTY_OBJECT;
416
- if (gEmpty) return routeTranslations;
417
- if (rEmpty) return globalTranslations;
447
+ if (gEmpty && rEmpty) {
448
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: EMPTY_OBJECT });
449
+ return EMPTY_OBJECT;
450
+ }
451
+ if (gEmpty) {
452
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: routeTranslations });
453
+ return routeTranslations;
454
+ }
455
+ if (rEmpty) {
456
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: globalTranslations });
457
+ return globalTranslations;
458
+ }
418
459
 
419
- const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
420
460
  const gCached = i18nCache.get(globalPath);
421
461
  const rCached = i18nCache.get(routePath);
422
462
  const gm = gCached?.mtime ?? 0;
@@ -626,32 +666,41 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
626
666
  * @returns {Object|null} Route config or null
627
667
  */
628
668
  function loadRouteConfig(configPath, isDev) {
669
+ if (!isDev) {
670
+ if (configCache.has(configPath)) {
671
+ return configCache.get(configPath);
672
+ }
673
+ if (!fs.existsSync(configPath)) {
674
+ configCache.set(configPath, null);
675
+ return null;
676
+ }
677
+ try {
678
+ const config = require(configPath);
679
+ configCache.set(configPath, config);
680
+ return config;
681
+ } catch (err) {
682
+ console.error('Error loading route config:', configPath, err.message);
683
+ configCache.set(configPath, null);
684
+ return null;
685
+ }
686
+ }
687
+
629
688
  if (!fs.existsSync(configPath)) {
630
689
  routeConfigDevCache.delete(configPath);
631
690
  return null;
632
691
  }
633
692
 
634
693
  try {
635
- if (isDev) {
636
- const stats = fs.statSync(configPath);
637
- const devCached = routeConfigDevCache.get(configPath);
638
- if (devCached && devCached.mtime >= stats.mtimeMs) {
639
- return devCached.config;
640
- }
641
- if (require.cache[require.resolve(configPath)]) {
642
- delete require.cache[require.resolve(configPath)];
643
- }
644
- const config = require(configPath);
645
- routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
646
- return config;
694
+ const stats = fs.statSync(configPath);
695
+ const devCached = routeConfigDevCache.get(configPath);
696
+ if (devCached && devCached.mtime >= stats.mtimeMs) {
697
+ return devCached.config;
647
698
  }
648
-
649
- if (configCache.has(configPath)) {
650
- return configCache.get(configPath);
699
+ if (require.cache[require.resolve(configPath)]) {
700
+ delete require.cache[require.resolve(configPath)];
651
701
  }
652
-
653
702
  const config = require(configPath);
654
- configCache.set(configPath, config);
703
+ routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
655
704
  return config;
656
705
  } catch (err) {
657
706
  console.error('Error loading route config:', configPath, err.message);
@@ -976,123 +1025,133 @@ function mountPages(app, options) {
976
1025
  /** Register API routes (shared by static phase and dynamic phase). */
977
1026
  const registerApiRoutes = (routes) => {
978
1027
  for (const route of routes) {
979
- const handler = require(route.fullPath);
980
- const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
981
- const routeMiddleware = handler.middleware;
1028
+ const handler = require(route.fullPath);
1029
+ const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
1030
+ const routeMiddleware = handler.middleware;
982
1031
 
983
- const preResolvedMw = routeMiddleware
984
- ? resolveMiddlewares(routeMiddleware, middlewares)
985
- : EMPTY_ARRAY;
986
-
987
- if (typeof handlerFn !== 'function') {
988
- console.warn(`API route ${route.file} does not export a function`);
989
- continue;
990
- }
1032
+ const preResolvedMw = routeMiddleware
1033
+ ? resolveMiddlewares(routeMiddleware, middlewares)
1034
+ : EMPTY_ARRAY;
991
1035
 
992
- app[route.method](route.routePath, async (req, res, next) => {
993
- try {
994
- // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
995
- if (db != null) {
996
- req.db = db;
997
- }
998
- if (serviceRegistry != null) {
999
- req.service = (name, input, opts) =>
1000
- serviceRegistry.call(name, input, { req, res, db }, opts);
1001
- }
1036
+ if (typeof handlerFn !== 'function') {
1037
+ console.warn(`API route ${route.file} does not export a function`);
1038
+ continue;
1039
+ }
1002
1040
 
1003
- // Reload handler in dev mode
1004
- if (isDev && require.cache[require.resolve(route.fullPath)]) {
1005
- delete require.cache[require.resolve(route.fullPath)];
1006
- }
1007
- const currentHandler = isDev
1008
- ? require(route.fullPath)
1009
- : handler;
1010
- const fn = typeof currentHandler === 'function'
1011
- ? currentHandler
1012
- : currentHandler.default || currentHandler.handler;
1013
-
1014
- if (isDev) {
1015
- invalidateSchema(route.fullPath);
1016
- }
1017
- let compiledSchema;
1041
+ let preCompiledSchema = null;
1042
+ if (!isDev) {
1018
1043
  try {
1019
- compiledSchema = compileSchema(route.fullPath, currentHandler);
1044
+ preCompiledSchema = compileSchema(route.fullPath, handler);
1020
1045
  } catch (schemaErr) {
1021
1046
  console.error(`API schema compile error ${route.routePath}:`, schemaErr);
1022
- res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
1023
- return;
1024
1047
  }
1025
-
1048
+ }
1049
+
1050
+ app[route.method](route.routePath, async (req, res, next) => {
1026
1051
  try {
1027
- applySchema(req, compiledSchema);
1028
- } catch (err) {
1029
- if (err instanceof ZodError) {
1030
- return res.status(400).json({
1031
- error: 'Validation Error',
1032
- issues: err.issues,
1033
- });
1052
+ // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
1053
+ if (db != null) {
1054
+ req.db = db;
1034
1055
  }
1035
- throw err;
1036
- }
1037
-
1038
- // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1039
- if (preResolvedMw.length) {
1040
- for (const mw of preResolvedMw) {
1041
- await new Promise((resolve, reject) => {
1042
- mw(req, res, (err) => {
1043
- if (err) reject(err);
1044
- else resolve();
1056
+ if (serviceRegistry != null) {
1057
+ req.service = (name, input, opts) =>
1058
+ serviceRegistry.call(name, input, { req, res, db }, opts);
1059
+ }
1060
+
1061
+ let fn = handlerFn;
1062
+ let compiledSchema = preCompiledSchema;
1063
+
1064
+ // Reload handler and schema in dev mode
1065
+ if (isDev) {
1066
+ if (require.cache[require.resolve(route.fullPath)]) {
1067
+ delete require.cache[require.resolve(route.fullPath)];
1068
+ }
1069
+ const currentHandler = require(route.fullPath);
1070
+ fn = typeof currentHandler === 'function'
1071
+ ? currentHandler
1072
+ : currentHandler.default || currentHandler.handler;
1073
+
1074
+ invalidateSchema(route.fullPath);
1075
+ try {
1076
+ compiledSchema = compileSchema(route.fullPath, currentHandler);
1077
+ } catch (schemaErr) {
1078
+ console.error(`API schema compile error ${route.routePath}:`, schemaErr);
1079
+ res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
1080
+ return;
1081
+ }
1082
+ }
1083
+
1084
+ try {
1085
+ applySchema(req, compiledSchema);
1086
+ } catch (err) {
1087
+ if (err instanceof ZodError) {
1088
+ return res.status(400).json({
1089
+ error: 'Validation Error',
1090
+ issues: err.issues,
1045
1091
  });
1046
- });
1092
+ }
1093
+ throw err;
1047
1094
  }
1095
+
1096
+ // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1097
+ if (preResolvedMw.length) {
1098
+ for (const mw of preResolvedMw) {
1099
+ await new Promise((resolve, reject) => {
1100
+ mw(req, res, (err) => {
1101
+ if (err) reject(err);
1102
+ else resolve();
1103
+ });
1104
+ });
1105
+ }
1106
+ }
1107
+
1108
+ await fn(req, res, next);
1109
+ } catch (err) {
1110
+ console.error(`API error ${route.routePath}:`, err);
1111
+ const hookCtx = { req, res, error: err };
1112
+ try {
1113
+ await executeHook(globalHooks, 'onError', hookCtx, err);
1114
+ } catch (hookErr) {
1115
+ console.error('Error in onError hook:', hookErr);
1116
+ }
1117
+ return next(err);
1048
1118
  }
1049
-
1050
- await fn(req, res, next);
1051
- } catch (err) {
1052
- console.error(`API error ${route.routePath}:`, err);
1053
- const hookCtx = { req, res, error: err };
1054
- try {
1055
- await executeHook(globalHooks, 'onError', hookCtx, err);
1056
- } catch (hookErr) {
1057
- console.error('Error in onError hook:', hookErr);
1058
- }
1059
- return next(err);
1060
- }
1061
- });
1062
-
1063
- log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
1119
+ });
1120
+
1121
+ log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
1064
1122
  }
1065
1123
  };
1066
1124
 
1067
1125
  /** Register SSR GET routes (shared by static phase and dynamic phase). */
1068
1126
  const registerSsrRoutes = (routes) => {
1069
1127
  for (const route of routes) {
1070
- const mountConfig = loadRouteConfig(route.configPath, isDev);
1071
- const preResolvedPageMw = mountConfig?.middleware
1072
- ? resolveMiddlewares(mountConfig.middleware, middlewares)
1073
- : EMPTY_ARRAY;
1074
-
1075
- app.get(route.routePath, async (req, res, next) => {
1076
- try {
1077
- // Detect locale
1078
- const locale = detectLocale(req);
1079
- const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
1080
-
1081
- // Load translations (primary + fallback)
1082
- const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
1083
- const fallbackTranslations = (locale !== defaultLocale)
1084
- ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale)
1085
- : EMPTY_OBJECT;
1128
+ const mountConfig = loadRouteConfig(route.configPath, isDev);
1129
+ const preResolvedPageMw = mountConfig?.middleware
1130
+ ? resolveMiddlewares(mountConfig.middleware, middlewares)
1131
+ : EMPTY_ARRAY;
1132
+ const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
1086
1133
 
1087
- const t = createTranslator(translations, {
1088
- locale,
1089
- fallbackTranslations,
1090
- fallbackLocale: defaultLocale,
1091
- });
1092
-
1093
- // Load route config
1094
- const config = loadRouteConfig(route.configPath, isDev);
1095
- const routeHooks = config?.hooks || EMPTY_OBJECT;
1134
+ app.get(route.routePath, async (req, res, next) => {
1135
+ try {
1136
+ // Detect locale
1137
+ const locale = detectLocale(req);
1138
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
1139
+
1140
+ // Load translations (primary + fallback)
1141
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
1142
+ const fallbackTranslations = (locale !== defaultLocale)
1143
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
1144
+ : EMPTY_OBJECT;
1145
+
1146
+ const t = createTranslator(translations, {
1147
+ locale,
1148
+ fallbackTranslations,
1149
+ fallbackLocale: defaultLocale,
1150
+ });
1151
+
1152
+ // Load route config
1153
+ const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
1154
+ const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
1096
1155
 
1097
1156
  // Create context with plugin helpers merged
1098
1157
  const baseHelpers = createHelpers({ req, res, locale, t });
@@ -1134,6 +1193,8 @@ function mountPages(app, options) {
1134
1193
  clientRuntime,
1135
1194
  };
1136
1195
 
1196
+ req.context = ctx;
1197
+
1137
1198
  // Execute hooks: onRequest
1138
1199
  await executeHook(globalHooks, 'onRequest', ctx);
1139
1200
  await executeHook(routeHooks, 'onRequest', ctx);
@@ -115,6 +115,24 @@ function readAndParse(absPath, isDevLike = false) {
115
115
  * @param {boolean} isDev NODE_ENV !== 'production' style
116
116
  */
117
117
  function loadNjkRouteTemplate(absPath, isDev) {
118
+ if (!isDev) {
119
+ const cached = prodRouteCache.get(absPath);
120
+ if (cached) {
121
+ return cached;
122
+ }
123
+ if (!fs.existsSync(absPath)) {
124
+ const empty = {
125
+ useStringRender: false,
126
+ templateBody: null,
127
+ metaPatch: {},
128
+ dataPatch: {},
129
+ };
130
+ prodRouteCache.set(absPath, empty);
131
+ return empty;
132
+ }
133
+ return readAndParse(absPath, false);
134
+ }
135
+
118
136
  if (!fs.existsSync(absPath)) {
119
137
  return {
120
138
  useStringRender: false,
@@ -125,21 +143,13 @@ function loadNjkRouteTemplate(absPath, isDev) {
125
143
  }
126
144
 
127
145
  const stat = fs.statSync(absPath);
128
-
129
- if (isDev) {
130
- const prev = devRouteCache.get(absPath);
131
- if (prev && prev.mtimeMs >= stat.mtimeMs) {
132
- return prev.cached;
133
- }
134
- const fresh = readAndParse(absPath, true);
135
- devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
136
- return fresh;
137
- }
138
-
139
- if (prodRouteCache.has(absPath)) {
140
- return prodRouteCache.get(absPath);
146
+ const prev = devRouteCache.get(absPath);
147
+ if (prev && prev.mtimeMs >= stat.mtimeMs) {
148
+ return prev.cached;
141
149
  }
142
- return readAndParse(absPath, false);
150
+ const fresh = readAndParse(absPath, true);
151
+ devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
152
+ return fresh;
143
153
  }
144
154
 
145
155
  function clearNjkFrontmatterCaches() {
package/src/server.js CHANGED
@@ -47,7 +47,7 @@ function configureSafeNunjucks(templateDirs, options = {}) {
47
47
  const origRender = env.render.bind(env);
48
48
  const origRenderString = env.renderString.bind(env);
49
49
 
50
- function wrapTemplateRoot(tmpl, name, store) {
50
+ function wrapTemplateRoot(tmpl, name) {
51
51
  if (!tmpl || tmpl._safeWrapped) return;
52
52
  tmpl._safeWrapped = true;
53
53
  const origRoot = tmpl.rootRenderFunc;
@@ -55,21 +55,28 @@ function configureSafeNunjucks(templateDirs, options = {}) {
55
55
 
56
56
  tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
57
57
  const normalizedName = String(name || tmpl.path || 'anonymous');
58
- store.stack.push(normalizedName);
58
+ const store = renderStackStorage.getStore();
59
+ if (store) {
60
+ store.stack.push(normalizedName);
61
+ }
59
62
  let finished = false;
60
63
  const done = (err, out) => {
61
64
  if (!finished) {
62
65
  finished = true;
63
- const idx = store.stack.lastIndexOf(normalizedName);
64
- if (idx !== -1) store.stack.splice(idx, 1);
66
+ if (store) {
67
+ const idx = store.stack.lastIndexOf(normalizedName);
68
+ if (idx !== -1) store.stack.splice(idx, 1);
69
+ }
65
70
  }
66
71
  renderCb(err, out);
67
72
  };
68
73
  try {
69
74
  return origRoot.call(this, e, context, frame, runtime, done);
70
75
  } catch (ex) {
71
- const idx = store.stack.lastIndexOf(normalizedName);
72
- if (idx !== -1) store.stack.splice(idx, 1);
76
+ if (store) {
77
+ const idx = store.stack.lastIndexOf(normalizedName);
78
+ if (idx !== -1) store.stack.splice(idx, 1);
79
+ }
73
80
  throw ex;
74
81
  }
75
82
  };
@@ -97,15 +104,15 @@ function configureSafeNunjucks(templateDirs, options = {}) {
97
104
  }
98
105
 
99
106
  const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
100
- if (tmpl && store) {
101
- wrapTemplateRoot(tmpl, name, store);
107
+ if (tmpl) {
108
+ wrapTemplateRoot(tmpl, name);
102
109
  }
103
110
  cb(err, tmpl);
104
111
  } : undefined;
105
112
 
106
113
  const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
107
- if (res && store) {
108
- wrapTemplateRoot(res, name, store);
114
+ if (res) {
115
+ wrapTemplateRoot(res, name);
109
116
  }
110
117
  return res;
111
118
  };
@@ -467,10 +474,21 @@ function createApp(options = {}) {
467
474
  const app = express();
468
475
  app.serviceRegistry = serviceRegistry;
469
476
 
470
- // Services caller middleware
477
+ // Request context & services caller middleware
471
478
  app.use((req, res, next) => {
479
+ if (options.db) {
480
+ req.db = options.db;
481
+ }
482
+ req.context = {
483
+ req,
484
+ res,
485
+ db: options.db ?? null,
486
+ app,
487
+ serviceRegistry,
488
+ ...(req.context || {}),
489
+ };
472
490
  req.service = (name, input, opts) =>
473
- serviceRegistry.call(name, input, { req, res, db: options.db ?? null, ...(req.context || {}) }, opts);
491
+ serviceRegistry.call(name, input, req.context, opts);
474
492
  next();
475
493
  });
476
494
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
+ const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
9
10
 
10
11
  const VALID_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
11
12
 
@@ -50,7 +51,7 @@ function filePathToServiceName(relativePath) {
50
51
  }
51
52
 
52
53
  // Cross-platform slash normalization (Windows \ to Unix /)
53
- const normalized = relativePath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
54
+ const normalized = trimUrlPathSlashes(relativePath.replace(/\\/g, '/'));
54
55
  const ext = path.extname(normalized);
55
56
  const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
56
57
  const segments = withoutExt.split('/').filter(Boolean);
@@ -102,6 +102,7 @@ class ServiceRegistry {
102
102
  rawHandler: definition.handler,
103
103
  memoize: memoizeFn,
104
104
  metadata: definition.metadata || {},
105
+ description: definition.description || definition.metadata?.description || '',
105
106
  auth: definition.auth,
106
107
  cache: definition.cache,
107
108
  timeout: definition.timeout,
@@ -158,6 +159,7 @@ class ServiceRegistry {
158
159
  rawHandler: def.handler,
159
160
  memoize: memoizeFn,
160
161
  metadata: def.metadata || {},
162
+ description: def.description || def.metadata?.description || '',
161
163
  auth: def.auth,
162
164
  cache: def.cache,
163
165
  timeout: def.timeout,
@@ -4,60 +4,9 @@
4
4
  * @module src/services/validator
5
5
  */
6
6
 
7
- const z = require('zod');
8
7
  const { ValidationError } = require('../../core/errors');
9
- const { extendZ } = require('../../core/orm/utils/nanoid');
10
-
11
- /** Zod instance extended with z.nanoid() */
12
- const zForServices = extendZ(z);
13
-
14
- /**
15
- * Check if object contains dangerous prototype pollution keys
16
- * @param {*} obj
17
- * @returns {boolean}
18
- */
19
- function hasDangerousKeys(obj) {
20
- if (obj === null || typeof obj !== 'object') {
21
- return false;
22
- }
23
- if (Array.isArray(obj)) {
24
- return obj.some(item => hasDangerousKeys(item));
25
- }
26
- for (const key of Object.keys(obj)) {
27
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
28
- return true;
29
- }
30
- if (typeof obj[key] === 'object' && obj[key] !== null && hasDangerousKeys(obj[key])) {
31
- return true;
32
- }
33
- }
34
- return false;
35
- }
36
-
37
- /**
38
- * Sanitize input against prototype pollution
39
- * @param {*} input
40
- * @returns {*} Sanitized input
41
- */
42
- function sanitizeInput(input) {
43
- if (input === null || typeof input !== 'object') {
44
- return input;
45
- }
46
- if (!hasDangerousKeys(input)) {
47
- return input;
48
- }
49
- if (Array.isArray(input)) {
50
- return input.map(item => sanitizeInput(item));
51
- }
52
- const clean = {};
53
- for (const key of Object.keys(input)) {
54
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
55
- continue;
56
- }
57
- clean[key] = sanitizeInput(input[key]);
58
- }
59
- return clean;
60
- }
8
+ const { z: zForServices, sanitizeInput, hasDangerousKeys } = require('../../core/validation');
9
+ const z = zForServices;
61
10
 
62
11
  /**
63
12
  * Compile a concise type string descriptor into a Zod schema
@@ -4,8 +4,10 @@ Sections follow the source repo layout (`index.js`, `src/`, `core/orm`, `doc/ind
4
4
 
5
5
  ## 1. What this framework is
6
6
 
7
- - **SSR**: Express + **Nunjucks**; URLs map to files under `pages/`.
8
- - **API**: File-based handlers under `pages/api/` with method suffixes and optional **Zod** validation (`req.input`).
7
+ - **Architecture**: Modern full-stack Node.js framework with Next.js/Nuxt-style file-based routing.
8
+ - **SSR**: Native server-side rendering with **Nunjucks**; URLs map directly to files under `pages/`.
9
+ - **API**: 100% file-based handlers under `pages/api/` with HTTP method suffixes (`.get.js`, `.post.js`, `[id].delete.js`) and declarative **Zod** validation (`req.input`).
10
+ - ⚠️ **CRITICAL ANTI-PATTERN**: **NEVER** create a `routes/` or `src/routes/` directory. **NEVER** use `app.get()`, `app.post()`, or `express.Router()`. All application routes must live under `pages/` and `pages/api/`.
9
11
  - **i18n**: JSON locales; `t('key')` in templates.
10
12
  - **ORM**: Knex-backed layer in `core/orm` — `defineModel`, `zdb` schema helpers, repositories, query builder, migrations.
11
13
  - **Plugins**: Register in `createApp({ plugins })`; optional `db` passed as `ctx.db`.
@@ -60,7 +62,7 @@ project/
60
62
  | `pageAssets` | Opt-in **`true`** or **`{ enabled?, stylesheets?, scripts? }`**. When on, route **`load()`** may return reserved keys **`stylesheets`** (string or list; also `{ href, media? }` objects) and **`scripts`** (string, `{ src, defer?, async?, type? }`, or list). They are removed from the root Nunjucks context and passed as **`pageHead`** with **`pageAssets: true`**. The app layout must print them (see [`views/layout.njk`](../../../views/layout.njk) in the package). Default **off** — `stylesheets` / `scripts` in **`load()`** behave as normal data keys. |
61
63
  | `clientRuntime` | Opt-in **`{ alpine?: boolean \| object, swup?: boolean \| object }`**. Serves **`/__webspresso/client-runtime/*`** (Alpine 3, swup 4 + Head + Scripts plugins + bootstrap). Template context **`clientRuntime`**; include [`views/partials/webspresso-client-runtime.njk`](../../../views/partials/webspresso-client-runtime.njk) and set **`<main id="swup">`** when swup is on. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`** (`1` or `true`). Admin / dev dashboard HTML is unchanged (Mithril). Use **`data-no-swup`** on links for full page loads. HTMX is not used. |
62
64
  | `auth` | `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`webspresso/core/auth`). Mounts cookie parser + **`express-session`** + per-request **`authenticate`**; sets **`req.user`**, **`req.auth`**. Injects named route middleware **`auth`** and **`guest`** (overwrites same keys in `middlewares` if you passed both — avoid reusing those names for custom handlers). |
63
- | `setupRoutes(app, ctx)` | **Register custom Express routes here** — runs **after** file routes and plugins’ `onRoutesReady`, **before** 404. **`ctx.clientRuntime`** is the resolved flags. **`ctx.authMiddleware`** is set when `auth` was passed (guards: `requireAuth`, `requireGuest`, `requireCan`, `requireVerified`, …). Do not rely on `app.get` *after* `createApp` returns unless routes are appended before the 404 middleware (see [`src/server.js`](../../../src/server.js)). |
65
+ | `setupRoutes(app, ctx)` | Low-level server extension hook (e.g. mounting third-party sub-apps or raw middleware). ⚠️ **NEVER** use `setupRoutes` to define application domain or API routes. All application endpoints **MUST** live under `pages/` and `pages/api/`. |
64
66
 
65
67
  **Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is **`null`** when `auth` was not configured.
66
68
 
@@ -267,7 +269,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
267
269
 
268
270
  ## 14. Pitfalls (for agents)
269
271
 
270
- 1. **Custom Express routes** — use **`setupRoutes`**, not only `app.use` after `createApp` returns.
272
+ 1. **NEVER use Express routes (`app.get` / `routes/`)** — Webspresso is 100% file-based. Never create `routes/` or call `app.get()`. All routes must live in `pages/` and `pages/api/`.
271
273
  2. **File-router order** — understand precedence vs manual routes.
272
274
  3. **`ctx.db`** — only when `createApp({ db })` is set.
273
275
  4. **ORM hidden fields** — never expose to clients unintentionally.