webspresso 0.0.95 → 0.0.96

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 (73) hide show
  1. package/.agents/ADMIN.md +225 -0
  2. package/.agents/AGENTS.md +72 -0
  3. package/.agents/ARCHITECTURE.md +119 -0
  4. package/.agents/AUTH.md +103 -0
  5. package/.agents/CLI.md +115 -0
  6. package/.agents/COMPRESSION.md +59 -0
  7. package/.agents/CONVENTIONS.md +46 -0
  8. package/.agents/EMAIL.md +141 -0
  9. package/.agents/ERRORS.md +201 -0
  10. package/.agents/MCP.md +103 -0
  11. package/.agents/ORM.md +230 -0
  12. package/.agents/PLUGINS.md +419 -0
  13. package/.agents/QUEUE.md +113 -0
  14. package/.agents/REALTIME.md +213 -0
  15. package/.agents/ROUTING.md +292 -0
  16. package/.agents/SERVICES.md +341 -0
  17. package/.agents/SHUTDOWN.md +67 -0
  18. package/.agents/STREAMING.md +73 -0
  19. package/.agents/WORKFLOW.md +27 -0
  20. package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
  21. package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
  22. package/.cursorrules +14 -0
  23. package/AGENTS.md +175 -0
  24. package/CLAUDE.md +54 -0
  25. package/README.md +18 -14
  26. package/bin/commands/agents-init.js +169 -0
  27. package/bin/commands/api.js +114 -48
  28. package/bin/commands/doctor.js +35 -0
  29. package/bin/commands/new.js +13 -2
  30. package/bin/commands/page.js +75 -89
  31. package/bin/commands/service.js +77 -0
  32. package/bin/webspresso.js +2 -2
  33. package/core/compileSchema.js +1 -1
  34. package/core/errors/middleware.js +22 -2
  35. package/core/errors/normalize.js +29 -2
  36. package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
  37. package/index.d.ts +306 -0
  38. package/index.js +35 -1
  39. package/package.json +5 -3
  40. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
  41. package/plugins/csrf/index.js +3 -2
  42. package/plugins/csv/engine.js +447 -0
  43. package/plugins/csv/index.js +94 -0
  44. package/plugins/csv/middleware.js +80 -0
  45. package/plugins/csv/services.js +138 -0
  46. package/plugins/file-manager/admin-component.js +613 -0
  47. package/plugins/file-manager/api-handlers.js +261 -0
  48. package/plugins/file-manager/core.js +601 -0
  49. package/plugins/file-manager/index.js +139 -0
  50. package/plugins/file-manager/security.js +207 -0
  51. package/plugins/index.js +8 -0
  52. package/plugins/xlsx/engine.js +397 -0
  53. package/plugins/xlsx/index.js +91 -0
  54. package/plugins/xlsx/middleware.js +87 -0
  55. package/plugins/xlsx/services.js +118 -0
  56. package/src/api/api-loader.js +233 -0
  57. package/src/api/define-api.js +35 -0
  58. package/src/discovery/file-route-parser.js +201 -0
  59. package/src/discovery/scan-routes.js +265 -0
  60. package/src/file-router.js +6 -0
  61. package/src/modules/define-module.js +99 -0
  62. package/src/modules/module-discovery.js +117 -0
  63. package/src/pages/define-page.js +36 -0
  64. package/src/pages/page-loader.js +214 -0
  65. package/src/routing/mount-discovered-routes.js +78 -0
  66. package/src/routing/route-table.js +191 -0
  67. package/src/server.js +112 -15
  68. package/src/services/builtins/file-manager.js +178 -0
  69. package/src/services/index.js +2 -0
  70. package/bin/commands/skill.js +0 -187
  71. package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
  72. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
  73. package/templates/skills/webspresso-usage/SKILL.md +0 -48
