webspresso 0.0.95 → 0.0.97
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/ADMIN.md +225 -0
- package/.agents/AGENTS.md +72 -0
- package/.agents/ARCHITECTURE.md +119 -0
- package/.agents/AUTH.md +103 -0
- package/.agents/CLI.md +115 -0
- package/.agents/COMPRESSION.md +59 -0
- package/.agents/CONVENTIONS.md +46 -0
- package/.agents/EMAIL.md +141 -0
- package/.agents/ERRORS.md +201 -0
- package/.agents/MCP.md +103 -0
- package/.agents/ORM.md +230 -0
- package/.agents/PLUGINS.md +419 -0
- package/.agents/QUEUE.md +113 -0
- package/.agents/REALTIME.md +213 -0
- package/.agents/ROUTING.md +292 -0
- package/.agents/SERVICES.md +341 -0
- package/.agents/SHUTDOWN.md +67 -0
- package/.agents/STREAMING.md +73 -0
- package/.agents/WORKFLOW.md +27 -0
- package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
- package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +209 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/new.js +13 -2
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +75 -89
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- package/core/auth/hash.js +10 -5
- package/core/compileSchema.js +1 -1
- package/core/errors/middleware.js +22 -2
- package/core/errors/normalize.js +29 -2
- package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
- package/index.d.ts +306 -0
- package/index.js +35 -1
- package/package.json +36 -14
- package/plugins/admin-panel/app.js +10 -9
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- package/plugins/admin-panel/core/api-extensions.js +4 -0
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/csrf/index.js +3 -2
- package/plugins/csv/engine.js +447 -0
- package/plugins/csv/index.js +94 -0
- package/plugins/csv/middleware.js +80 -0
- package/plugins/csv/services.js +138 -0
- package/plugins/file-manager/admin-component.js +613 -0
- package/plugins/file-manager/api-handlers.js +261 -0
- package/plugins/file-manager/core.js +601 -0
- package/plugins/file-manager/index.js +139 -0
- package/plugins/file-manager/security.js +217 -0
- package/plugins/index.js +45 -79
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/site-analytics/admin-component.js +7 -8
- package/plugins/xlsx/engine.js +397 -0
- package/plugins/xlsx/index.js +91 -0
- package/plugins/xlsx/middleware.js +87 -0
- package/plugins/xlsx/services.js +118 -0
- package/src/api/api-loader.js +233 -0
- package/src/api/define-api.js +35 -0
- package/src/discovery/file-route-parser.js +201 -0
- package/src/discovery/scan-routes.js +265 -0
- package/src/file-router.js +9 -1
- package/src/modules/define-module.js +99 -0
- package/src/modules/module-discovery.js +117 -0
- package/src/pages/define-page.js +36 -0
- package/src/pages/page-loader.js +214 -0
- package/src/routing/mount-discovered-routes.js +78 -0
- package/src/routing/route-table.js +191 -0
- package/src/server.js +112 -15
- package/src/services/builtins/file-manager.js +178 -0
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -11
- package/bin/commands/skill.js +0 -187
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
- 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 =
|
|
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
|
-
//
|
|
752
|
-
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
//
|
|
768
|
-
|
|
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
|
|
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 = {}) {
|