webspresso 0.0.94 → 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.
- 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/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/agents-init.js +169 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/new.js +13 -2
- package/bin/commands/page.js +75 -89
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- 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 +19 -6
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- 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 +207 -0
- package/plugins/index.js +8 -0
- package/plugins/polar/CHANGELOG.md +18 -0
- package/plugins/polar/README.md +30 -7
- package/plugins/polar/index.js +6 -1
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/config.js +2 -0
- package/plugins/polar/src/rate-limit.js +96 -28
- package/plugins/polar/src/routes.js +3 -2
- package/plugins/polar/src/urls.js +26 -2
- package/plugins/polar/src/user-resolver.js +5 -1
- package/plugins/rate-limit/index.js +5 -1
- 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 +6 -0
- 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/index.js +2 -0
- 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,201 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso File Route Parser
|
|
5
|
+
* Converts relative filesystem paths into Express route paths and HTTP methods
|
|
6
|
+
* @module src/discovery/file-route-parser
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const SUPPORTED_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head']);
|
|
13
|
+
const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
|
|
14
|
+
const METHOD_SUFFIX_REGEX = /\.([a-zA-Z]+)\.js$/;
|
|
15
|
+
const TEMPORARY_FILE_REGEX = /(?:~|\.swp|\.tmp|\.bak|\.DS_Store)$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Safely scans a directory recursively for route files
|
|
19
|
+
* @param {string} dirPath - Absolute directory path
|
|
20
|
+
* @param {string} [baseDir=dirPath] - Base directory for computing relative paths
|
|
21
|
+
* @returns {Array<{ absolutePath: string, relativePath: string }>}
|
|
22
|
+
*/
|
|
23
|
+
function scanDirSafely(dirPath, baseDir = dirPath) {
|
|
24
|
+
const results = [];
|
|
25
|
+
if (!fs.existsSync(dirPath)) return results;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
29
|
+
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const name = entry.name;
|
|
32
|
+
if (isPrivateOrIgnored(name)) continue;
|
|
33
|
+
|
|
34
|
+
const fullPath = path.join(dirPath, name);
|
|
35
|
+
const relPath = path.relative(baseDir, fullPath);
|
|
36
|
+
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
results.push(...scanDirSafely(fullPath, baseDir));
|
|
39
|
+
} else if (entry.isFile()) {
|
|
40
|
+
const ext = path.extname(name);
|
|
41
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
42
|
+
results.push({
|
|
43
|
+
absolutePath: fullPath,
|
|
44
|
+
relativePath: relPath,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`[webspresso] Failed to scan directory ${dirPath}:`, err.message);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Checks if a filename or directory name should be ignored (private or temporary)
|
|
58
|
+
* @param {string} name - File or directory basename
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function isPrivateOrIgnored(name) {
|
|
62
|
+
if (!name || typeof name !== 'string') return true;
|
|
63
|
+
// Hidden files/directories (.git, .DS_Store, .login.js.swp)
|
|
64
|
+
if (name.startsWith('.')) return true;
|
|
65
|
+
// Private files/directories (_components, _helpers, _private.js)
|
|
66
|
+
if (name.startsWith('_')) return true;
|
|
67
|
+
// Temporary editor files
|
|
68
|
+
if (TEMPORARY_FILE_REGEX.test(name)) return true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Converts parameter markers [id] -> :id and catch-all [...path] -> *
|
|
74
|
+
* @param {string} segment
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function transformDynamicSegment(segment) {
|
|
78
|
+
if (!segment || typeof segment !== 'string') return segment || '';
|
|
79
|
+
if (segment.startsWith('[...') && segment.endsWith(']')) {
|
|
80
|
+
const paramName = segment.slice(4, -1).trim();
|
|
81
|
+
return paramName ? `*${paramName}` : '*';
|
|
82
|
+
}
|
|
83
|
+
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
84
|
+
const paramName = segment.slice(1, -1).trim();
|
|
85
|
+
return paramName ? `:${paramName}` : segment;
|
|
86
|
+
}
|
|
87
|
+
return segment;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Normalizes a URL prefix to have a leading slash and no trailing slash
|
|
92
|
+
* @param {string} [prefix='']
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function normalizePrefix(prefix = '') {
|
|
96
|
+
if (!prefix || typeof prefix !== 'string') return '';
|
|
97
|
+
let clean = prefix.trim().replace(/\\/g, '/');
|
|
98
|
+
clean = clean.replace(/\/+/g, '/');
|
|
99
|
+
if (clean === '/' || !clean) return '';
|
|
100
|
+
if (!clean.startsWith('/')) clean = '/' + clean;
|
|
101
|
+
if (clean.endsWith('/')) clean = clean.slice(0, -1);
|
|
102
|
+
return clean === '/' ? '' : clean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses a relative file path for an API or Page route
|
|
107
|
+
* @param {string} relativePath - Relative path from directory root (e.g. 'users/[id].get.js' or 'blog/[slug].js')
|
|
108
|
+
* @param {Object} [options]
|
|
109
|
+
* @param {string} [options.type='page'] - 'page' | 'api'
|
|
110
|
+
* @param {string} [options.prefix=''] - Base prefix (e.g. '/api' or '/auth')
|
|
111
|
+
* @returns {{ path: string, method: string, isValid: boolean, isPrivate: boolean, originalMethodPart?: string }}
|
|
112
|
+
*/
|
|
113
|
+
function parseFileRoute(relativePath, options = {}) {
|
|
114
|
+
if (!relativePath || typeof relativePath !== 'string') {
|
|
115
|
+
return { path: '', method: '', isValid: false, isPrivate: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { type = 'page', prefix = '' } = options;
|
|
119
|
+
|
|
120
|
+
// Normalize path separators to forward slashes
|
|
121
|
+
const normalizedPath = relativePath.split(path.sep).join('/').replace(/\\/g, '/');
|
|
122
|
+
const segments = normalizedPath.split('/').filter(Boolean);
|
|
123
|
+
|
|
124
|
+
// Check if any segment is private or ignored
|
|
125
|
+
for (const seg of segments) {
|
|
126
|
+
if (isPrivateOrIgnored(seg)) {
|
|
127
|
+
return { path: '', method: '', isValid: false, isPrivate: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const filename = segments[segments.length - 1];
|
|
132
|
+
if (!filename) {
|
|
133
|
+
return { path: '', method: '', isValid: false, isPrivate: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let method = 'get';
|
|
137
|
+
let cleanFilename = filename;
|
|
138
|
+
let originalMethodPart = null;
|
|
139
|
+
|
|
140
|
+
// Check for HTTP method suffix: e.g. health.get.js, [id].patch.js
|
|
141
|
+
const methodMatch = filename.match(METHOD_SUFFIX_REGEX);
|
|
142
|
+
if (methodMatch) {
|
|
143
|
+
const rawMethod = methodMatch[1].toLowerCase();
|
|
144
|
+
originalMethodPart = rawMethod;
|
|
145
|
+
if (SUPPORTED_METHODS.has(rawMethod)) {
|
|
146
|
+
method = rawMethod;
|
|
147
|
+
cleanFilename = filename.replace(`.${methodMatch[1]}.js`, '');
|
|
148
|
+
} else {
|
|
149
|
+
// Invalid HTTP method in filename
|
|
150
|
+
return { path: '', method: rawMethod, isValid: false, isPrivate: false, invalidMethod: true };
|
|
151
|
+
}
|
|
152
|
+
} else if (filename.endsWith('.js')) {
|
|
153
|
+
cleanFilename = filename.slice(0, -3);
|
|
154
|
+
} else if (filename.endsWith('.njk')) {
|
|
155
|
+
cleanFilename = filename.slice(0, -4);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Replace last segment with cleanFilename
|
|
159
|
+
const routeSegments = [...segments.slice(0, -1), cleanFilename];
|
|
160
|
+
|
|
161
|
+
// Process dynamic brackets in segments
|
|
162
|
+
const transformedSegments = routeSegments.map(transformDynamicSegment);
|
|
163
|
+
|
|
164
|
+
// Handle 'index' at the end of path
|
|
165
|
+
if (transformedSegments[transformedSegments.length - 1] === 'index') {
|
|
166
|
+
transformedSegments.pop();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let routePath = '/' + transformedSegments.join('/');
|
|
170
|
+
if (routePath === '//' || routePath === '') {
|
|
171
|
+
routePath = '/';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Combine with prefix
|
|
175
|
+
const cleanPrefix = normalizePrefix(prefix);
|
|
176
|
+
if (cleanPrefix) {
|
|
177
|
+
if (routePath === '/') {
|
|
178
|
+
routePath = cleanPrefix;
|
|
179
|
+
} else {
|
|
180
|
+
routePath = `${cleanPrefix}${routePath}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
path: routePath,
|
|
186
|
+
method: method.toUpperCase(),
|
|
187
|
+
isValid: true,
|
|
188
|
+
isPrivate: false,
|
|
189
|
+
originalMethodPart,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
parseFileRoute,
|
|
195
|
+
isPrivateOrIgnored,
|
|
196
|
+
normalizePrefix,
|
|
197
|
+
transformDynamicSegment,
|
|
198
|
+
scanDirSafely,
|
|
199
|
+
SUPPORTED_METHODS,
|
|
200
|
+
SUPPORTED_EXTENSIONS,
|
|
201
|
+
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso Route Discovery Scanner
|
|
5
|
+
* Scans pages, api, and module directories to produce raw route descriptors
|
|
6
|
+
* @module src/discovery/scan-routes
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { parseFileRoute, isPrivateOrIgnored } = require('./file-route-parser');
|
|
12
|
+
const { discoverModuleDetails } = require('../modules/module-discovery');
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Safely scans a directory recursively for route files
|
|
18
|
+
* @param {string} dirPath - Absolute directory path
|
|
19
|
+
* @param {string} [baseDir=dirPath] - Base directory for computing relative paths
|
|
20
|
+
* @returns {Array<{ absolutePath: string, relativePath: string }>}
|
|
21
|
+
*/
|
|
22
|
+
function scanDirSafely(dirPath, baseDir = dirPath) {
|
|
23
|
+
const results = [];
|
|
24
|
+
if (!fs.existsSync(dirPath)) return results;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const name = entry.name;
|
|
31
|
+
if (isPrivateOrIgnored(name)) continue;
|
|
32
|
+
|
|
33
|
+
const fullPath = path.join(dirPath, name);
|
|
34
|
+
const relPath = path.relative(baseDir, fullPath);
|
|
35
|
+
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
results.push(...scanDirSafely(fullPath, baseDir));
|
|
38
|
+
} else if (entry.isFile()) {
|
|
39
|
+
const ext = path.extname(name);
|
|
40
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
41
|
+
results.push({
|
|
42
|
+
absolutePath: fullPath,
|
|
43
|
+
relativePath: relPath,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.warn(`[webspresso] Failed to scan directory ${dirPath}:`, err.message);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scans configured pages, api, and module directories
|
|
57
|
+
* @param {Object} options
|
|
58
|
+
* @param {string} [options.rootDir=process.cwd()] - Project root directory
|
|
59
|
+
* @param {boolean|Object} [options.pages] - Pages configuration
|
|
60
|
+
* @param {boolean|Object} [options.api] - API configuration
|
|
61
|
+
* @param {boolean|Object} [options.modules] - Modules configuration
|
|
62
|
+
* @returns {Array<Object>} Discovered route descriptors
|
|
63
|
+
*/
|
|
64
|
+
function scanRoutes(options = {}) {
|
|
65
|
+
const rootDir = options.rootDir || process.cwd();
|
|
66
|
+
const descriptors = [];
|
|
67
|
+
|
|
68
|
+
// Helper to resolve directory paths
|
|
69
|
+
function resolveDirCandidate(candidates) {
|
|
70
|
+
for (const cand of candidates) {
|
|
71
|
+
const p = path.isAbsolute(cand) ? cand : path.join(rootDir, cand);
|
|
72
|
+
if (fs.existsSync(p)) return p;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 1. Scan Global Pages
|
|
78
|
+
const pagesOpt = options.pages;
|
|
79
|
+
if (pagesOpt !== false) {
|
|
80
|
+
const explicitPagesDir = typeof pagesOpt === 'object' && pagesOpt.dir ? pagesOpt.dir : null;
|
|
81
|
+
const pagesPrefix = typeof pagesOpt === 'object' && pagesOpt.prefix ? pagesOpt.prefix : '';
|
|
82
|
+
const pagesDir = explicitPagesDir
|
|
83
|
+
? (path.isAbsolute(explicitPagesDir) ? explicitPagesDir : path.join(rootDir, explicitPagesDir))
|
|
84
|
+
: resolveDirCandidate(['src/pages', 'pages']);
|
|
85
|
+
|
|
86
|
+
if (pagesDir && fs.existsSync(pagesDir)) {
|
|
87
|
+
const files = scanDirSafely(pagesDir);
|
|
88
|
+
const filesSet = new Set(files.map((f) => f.absolutePath));
|
|
89
|
+
|
|
90
|
+
for (const { absolutePath, relativePath } of files) {
|
|
91
|
+
// Skip .njk templates (handled by classic Nunjucks mountPages)
|
|
92
|
+
if (relativePath.endsWith('.njk')) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Skip non-js files
|
|
97
|
+
if (!relativePath.endsWith('.js')) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Skip api directory inside pages (handled by classic mountPages or explicit api scan)
|
|
102
|
+
const isUnderApi = relativePath.startsWith('api' + path.sep) || relativePath.startsWith('api/');
|
|
103
|
+
if (isUnderApi) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// If a companion .njk file exists, this .js is a data loader for mountPages, not a standalone definePage route
|
|
108
|
+
const companionNjk = absolutePath.replace(/\.js$/, '.njk');
|
|
109
|
+
if (filesSet.has(companionNjk)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Parse as standalone page route
|
|
114
|
+
const parsed = parseFileRoute(relativePath, {
|
|
115
|
+
type: 'page',
|
|
116
|
+
prefix: pagesPrefix,
|
|
117
|
+
});
|
|
118
|
+
if (parsed.isValid) {
|
|
119
|
+
descriptors.push({
|
|
120
|
+
type: 'page',
|
|
121
|
+
method: parsed.method,
|
|
122
|
+
path: parsed.path,
|
|
123
|
+
file: absolutePath,
|
|
124
|
+
source: path.relative(rootDir, absolutePath),
|
|
125
|
+
module: null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Scan Global API (e.g. src/api or api)
|
|
133
|
+
const apiOpt = options.api;
|
|
134
|
+
if (apiOpt !== false) {
|
|
135
|
+
const explicitApiDir = typeof apiOpt === 'object' && apiOpt.dir ? apiOpt.dir : null;
|
|
136
|
+
const apiPrefix = typeof apiOpt === 'object' && apiOpt.prefix ? apiOpt.prefix : '/api';
|
|
137
|
+
const apiDir = explicitApiDir
|
|
138
|
+
? (path.isAbsolute(explicitApiDir) ? explicitApiDir : path.join(rootDir, explicitApiDir))
|
|
139
|
+
: resolveDirCandidate(['src/api', 'api']);
|
|
140
|
+
|
|
141
|
+
if (apiDir && fs.existsSync(apiDir)) {
|
|
142
|
+
const files = scanDirSafely(apiDir);
|
|
143
|
+
for (const { absolutePath, relativePath } of files) {
|
|
144
|
+
const parsed = parseFileRoute(relativePath, {
|
|
145
|
+
type: 'api',
|
|
146
|
+
prefix: apiPrefix,
|
|
147
|
+
});
|
|
148
|
+
if (parsed.isValid) {
|
|
149
|
+
descriptors.push({
|
|
150
|
+
type: 'api',
|
|
151
|
+
method: parsed.method,
|
|
152
|
+
path: parsed.path,
|
|
153
|
+
file: absolutePath,
|
|
154
|
+
source: path.relative(rootDir, absolutePath),
|
|
155
|
+
module: null,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 3. Scan Modules (e.g. src/modules or modules)
|
|
163
|
+
const modulesOpt = options.modules;
|
|
164
|
+
if (modulesOpt !== false) {
|
|
165
|
+
const explicitModulesDir = typeof modulesOpt === 'object' && modulesOpt.dir ? modulesOpt.dir : null;
|
|
166
|
+
const modulesDir = explicitModulesDir
|
|
167
|
+
? (path.isAbsolute(explicitModulesDir) ? explicitModulesDir : path.join(rootDir, explicitModulesDir))
|
|
168
|
+
: resolveDirCandidate(['src/modules', 'modules']);
|
|
169
|
+
|
|
170
|
+
if (modulesDir && fs.existsSync(modulesDir)) {
|
|
171
|
+
const entries = fs.readdirSync(modulesDir, { withFileTypes: true });
|
|
172
|
+
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!entry.isDirectory() || isPrivateOrIgnored(entry.name)) continue;
|
|
175
|
+
|
|
176
|
+
const moduleName = entry.name;
|
|
177
|
+
const modulePath = path.join(modulesDir, moduleName);
|
|
178
|
+
|
|
179
|
+
const details = discoverModuleDetails(modulePath, moduleName);
|
|
180
|
+
const moduleConfig = Object.assign({}, details.config, {
|
|
181
|
+
middlewares: details.middlewares,
|
|
182
|
+
services: details.services,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Module Pages
|
|
186
|
+
const modPagesConfig = moduleConfig?.pages;
|
|
187
|
+
if (modPagesConfig !== false) {
|
|
188
|
+
const modPagesDirName = typeof modPagesConfig === 'object' && modPagesConfig.dir ? modPagesConfig.dir : 'pages';
|
|
189
|
+
const modPagesPrefix = typeof modPagesConfig === 'object' && modPagesConfig.prefix !== undefined
|
|
190
|
+
? modPagesConfig.prefix
|
|
191
|
+
: `/${moduleName}`;
|
|
192
|
+
const modPagesDir = path.isAbsolute(modPagesDirName)
|
|
193
|
+
? modPagesDirName
|
|
194
|
+
: path.join(modulePath, modPagesDirName);
|
|
195
|
+
|
|
196
|
+
if (fs.existsSync(modPagesDir)) {
|
|
197
|
+
const files = scanDirSafely(modPagesDir);
|
|
198
|
+
const modFilesSet = new Set(files.map((f) => f.absolutePath));
|
|
199
|
+
|
|
200
|
+
for (const { absolutePath, relativePath } of files) {
|
|
201
|
+
if (!relativePath.endsWith('.js')) continue;
|
|
202
|
+
if (modFilesSet.has(absolutePath.replace(/\.js$/, '.njk'))) continue;
|
|
203
|
+
|
|
204
|
+
const parsed = parseFileRoute(relativePath, {
|
|
205
|
+
type: 'page',
|
|
206
|
+
prefix: modPagesPrefix,
|
|
207
|
+
});
|
|
208
|
+
if (parsed.isValid) {
|
|
209
|
+
descriptors.push({
|
|
210
|
+
type: 'page',
|
|
211
|
+
method: parsed.method,
|
|
212
|
+
path: parsed.path,
|
|
213
|
+
file: absolutePath,
|
|
214
|
+
source: path.relative(rootDir, absolutePath),
|
|
215
|
+
module: moduleName,
|
|
216
|
+
moduleConfig,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Module API
|
|
224
|
+
const modApiConfig = moduleConfig?.api;
|
|
225
|
+
if (modApiConfig !== false) {
|
|
226
|
+
const modApiDirName = typeof modApiConfig === 'object' && modApiConfig.dir ? modApiConfig.dir : 'api';
|
|
227
|
+
const modApiPrefix = typeof modApiConfig === 'object' && modApiConfig.prefix !== undefined
|
|
228
|
+
? modApiConfig.prefix
|
|
229
|
+
: `/api/${moduleName}`;
|
|
230
|
+
const modApiDir = path.isAbsolute(modApiDirName)
|
|
231
|
+
? modApiDirName
|
|
232
|
+
: path.join(modulePath, modApiDirName);
|
|
233
|
+
|
|
234
|
+
if (fs.existsSync(modApiDir)) {
|
|
235
|
+
const files = scanDirSafely(modApiDir);
|
|
236
|
+
for (const { absolutePath, relativePath } of files) {
|
|
237
|
+
const parsed = parseFileRoute(relativePath, {
|
|
238
|
+
type: 'api',
|
|
239
|
+
prefix: modApiPrefix,
|
|
240
|
+
});
|
|
241
|
+
if (parsed.isValid) {
|
|
242
|
+
descriptors.push({
|
|
243
|
+
type: 'api',
|
|
244
|
+
method: parsed.method,
|
|
245
|
+
path: parsed.path,
|
|
246
|
+
file: absolutePath,
|
|
247
|
+
source: path.relative(rootDir, absolutePath),
|
|
248
|
+
module: moduleName,
|
|
249
|
+
moduleConfig,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return descriptors;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
scanRoutes,
|
|
264
|
+
scanDirSafely,
|
|
265
|
+
};
|
package/src/file-router.js
CHANGED
|
@@ -1026,6 +1026,9 @@ function mountPages(app, options) {
|
|
|
1026
1026
|
/** Register API routes (shared by static phase and dynamic phase). */
|
|
1027
1027
|
const registerApiRoutes = (routes) => {
|
|
1028
1028
|
for (const route of routes) {
|
|
1029
|
+
if (options.skipExistingRoutes && options.skipExistingRoutes.has(route.method || 'GET', route.routePath)) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1029
1032
|
const handler = require(route.fullPath);
|
|
1030
1033
|
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
1031
1034
|
const routeMiddleware = handler.middleware;
|
|
@@ -1134,6 +1137,9 @@ function mountPages(app, options) {
|
|
|
1134
1137
|
/** Register SSR GET routes (shared by static phase and dynamic phase). */
|
|
1135
1138
|
const registerSsrRoutes = (routes) => {
|
|
1136
1139
|
for (const route of routes) {
|
|
1140
|
+
if (options.skipExistingRoutes && options.skipExistingRoutes.has('GET', route.routePath)) {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1137
1143
|
const mountConfig = loadRouteConfig(route.configPath, isDev);
|
|
1138
1144
|
const preResolvedPageMw = mountConfig?.middleware
|
|
1139
1145
|
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso Module Definition Helper
|
|
5
|
+
* Creates feature-oriented modules compatible with Webspresso PluginManager
|
|
6
|
+
* @module src/modules/define-module
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Defines a feature module with pages, api, services, middlewares, and lifecycle hooks
|
|
11
|
+
* @param {Object} config
|
|
12
|
+
* @param {string} config.name - Module unique name (e.g. 'auth', 'shop')
|
|
13
|
+
* @param {string} [config.version='1.0.0'] - Module version
|
|
14
|
+
* @param {string} [config.description] - Module description
|
|
15
|
+
* @param {Array<string>} [config.imports] - Dependent module names
|
|
16
|
+
* @param {Array<string>} [config.dependencies] - Alias for imports
|
|
17
|
+
* @param {boolean|Object} [config.pages] - Pages configuration ({ dir, prefix })
|
|
18
|
+
* @param {boolean|Object} [config.api] - API configuration ({ dir, prefix })
|
|
19
|
+
* @param {Object} [config.services] - Registered service definitions
|
|
20
|
+
* @param {Object} [config.middlewares] - Module-local named middleware functions
|
|
21
|
+
* @param {Object} [config.exports] - Public API methods
|
|
22
|
+
* @param {Object} [config.api] - Public API alias
|
|
23
|
+
* @param {Function} [config.onInit] - Initialization lifecycle hook
|
|
24
|
+
* @param {Function} [config.onDestroy] - Destruction lifecycle hook
|
|
25
|
+
* @returns {Function} Module factory function returning a Webspresso plugin object
|
|
26
|
+
*/
|
|
27
|
+
function defineModule(config = {}) {
|
|
28
|
+
const {
|
|
29
|
+
name,
|
|
30
|
+
version = '1.0.0',
|
|
31
|
+
description = '',
|
|
32
|
+
imports = [],
|
|
33
|
+
dependencies = [],
|
|
34
|
+
pages,
|
|
35
|
+
api: apiConfig,
|
|
36
|
+
services = {},
|
|
37
|
+
middlewares = {},
|
|
38
|
+
exports: publicExports = {},
|
|
39
|
+
onInit,
|
|
40
|
+
onDestroy,
|
|
41
|
+
} = config;
|
|
42
|
+
|
|
43
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
44
|
+
throw new Error('defineModule requires a valid non-empty "name" string');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const combinedDeps = Array.from(new Set([...(imports || []), ...(dependencies || [])]));
|
|
48
|
+
|
|
49
|
+
return function moduleFactory(userOptions = {}) {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
version,
|
|
53
|
+
description,
|
|
54
|
+
dependencies: combinedDeps,
|
|
55
|
+
__isWebspressoModule: true,
|
|
56
|
+
moduleConfig: {
|
|
57
|
+
...config,
|
|
58
|
+
...userOptions,
|
|
59
|
+
},
|
|
60
|
+
api: typeof publicExports === 'object' && publicExports !== null ? publicExports : {},
|
|
61
|
+
|
|
62
|
+
register(ctx) {
|
|
63
|
+
// 1. Register module services into serviceRegistry
|
|
64
|
+
if (ctx.app && ctx.app.serviceRegistry && services && typeof services === 'object') {
|
|
65
|
+
for (const [sName, sDef] of Object.entries(services)) {
|
|
66
|
+
const qualifiedName = sName.includes('.') ? sName : `${name}.${sName}`;
|
|
67
|
+
if (!ctx.app.serviceRegistry.has(qualifiedName)) {
|
|
68
|
+
ctx.app.serviceRegistry.register(qualifiedName, sDef);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Register module-local middlewares into context
|
|
74
|
+
if (ctx.middlewares && middlewares && typeof middlewares === 'object') {
|
|
75
|
+
for (const [mwName, mwFn] of Object.entries(middlewares)) {
|
|
76
|
+
// Register with module prefix as well
|
|
77
|
+
ctx.middlewares[`${name}.${mwName}`] = mwFn;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Run onInit lifecycle hook if provided
|
|
82
|
+
if (typeof onInit === 'function') {
|
|
83
|
+
onInit(ctx);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4. Register onDestroy hook with ShutdownManager if provided
|
|
87
|
+
if (typeof onDestroy === 'function' && ctx.shutdownManager) {
|
|
88
|
+
ctx.shutdownManager.registerDisposer(onDestroy, {
|
|
89
|
+
name: `module:${name}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
defineModule,
|
|
99
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso Module Discovery Utilities
|
|
5
|
+
* Auto-discovers services and middlewares within feature module directories
|
|
6
|
+
* @module src/modules/module-discovery
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { scanDirSafely, isPrivateOrIgnored } = require('../discovery/file-route-parser');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Converts a file path to camelCase or dot-separated identifier
|
|
15
|
+
* @param {string} str
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function toCamelCase(str) {
|
|
19
|
+
return str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Discovers services, middlewares, and configuration for a module directory
|
|
24
|
+
* @param {string} modulePath - Absolute path to module folder (e.g. /app/modules/auth)
|
|
25
|
+
* @param {string} moduleName - Name of the module (e.g. 'auth')
|
|
26
|
+
* @returns {{ services: Record<string, any>, middlewares: Record<string, Function>, config: Object|null }}
|
|
27
|
+
*/
|
|
28
|
+
function discoverModuleDetails(modulePath, moduleName) {
|
|
29
|
+
const discovered = {
|
|
30
|
+
services: {},
|
|
31
|
+
middlewares: {},
|
|
32
|
+
config: null,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(modulePath)) return discovered;
|
|
36
|
+
|
|
37
|
+
// 1. Check for module configuration file
|
|
38
|
+
const configCandidates = [
|
|
39
|
+
path.join(modulePath, `${moduleName}.module.js`),
|
|
40
|
+
path.join(modulePath, 'module.js'),
|
|
41
|
+
path.join(modulePath, 'index.js'),
|
|
42
|
+
];
|
|
43
|
+
for (const cf of configCandidates) {
|
|
44
|
+
if (fs.existsSync(cf)) {
|
|
45
|
+
try {
|
|
46
|
+
const loaded = require(cf);
|
|
47
|
+
const evaluated = typeof loaded === 'function' ? loaded() : loaded;
|
|
48
|
+
discovered.config = evaluated && evaluated.moduleConfig ? evaluated.moduleConfig : evaluated;
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`[webspresso] Error loading module config ${cf}:`, err.message);
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Discover Services in modules/{name}/services/
|
|
57
|
+
const servicesDir = path.join(modulePath, 'services');
|
|
58
|
+
if (fs.existsSync(servicesDir)) {
|
|
59
|
+
const serviceFiles = scanDirSafely(servicesDir);
|
|
60
|
+
for (const { absolutePath, relativePath } of serviceFiles) {
|
|
61
|
+
if (!relativePath.endsWith('.js')) continue;
|
|
62
|
+
const baseName = relativePath.slice(0, -3).split(path.sep).join('.');
|
|
63
|
+
const qualifiedName = `${moduleName}.${baseName}`;
|
|
64
|
+
try {
|
|
65
|
+
const serviceModule = require(absolutePath);
|
|
66
|
+
const serviceDef = serviceModule.default || serviceModule;
|
|
67
|
+
discovered.services[qualifiedName] = serviceDef;
|
|
68
|
+
|
|
69
|
+
// Also add camelCase alias if hyphenated
|
|
70
|
+
const camelAlias = `${moduleName}.${toCamelCase(baseName)}`;
|
|
71
|
+
if (camelAlias !== qualifiedName) {
|
|
72
|
+
discovered.services[camelAlias] = serviceDef;
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn(`[webspresso] Error loading module service ${absolutePath}:`, err.message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Discover Middlewares in modules/{name}/middleware/ or middlewares/
|
|
81
|
+
const mwDirs = [path.join(modulePath, 'middleware'), path.join(modulePath, 'middlewares')];
|
|
82
|
+
for (const mwDir of mwDirs) {
|
|
83
|
+
if (fs.existsSync(mwDir)) {
|
|
84
|
+
const mwFiles = scanDirSafely(mwDir);
|
|
85
|
+
for (const { absolutePath, relativePath } of mwFiles) {
|
|
86
|
+
if (!relativePath.endsWith('.js')) continue;
|
|
87
|
+
const rawName = path.basename(relativePath, '.js');
|
|
88
|
+
try {
|
|
89
|
+
const mwModule = require(absolutePath);
|
|
90
|
+
const mwFn = mwModule.default || mwModule;
|
|
91
|
+
if (typeof mwFn === 'function') {
|
|
92
|
+
discovered.middlewares[rawName] = mwFn;
|
|
93
|
+
discovered.middlewares[toCamelCase(rawName)] = mwFn;
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.warn(`[webspresso] Error loading module middleware ${absolutePath}:`, err.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Merge explicitly defined middlewares from module config
|
|
103
|
+
if (discovered.config?.middlewares && typeof discovered.config.middlewares === 'object') {
|
|
104
|
+
Object.assign(discovered.middlewares, discovered.config.middlewares);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Merge explicitly defined services from module config
|
|
108
|
+
if (discovered.config?.services && typeof discovered.config.services === 'object') {
|
|
109
|
+
Object.assign(discovered.services, discovered.config.services);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return discovered;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
discoverModuleDetails,
|
|
117
|
+
};
|