@@ -0,0 +1,214 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Page Loader
5
+ * Handles execution of definePage() lifecycle and renders page output
6
+ * @module src/pages/page-loader
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { createHelpers } = require('../helpers');
12
+ const { loadI18n, createTranslator, detectLocale, resolveMiddlewares } = require('../file-router');
13
+
14
+ const EMPTY_ARRAY = Object.freeze([]);
15
+
16
+ /**
17
+ * Resolves named and functional middlewares with module-level precedence
18
+ * @param {Array<string|Function>} middlewareList
19
+ * @param {Object} [globalMiddlewares={}]
20
+ * @param {Object} [moduleMiddlewares={}]
21
+ * @returns {Array<Function>}
22
+ */
23
+ function resolveMiddlewaresWithModule(middlewareList, globalMiddlewares = {}, moduleMiddlewares = {}) {
24
+ const combined = Object.assign({}, globalMiddlewares, moduleMiddlewares);
25
+ return resolveMiddlewares(middlewareList, combined);
26
+ }
27
+
28
+ /**
29
+ * Creates an Express route handler for a discovered Page descriptor
30
+ * @param {Object} descriptor - Route descriptor
31
+ * @param {Object} context - Server execution context
32
+ * @returns {Function} Express request handler
33
+ */
34
+ function createPageHandler(descriptor, context) {
35
+ const {
36
+ nunjucks,
37
+ middlewares = {},
38
+ serviceRegistry = null,
39
+ db = null,
40
+ options: appOptions = {},
41
+ } = context;
42
+
43
+ const isDev = process.env.NODE_ENV === 'development';
44
+ const pagesDir = path.dirname(descriptor.file);
45
+ const njkCandidate = descriptor.file.endsWith('.njk')
46
+ ? descriptor.file
47
+ : descriptor.file.replace(/\.js$/, '.njk');
48
+
49
+ let hasNjkTemplate = !isDev ? fs.existsSync(njkCandidate) : null;
50
+ let cachedMiddlewares = null;
51
+ let cachedPageDef = null;
52
+
53
+ return async (req, res, next) => {
54
+ try {
55
+ // 1. Load page definition (with hot reload in dev)
56
+ if (isDev && descriptor.file.endsWith('.js')) {
57
+ try {
58
+ const resolvedPath = require.resolve(descriptor.file);
59
+ if (require.cache[resolvedPath]) {
60
+ delete require.cache[resolvedPath];
61
+ cachedMiddlewares = null;
62
+ cachedPageDef = null;
63
+ }
64
+ } catch {}
65
+ }
66
+
67
+ let pageDef = cachedPageDef;
68
+ if (!pageDef) {
69
+ let pageModule = {};
70
+ if (descriptor.file.endsWith('.js')) {
71
+ try {
72
+ pageModule = require(descriptor.file);
73
+ } catch (loadErr) {
74
+ return next(loadErr);
75
+ }
76
+ }
77
+
78
+ pageDef = typeof pageModule === 'function' ? { load: pageModule } : pageModule.default || pageModule;
79
+ if (!isDev) {
80
+ cachedPageDef = pageDef;
81
+ }
82
+ }
83
+
84
+ // 2. Resolve middlewares (module-local first, cached across requests)
85
+ if (!cachedMiddlewares) {
86
+ const moduleMiddlewares = descriptor.moduleConfig?.middlewares || {};
87
+ const combinedRegistry = Object.assign({}, middlewares, moduleMiddlewares);
88
+ cachedMiddlewares = resolveMiddlewares(pageDef.middleware, combinedRegistry);
89
+ }
90
+
91
+ // Run middlewares in sequence
92
+ for (const mw of cachedMiddlewares) {
93
+ if (typeof mw !== 'function') continue;
94
+ await new Promise((resolve, reject) => {
95
+ try {
96
+ mw(req, res, (err) => {
97
+ if (err) reject(err);
98
+ else resolve();
99
+ });
100
+ } catch (syncErr) {
101
+ reject(syncErr);
102
+ }
103
+ });
104
+ if (res.headersSent) return;
105
+ }
106
+
107
+ // 3. Build Page Context
108
+ const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
109
+ const locale = detectLocale(req);
110
+ const fsy = createHelpers({ req, res, baseUrl, locale });
111
+
112
+ // i18n
113
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
114
+ const translations = loadI18n(pagesDir, '', locale, isDev);
115
+ const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, isDev) : undefined;
116
+ const t = createTranslator(translations, {
117
+ locale,
118
+ fallbackTranslations,
119
+ fallbackLocale: defaultLocale,
120
+ });
121
+
122
+ const pageCtx = {
123
+ req,
124
+ res,
125
+ params: req.params || {},
126
+ query: req.query || {},
127
+ service: (name, input, opts) =>
128
+ serviceRegistry ? serviceRegistry.call(name, input, { req, res, db }, opts) : null,
129
+ module: descriptor.module || null,
130
+ config: appOptions,
131
+ logger: console,
132
+ redirect: (url, status = 302) => {
133
+ res.redirect(status, url);
134
+ return { __redirected: true };
135
+ },
136
+ error: (status = 500, message = 'Page Error') => {
137
+ const err = new Error(message);
138
+ err.status = status;
139
+ throw err;
140
+ },
141
+ fsy,
142
+ locale,
143
+ t,
144
+ db,
145
+ };
146
+
147
+ // 4. Run load() lifecycle
148
+ let loadedData = {};
149
+ if (typeof pageDef.load === 'function') {
150
+ loadedData = (await pageDef.load(pageCtx)) || {};
151
+ if (loadedData.__redirected || res.headersSent) {
152
+ return;
153
+ }
154
+ }
155
+
156
+ // 5. Run head() lifecycle
157
+ let headData = {};
158
+ if (typeof pageDef.head === 'function') {
159
+ headData = (await pageDef.head(loadedData, pageCtx)) || {};
160
+ }
161
+
162
+ // 6. Check for companion Nunjucks template (cached in production)
163
+ const templateExists = hasNjkTemplate !== null ? hasNjkTemplate : fs.existsSync(njkCandidate);
164
+
165
+ if (templateExists && nunjucks) {
166
+ const templateData = {
167
+ ...loadedData,
168
+ head: headData,
169
+ pageHead: headData,
170
+ fsy,
171
+ locale,
172
+ t,
173
+ params: req.params,
174
+ query: req.query,
175
+ };
176
+ return nunjucks.render(njkCandidate, templateData, (err, html) => {
177
+ if (err) return next(err);
178
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
179
+ return res.send(html);
180
+ });
181
+ }
182
+
183
+ // 7. Render via definePage render() method
184
+ if (typeof pageDef.render === 'function') {
185
+ const html = await pageDef.render(loadedData, pageCtx);
186
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
187
+ return res.send(html);
188
+ }
189
+
190
+ // 8. Fallback: if json or data
191
+ if (typeof loadedData === 'object' && loadedData !== null) {
192
+ return res.json(loadedData);
193
+ }
194
+
195
+ return res.send(String(loadedData));
196
+ } catch (err) {
197
+ if (err && typeof err === 'object') {
198
+ if (!err.route) err.route = descriptor.path;
199
+ if (!err.method) err.method = descriptor.method ? descriptor.method.toUpperCase() : (req.method || 'GET');
200
+ if (!err.source) err.source = descriptor.source || descriptor.file;
201
+ if (!err.file) err.file = descriptor.file;
202
+ if (!err.module) err.module = descriptor.module || null;
203
+ if (!err.phase) err.phase = 'page';
204
+ if (!err.requestId) err.requestId = req.id || (req.headers && req.headers['x-request-id']) || null;
205
+ }
206
+ return next(err);
207
+ }
208
+ };
209
+ }
210
+
211
+ module.exports = {
212
+ createPageHandler,
213
+ resolveMiddlewaresWithModule,
214
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Route Mounting Layer
5
+ * Binds compiled RouteTable descriptors to Express routes with static/dynamic phase separation
6
+ * @module src/routing/mount-discovered-routes
7
+ */
8
+
9
+ const { createPageHandler } = require('../pages/page-loader');
10
+ const { createApiHandler } = require('../api/api-loader');
11
+ const { getRouteSortMeta } = require('./route-table');
12
+
13
+ const NOOP = () => {};
14
+
15
+ /**
16
+ * Mounts discovered routes onto the Express application
17
+ * @param {import('express').Application} app
18
+ * @param {import('./route-table').RouteTable} routeTable
19
+ * @param {Object} context - Server context
20
+ * @returns {{ routeMetadata: Array, registerDynamicDiscoveredRoutes: Function }}
21
+ */
22
+ function mountDiscoveredRoutes(app, routeTable, context) {
23
+ const { silent = false } = context;
24
+ const log = silent ? NOOP : console.log.bind(console);
25
+
26
+ const staticRoutes = [];
27
+ const dynamicRoutes = [];
28
+ const routeMetadata = [];
29
+
30
+ for (const descriptor of routeTable.routes) {
31
+ const sortMeta = descriptor._sortMeta || getRouteSortMeta(descriptor.path);
32
+ if (sortMeta.tier === 0) {
33
+ staticRoutes.push(descriptor);
34
+ } else {
35
+ dynamicRoutes.push(descriptor);
36
+ }
37
+
38
+ routeMetadata.push({
39
+ file: descriptor.source || descriptor.file,
40
+ method: descriptor.method.toUpperCase(),
41
+ path: descriptor.path,
42
+ type: descriptor.type,
43
+ module: descriptor.module || null,
44
+ });
45
+ }
46
+
47
+ function bindRoute(descriptor) {
48
+ const method = descriptor.method.toLowerCase();
49
+ const handler =
50
+ descriptor.type === 'page'
51
+ ? createPageHandler(descriptor, context)
52
+ : createApiHandler(descriptor, context);
53
+
54
+ app[method](descriptor.path, handler);
55
+ log(` ${descriptor.method.toUpperCase().padEnd(6)} ${descriptor.path} -> ${descriptor.source || descriptor.file}`);
56
+ }
57
+
58
+ // Phase 1: Mount static routes immediately
59
+ for (const r of staticRoutes) {
60
+ bindRoute(r);
61
+ }
62
+
63
+ // Phase 2: Deferred function to mount dynamic and catch-all routes after plugins
64
+ function registerDynamicDiscoveredRoutes() {
65
+ for (const r of dynamicRoutes) {
66
+ bindRoute(r);
67
+ }
68
+ }
69
+
70
+ return {
71
+ routeMetadata,
72
+ registerDynamicDiscoveredRoutes,
73
+ };
74
+ }
75
+
76
+ module.exports = {
77
+ mountDiscoveredRoutes,
78
+ };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso Route Table & Compiler
5
+ * Compiles route descriptors, performs conflict checks, sorts deterministically, and provides introspection
6
+ * @module src/routing/route-table
7
+ */
8
+
9
+ /**
10
+ * Computes route sorting priority metadata
11
+ * @param {string} routePath
12
+ * @returns {{ tier: number, literalSegCount: number, paramSegCount: number, depth: number, routePath: string }}
13
+ */
14
+ function getRouteSortMeta(routePath) {
15
+ let hasStar = false;
16
+ let hasColon = false;
17
+ let depth = 0;
18
+ let literalSegCount = 0;
19
+ let paramSegCount = 0;
20
+
21
+ const clean = routePath.split('?')[0];
22
+ const segments = clean.split('/').filter(Boolean);
23
+
24
+ for (const seg of segments) {
25
+ depth++;
26
+ if (seg.includes('*')) {
27
+ hasStar = true;
28
+ } else if (seg.includes(':')) {
29
+ hasColon = true;
30
+ paramSegCount++;
31
+ } else {
32
+ literalSegCount++;
33
+ }
34
+ }
35
+
36
+ // Tier 0: Pure static (/about, /users)
37
+ // Tier 1: Parameterized (/users/:id)
38
+ // Tier 2: Catch-all (/docs/*)
39
+ let tier = 0;
40
+ if (hasStar) tier = 2;
41
+ else if (hasColon) tier = 1;
42
+
43
+ return {
44
+ tier,
45
+ literalSegCount,
46
+ paramSegCount,
47
+ depth,
48
+ routePath,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Comparator for deterministic route registration order
54
+ * @param {Object} a - Route descriptor
55
+ * @param {Object} b - Route descriptor
56
+ * @returns {number}
57
+ */
58
+ function compareRouteOrder(a, b) {
59
+ const ma = a._sortMeta || getRouteSortMeta(a.path);
60
+ const mb = b._sortMeta || getRouteSortMeta(b.path);
61
+
62
+ if (ma.tier !== mb.tier) return ma.tier - mb.tier;
63
+ if (ma.literalSegCount !== mb.literalSegCount) return mb.literalSegCount - ma.literalSegCount;
64
+ if (ma.depth !== mb.depth) return mb.depth - ma.depth;
65
+ if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
66
+ return ma.routePath.localeCompare(mb.routePath);
67
+ }
68
+
69
+ /**
70
+ * Immutable Route Table representing all compiled application routes
71
+ */
72
+ class RouteTable {
73
+ /**
74
+ * @param {Array<Object>} routes - Sorted, validated route descriptors
75
+ */
76
+ constructor(routes = []) {
77
+ this.routes = Object.freeze([...routes]);
78
+ this._map = new Map();
79
+
80
+ for (const r of this.routes) {
81
+ const key = `${r.method.toUpperCase()} ${r.path}`;
82
+ this._map.set(key, r);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Returns a copy of route introspection metadata
88
+ * @returns {Array<{ method: string, path: string, type: string, module: string|null, source: string, description?: string, tags?: string[] }>}
89
+ */
90
+ list() {
91
+ return this.routes.map((r) => ({
92
+ method: r.method.toUpperCase(),
93
+ path: r.path,
94
+ type: r.type,
95
+ module: r.module || null,
96
+ source: r.source || r.file,
97
+ description: r.description,
98
+ tags: r.tags,
99
+ }));
100
+ }
101
+
102
+ /**
103
+ * Retrieves a route descriptor by method and path
104
+ * @param {string} method
105
+ * @param {string} path
106
+ * @returns {Object|undefined}
107
+ */
108
+ get(method, path) {
109
+ return this._map.get(`${method.toUpperCase()} ${path}`);
110
+ }
111
+
112
+ /**
113
+ * Checks if a route exists in the table
114
+ * @param {string} method
115
+ * @param {string} path
116
+ * @returns {boolean}
117
+ */
118
+ has(method, path) {
119
+ return this._map.has(`${method.toUpperCase()} ${path}`);
120
+ }
121
+
122
+ /**
123
+ * Returns the count of routes
124
+ * @returns {number}
125
+ */
126
+ get size() {
127
+ return this.routes.length;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Compiles discovered route descriptors into an immutable RouteTable
133
+ * @param {Array<Object>} descriptors - Discovered route descriptors
134
+ * @param {Object} [options]
135
+ * @param {Array<Object>} [options.explicitRoutes=[]] - Manual user-configured routes
136
+ * @param {boolean} [options.isDev=false] - Development mode flag
137
+ * @returns {RouteTable}
138
+ */
139
+ function compileRouteTable(descriptors = [], options = {}) {
140
+ const { explicitRoutes = [], isDev = process.env.NODE_ENV !== 'production' } = options;
141
+
142
+ const routeMap = new Map();
143
+ const explicitKeys = new Set(
144
+ explicitRoutes.map((r) => `${(r.method || 'GET').toUpperCase()} ${r.path}`)
145
+ );
146
+
147
+ const safeDescriptors = Array.isArray(descriptors) ? descriptors : [];
148
+ for (const descriptor of safeDescriptors) {
149
+ if (!descriptor || typeof descriptor !== 'object' || !descriptor.path || !descriptor.method) continue;
150
+
151
+ const method = descriptor.method.toUpperCase();
152
+ const key = `${method} ${descriptor.path}`;
153
+
154
+ // 1. Check if overridden by explicit route
155
+ if (explicitKeys.has(key)) {
156
+ if (isDev) {
157
+ console.warn(`[webspresso] Generated route ${key} was overridden by an explicit route.`);
158
+ }
159
+ continue;
160
+ }
161
+
162
+ // 2. Conflict detection: check for duplicate route registrations
163
+ if (routeMap.has(key)) {
164
+ const existing = routeMap.get(key);
165
+ const existingSrc = existing.source || existing.file;
166
+ const currentSrc = descriptor.source || descriptor.file;
167
+
168
+ throw new Error(
169
+ `Route conflict detected:\n\n` +
170
+ `${key}\n\n` +
171
+ ` ${existingSrc}\n` +
172
+ ` ${currentSrc}\n`
173
+ );
174
+ }
175
+
176
+ descriptor._sortMeta = getRouteSortMeta(descriptor.path);
177
+ routeMap.set(key, descriptor);
178
+ }
179
+
180
+ // Sort routes deterministically
181
+ const sortedRoutes = Array.from(routeMap.values()).sort(compareRouteOrder);
182
+
183
+ return new RouteTable(sortedRoutes);
184
+ }
185
+
186
+ module.exports = {
187
+ RouteTable,
188
+ compileRouteTable,
189
+ compareRouteOrder,
190
+ getRouteSortMeta,
191
+ };
package/src/server.js CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const crypto = require('crypto');
8
9
  const { AsyncLocalStorage } = require('async_hooks');
9
10
  const express = require('express');
10
11
  const helmet = require('helmet');
@@ -18,6 +19,10 @@ const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file
18
19
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
19
20
  const { createPluginManager } = require('./plugin-manager');
20
21
  const { createServiceRegistry } = require('./services');
22
+ const { scanRoutes } = require('./discovery/scan-routes');
23
+ const { compileRouteTable } = require('./routing/route-table');
24
+ const { mountDiscoveredRoutes } = require('./routing/mount-discovered-routes');
25
+ const { discoverModuleDetails } = require('./modules/module-discovery');
21
26
  const { ShutdownManager, NodeHttpAdapter } = require('../core/shutdown');
22
27
  const { createCompressionMiddleware } = require('../core/compression');
23
28
  const {
@@ -390,7 +395,7 @@ function createApp(options = {}) {
390
395
  const isTest = NODE_ENV === 'test';
391
396
 
392
397
  const {
393
- pagesDir,
398
+ pagesDir: initialPagesDir,
394
399
  viewsDir,
395
400
  publicDir,
396
401
  logging = isDev && !isTest,
@@ -404,6 +409,26 @@ function createApp(options = {}) {
404
409
  setupRoutes,
405
410
  } = options;
406
411
 
412
+ if (!initialPagesDir && !options.pages && !options.api && !options.modules && !options.routes && !options.rootDir) {
413
+ throw new Error('pagesDir is required');
414
+ }
415
+
416
+ const rootDir = options.rootDir || process.cwd();
417
+ let pagesDir = initialPagesDir;
418
+ if (!pagesDir) {
419
+ if (typeof options.pages === 'string') {
420
+ pagesDir = path.isAbsolute(options.pages) ? options.pages : path.join(rootDir, options.pages);
421
+ } else if (typeof options.pages === 'object' && options.pages?.dir) {
422
+ pagesDir = path.isAbsolute(options.pages.dir) ? options.pages.dir : path.join(rootDir, options.pages.dir);
423
+ } else if (fs.existsSync(path.join(rootDir, 'src/pages'))) {
424
+ pagesDir = path.join(rootDir, 'src/pages');
425
+ } else if (fs.existsSync(path.join(rootDir, 'pages'))) {
426
+ pagesDir = path.join(rootDir, 'pages');
427
+ } else {
428
+ pagesDir = path.join(rootDir, 'pages');
429
+ }
430
+ }
431
+
407
432
  // Create plugin manager
408
433
  const pluginManager = createPluginManager();
409
434
 
@@ -413,10 +438,6 @@ function createApp(options = {}) {
413
438
  ...assetsConfig
414
439
  });
415
440
 
416
- if (!pagesDir) {
417
- throw new Error('pagesDir is required');
418
- }
419
-
420
441
  const clientRuntime = resolveClientRuntime(options);
421
442
 
422
443
  const shutdownConfig = {
@@ -474,6 +495,14 @@ function createApp(options = {}) {
474
495
  const app = express();
475
496
  app.serviceRegistry = serviceRegistry;
476
497
 
498
+ // Correlation Request ID middleware
499
+ app.use((req, res, next) => {
500
+ const reqId = (req.headers && req.headers['x-request-id']) ? req.headers['x-request-id'] : crypto.randomUUID();
501
+ req.id = reqId;
502
+ res.setHeader('X-Request-Id', reqId);
503
+ next();
504
+ });
505
+
477
506
  // Request context & services caller middleware
478
507
  app.use((req, res, next) => {
479
508
  if (options.db) {
@@ -685,7 +714,10 @@ function createApp(options = {}) {
685
714
  mountClientRuntime(app, clientRuntime);
686
715
 
687
716
  // Configure Nunjucks with viewsDir priority and circular extension guard
688
- const templateDirs = viewsDir ? [viewsDir, pagesDir] : [pagesDir];
717
+ const templateDirs = [viewsDir, pagesDir].filter(Boolean);
718
+ if (templateDirs.length === 0) {
719
+ templateDirs.push(path.join(rootDir, 'pages'));
720
+ }
689
721
 
690
722
  const nunjucksEnv = configureSafeNunjucks(templateDirs, {
691
723
  autoescape: true,
@@ -731,7 +763,33 @@ function createApp(options = {}) {
731
763
  }
732
764
  return d.toString();
733
765
  });
734
-
766
+
767
+ // Auto-discover module services and middlewares if modules directory exists
768
+ const modulesDirCandidate = options.modules?.dir
769
+ ? (path.isAbsolute(options.modules.dir) ? options.modules.dir : path.join(rootDir, options.modules.dir))
770
+ : (fs.existsSync(path.join(rootDir, 'src/modules')) ? path.join(rootDir, 'src/modules') : (fs.existsSync(path.join(rootDir, 'modules')) ? path.join(rootDir, 'modules') : null));
771
+
772
+ if (modulesDirCandidate && fs.existsSync(modulesDirCandidate) && options.modules !== false) {
773
+ try {
774
+ const entries = fs.readdirSync(modulesDirCandidate, { withFileTypes: true });
775
+ for (const entry of entries) {
776
+ if (entry.isDirectory() && !entry.name.startsWith('_') && !entry.name.startsWith('.')) {
777
+ const details = discoverModuleDetails(path.join(modulesDirCandidate, entry.name), entry.name);
778
+ for (const [sName, sDef] of Object.entries(details.services)) {
779
+ if (!serviceRegistry.has(sName)) {
780
+ serviceRegistry.register(sName, sDef);
781
+ }
782
+ }
783
+ for (const [mName, mFn] of Object.entries(details.middlewares)) {
784
+ middlewares[`${entry.name}.${mName}`] = mFn;
785
+ }
786
+ }
787
+ }
788
+ } catch (mErr) {
789
+ console.warn('[webspresso] Warning discovering modules:', mErr.message);
790
+ }
791
+ }
792
+
735
793
  // Register plugins (sync) — middlewares is the same object later passed to mountPages
736
794
  const pluginContext = { app, nunjucksEnv, options, middlewares, shutdownManager };
737
795
  pluginManager.registerSync(plugins, pluginContext);
@@ -748,12 +806,29 @@ function createApp(options = {}) {
748
806
  });
749
807
  }
750
808
 
751
- // Mount file-based routes
752
- if (!isTest) {
809
+ // 1. Scan and compile discovered routes (src/pages, src/api, modules/*)
810
+ const discoveredDescriptors = scanRoutes({
811
+ rootDir,
812
+ pages: options.pages !== undefined ? options.pages : (options.pagesDir ? { dir: options.pagesDir } : true),
813
+ api: options.api,
814
+ modules: options.modules,
815
+ });
816
+
817
+ const routeTable = compileRouteTable(discoveredDescriptors, {
818
+ explicitRoutes: options.routes || [],
819
+ isDev,
820
+ });
821
+
822
+ // Expose RouteTable on app instance
823
+ app.routes = routeTable;
824
+ app.routeTable = routeTable;
825
+
826
+ if (!isTest && routeTable.size > 0) {
753
827
  console.log('\nMounting routes:');
754
828
  }
755
- const { routeMetadata, registerDynamicFileRoutes } = mountPages(app, {
756
- pagesDir,
829
+
830
+ // Mount discovered routes
831
+ const { routeMetadata: discoveredMeta, registerDynamicDiscoveredRoutes } = mountDiscoveredRoutes(app, routeTable, {
757
832
  nunjucks: nunjucksEnv,
758
833
  middlewares,
759
834
  pluginManager,
@@ -762,10 +837,32 @@ function createApp(options = {}) {
762
837
  clientRuntime,
763
838
  pageAssets: options.pageAssets,
764
839
  serviceRegistry,
840
+ options,
765
841
  });
766
842
 
767
- // Set route metadata in plugin manager
768
- pluginManager.setRoutes(routeMetadata);
843
+ // 2. Mount classic file-based routes for backwards compatibility
844
+ let classicRouteMeta = [];
845
+ let registerDynamicFileRoutes = () => {};
846
+ if (pagesDir && fs.existsSync(path.resolve(pagesDir))) {
847
+ const classicMount = mountPages(app, {
848
+ pagesDir,
849
+ nunjucks: nunjucksEnv,
850
+ middlewares,
851
+ pluginManager,
852
+ silent: isTest,
853
+ db: options.db ?? null,
854
+ clientRuntime,
855
+ pageAssets: options.pageAssets,
856
+ serviceRegistry,
857
+ skipExistingRoutes: routeTable,
858
+ });
859
+ classicRouteMeta = classicMount.routeMetadata || [];
860
+ registerDynamicFileRoutes = classicMount.registerDynamicFileRoutes || (() => {});
861
+ }
862
+
863
+ // Combine route metadata for plugin manager
864
+ const allRouteMeta = [...discoveredMeta, ...classicRouteMeta];
865
+ pluginManager.setRoutes(allRouteMeta);
769
866
 
770
867
  // Call onRoutesReady hook synchronously (plugins should not be async in this phase)
771
868
  // and mount any custom routes added by plugins
@@ -808,9 +905,9 @@ function createApp(options = {}) {
808
905
  });
809
906
  }
810
907
 
811
- // Dynamic / catch-all file routes after plugins and setupRoutes so paths like /_admin
812
- // or custom /login are not shadowed by pages/[slug].njk (/:slug).
908
+ // Dynamic / catch-all file routes after plugins and setupRoutes
813
909
  registerDynamicFileRoutes();
910
+ registerDynamicDiscoveredRoutes();
814
911
 
815
912
  // Helper to create error page context with fsy
816
913
  function createErrorContext(req, extraData = {}) {