webspresso 0.0.90 → 0.0.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +141 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in Media & File Upload Services
|
|
3
|
+
* @module src/services/builtins/media
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs/promises');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { z } = require('zod');
|
|
9
|
+
const { ValidationError, NotFoundError } = require('../../../core/errors');
|
|
10
|
+
const { createLocalFileProvider, canonicalExtFromMime } = require('../../../plugins/upload/local-file-provider');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert base64 data (including data URLs) to Buffer
|
|
14
|
+
* @param {string} base64Str
|
|
15
|
+
* @returns {{ buffer: Buffer, mimeType?: string }}
|
|
16
|
+
*/
|
|
17
|
+
function parseBase64(base64Str) {
|
|
18
|
+
const match = base64Str.match(/^data:([^;]+);base64,(.+)$/);
|
|
19
|
+
if (match) {
|
|
20
|
+
const mimeType = match[1];
|
|
21
|
+
const buffer = Buffer.from(match[2], 'base64');
|
|
22
|
+
return { buffer, mimeType };
|
|
23
|
+
}
|
|
24
|
+
return { buffer: Buffer.from(base64Str, 'base64') };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validate storage key to prevent directory traversal
|
|
29
|
+
* @param {string} key
|
|
30
|
+
* @param {string} baseDir
|
|
31
|
+
* @returns {string} Absolute resolved file path
|
|
32
|
+
*/
|
|
33
|
+
function resolveSafeKeyPath(key, baseDir) {
|
|
34
|
+
if (!key || typeof key !== 'string' || key.includes('..') || path.isAbsolute(key)) {
|
|
35
|
+
throw new ValidationError('Invalid file key or path traversal attempt', [
|
|
36
|
+
{ field: 'key', message: 'Path traversal attempt detected' },
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const resolvedBase = path.resolve(baseDir);
|
|
41
|
+
const targetPath = path.resolve(resolvedBase, key);
|
|
42
|
+
|
|
43
|
+
if (!targetPath.startsWith(resolvedBase + path.sep) && targetPath !== resolvedBase) {
|
|
44
|
+
throw new ValidationError('Invalid file key or path traversal attempt', [
|
|
45
|
+
{ field: 'key', message: 'Path traversal attempt detected' },
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return targetPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create built-in media services map
|
|
54
|
+
* @param {Object} [options]
|
|
55
|
+
* @param {import('../../../plugins/upload').UploadStorageProvider} [options.provider]
|
|
56
|
+
* @param {string} [options.destDir]
|
|
57
|
+
* @param {string} [options.publicBasePath]
|
|
58
|
+
* @returns {Record<string, Object>}
|
|
59
|
+
*/
|
|
60
|
+
function createMediaServices(options = {}) {
|
|
61
|
+
const destDir = path.resolve(
|
|
62
|
+
options.destDir || path.join(process.cwd(), 'public', 'uploads')
|
|
63
|
+
);
|
|
64
|
+
const provider = options.provider || createLocalFileProvider({
|
|
65
|
+
destDir,
|
|
66
|
+
publicBasePath: options.publicBasePath,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
'media.upload': {
|
|
71
|
+
schema: z.object({
|
|
72
|
+
buffer: z.any().optional(),
|
|
73
|
+
base64: z.string().optional(),
|
|
74
|
+
filePath: z.string().optional(),
|
|
75
|
+
originalName: z.string().min(1).default('upload.bin'),
|
|
76
|
+
mimeType: z.string().optional(),
|
|
77
|
+
destDir: z.string().optional(),
|
|
78
|
+
publicBasePath: z.string().optional(),
|
|
79
|
+
}),
|
|
80
|
+
async handler(input, ctx) {
|
|
81
|
+
let { buffer, base64, filePath, originalName, mimeType, destDir: customDest, publicBasePath: customBase } = input;
|
|
82
|
+
let finalMime = mimeType || 'application/octet-stream';
|
|
83
|
+
|
|
84
|
+
if (buffer && !(buffer instanceof Buffer) && !(buffer instanceof Uint8Array)) {
|
|
85
|
+
if (typeof buffer === 'string') {
|
|
86
|
+
buffer = Buffer.from(buffer);
|
|
87
|
+
} else {
|
|
88
|
+
throw new ValidationError('Invalid buffer provided', [
|
|
89
|
+
{ field: 'buffer', message: 'Expected Buffer or Uint8Array' },
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!buffer && base64) {
|
|
95
|
+
const parsed = parseBase64(base64);
|
|
96
|
+
buffer = parsed.buffer;
|
|
97
|
+
if (parsed.mimeType && (!mimeType || mimeType === 'application/octet-stream')) {
|
|
98
|
+
finalMime = parsed.mimeType;
|
|
99
|
+
}
|
|
100
|
+
} else if (!buffer && filePath) {
|
|
101
|
+
try {
|
|
102
|
+
buffer = await fs.readFile(path.resolve(filePath));
|
|
103
|
+
if (!originalName || originalName === 'upload.bin') {
|
|
104
|
+
originalName = path.basename(filePath);
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new NotFoundError(`Source file not found: ${filePath}`, { cause: err });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!buffer || buffer.length === 0) {
|
|
112
|
+
throw new ValidationError('No file content provided (buffer, base64, or filePath is required)', [
|
|
113
|
+
{ field: 'buffer', message: 'No file content provided' },
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const activeProvider = (customDest || customBase)
|
|
118
|
+
? createLocalFileProvider({ destDir: customDest || destDir, publicBasePath: customBase })
|
|
119
|
+
: provider;
|
|
120
|
+
|
|
121
|
+
const uploadResult = await activeProvider.put({
|
|
122
|
+
buffer,
|
|
123
|
+
originalName,
|
|
124
|
+
mimeType: finalMime,
|
|
125
|
+
size: buffer.length,
|
|
126
|
+
req: ctx.req || null,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
publicUrl: uploadResult.publicUrl,
|
|
131
|
+
key: uploadResult.key || path.basename(uploadResult.publicUrl),
|
|
132
|
+
size: buffer.length,
|
|
133
|
+
mimeType: finalMime,
|
|
134
|
+
originalName,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
'media.delete': {
|
|
140
|
+
schema: z.object({
|
|
141
|
+
key: z.string().min(1, 'Key is required'),
|
|
142
|
+
destDir: z.string().optional(),
|
|
143
|
+
}),
|
|
144
|
+
async handler(input, ctx) {
|
|
145
|
+
const { key, destDir: customDest } = input;
|
|
146
|
+
const targetDir = customDest ? path.resolve(customDest) : destDir;
|
|
147
|
+
const targetPath = resolveSafeKeyPath(key, targetDir);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await fs.unlink(targetPath);
|
|
151
|
+
return { success: true, key };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (err.code === 'ENOENT') {
|
|
154
|
+
return { success: false, key, message: 'File not found' };
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
'media.info': {
|
|
162
|
+
schema: z.object({
|
|
163
|
+
key: z.string().min(1, 'Key is required'),
|
|
164
|
+
destDir: z.string().optional(),
|
|
165
|
+
}),
|
|
166
|
+
async handler(input, ctx) {
|
|
167
|
+
const { key, destDir: customDest } = input;
|
|
168
|
+
const targetDir = customDest ? path.resolve(customDest) : destDir;
|
|
169
|
+
const targetPath = resolveSafeKeyPath(key, targetDir);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const stats = await fs.stat(targetPath);
|
|
173
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
174
|
+
return {
|
|
175
|
+
exists: true,
|
|
176
|
+
key,
|
|
177
|
+
size: stats.size,
|
|
178
|
+
mtime: stats.mtime,
|
|
179
|
+
ext,
|
|
180
|
+
};
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err.code === 'ENOENT') {
|
|
183
|
+
return { exists: false, key };
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
createMediaServices,
|
|
194
|
+
resolveSafeKeyPath,
|
|
195
|
+
parseBase64,
|
|
196
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in System, Maintenance & Health Check Services
|
|
3
|
+
* @module src/services/builtins/system
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { z } = require('zod');
|
|
8
|
+
const { cleanExpiredTokens, createKnexAuthTokensAdapter } = require('../../../core/auth/tokens');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format memory usage bytes into human-readable MB
|
|
12
|
+
* @param {number} bytes
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function formatMb(bytes) {
|
|
16
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create built-in system services map
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Object} [options.db] - Database or knex instance
|
|
23
|
+
* @param {import('../registry').ServiceRegistry} [options.serviceRegistry] - ServiceRegistry instance
|
|
24
|
+
* @param {Object} [options.pluginManager] - PluginManager instance
|
|
25
|
+
* @returns {Record<string, Object>}
|
|
26
|
+
*/
|
|
27
|
+
function createSystemServices(options = {}) {
|
|
28
|
+
const { db, serviceRegistry, pluginManager } = options;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
'system.health': {
|
|
32
|
+
schema: z.object({
|
|
33
|
+
detailed: z.boolean().default(false),
|
|
34
|
+
}),
|
|
35
|
+
async handler(input, ctx) {
|
|
36
|
+
const activeDb = ctx.db || db;
|
|
37
|
+
const memory = process.memoryUsage();
|
|
38
|
+
const uptime = process.uptime();
|
|
39
|
+
const timestamp = new Date();
|
|
40
|
+
|
|
41
|
+
let dbStatus = 'disconnected';
|
|
42
|
+
let dbLatencyMs = null;
|
|
43
|
+
|
|
44
|
+
if (activeDb) {
|
|
45
|
+
const knex = activeDb.knex || activeDb;
|
|
46
|
+
const startPing = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
await knex.raw('SELECT 1');
|
|
49
|
+
dbStatus = 'connected';
|
|
50
|
+
dbLatencyMs = Date.now() - startPing;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
dbStatus = 'error';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isHealthy = dbStatus !== 'error';
|
|
57
|
+
|
|
58
|
+
const result = {
|
|
59
|
+
status: isHealthy ? 'ok' : 'degraded',
|
|
60
|
+
uptime,
|
|
61
|
+
uptimeHuman: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m ${Math.floor(uptime % 60)}s`,
|
|
62
|
+
timestamp,
|
|
63
|
+
database: {
|
|
64
|
+
status: dbStatus,
|
|
65
|
+
latencyMs: dbLatencyMs,
|
|
66
|
+
},
|
|
67
|
+
memory: {
|
|
68
|
+
rss: formatMb(memory.rss),
|
|
69
|
+
heapUsed: formatMb(memory.heapUsed),
|
|
70
|
+
heapTotal: formatMb(memory.heapTotal),
|
|
71
|
+
external: formatMb(memory.external),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (activeDb?.cache && typeof activeDb.cache.stats === 'function') {
|
|
76
|
+
result.cache = activeDb.cache.stats();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
'system.cleanup': {
|
|
84
|
+
schema: z.object({
|
|
85
|
+
olderThanDays: z.number().int().positive().default(90),
|
|
86
|
+
purgeAuthTokens: z.boolean().default(true),
|
|
87
|
+
purgeAuditLogs: z.boolean().default(true),
|
|
88
|
+
}),
|
|
89
|
+
auth: 'admin',
|
|
90
|
+
async handler(input, ctx) {
|
|
91
|
+
const { olderThanDays, purgeAuthTokens, purgeAuditLogs } = input;
|
|
92
|
+
const activeDb = ctx.db || db;
|
|
93
|
+
if (!activeDb) {
|
|
94
|
+
throw new Error('Database instance is required for system.cleanup');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const knex = activeDb.knex || activeDb;
|
|
98
|
+
const purged = {};
|
|
99
|
+
|
|
100
|
+
// 1. Purge expired auth tokens
|
|
101
|
+
if (purgeAuthTokens) {
|
|
102
|
+
try {
|
|
103
|
+
const hasAuthTokensTable = await knex.schema.hasTable('auth_tokens');
|
|
104
|
+
if (hasAuthTokensTable) {
|
|
105
|
+
const now = new Date();
|
|
106
|
+
const count = await knex('auth_tokens').where('expires_at', '<', now).delete();
|
|
107
|
+
purged.authTokens = count;
|
|
108
|
+
} else {
|
|
109
|
+
purged.authTokens = 0;
|
|
110
|
+
}
|
|
111
|
+
} catch (err) {
|
|
112
|
+
purged.authTokens = 0;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Purge old audit logs if table exists
|
|
117
|
+
if (purgeAuditLogs) {
|
|
118
|
+
try {
|
|
119
|
+
const hasAuditTable = await knex.schema.hasTable('audit_logs');
|
|
120
|
+
if (hasAuditTable) {
|
|
121
|
+
const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
|
|
122
|
+
const deleted = await knex('audit_logs').where('created_at', '<', cutoff).delete();
|
|
123
|
+
purged.auditLogs = deleted;
|
|
124
|
+
} else {
|
|
125
|
+
purged.auditLogs = 0;
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
purged.auditLogs = 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
success: true,
|
|
134
|
+
purged,
|
|
135
|
+
timestamp: new Date(),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
'system.cache-flush': {
|
|
141
|
+
schema: z.object({
|
|
142
|
+
scope: z.enum(['all', 'orm', 'services']).default('all'),
|
|
143
|
+
model: z.string().optional(),
|
|
144
|
+
}),
|
|
145
|
+
auth: 'admin',
|
|
146
|
+
async handler(input, ctx) {
|
|
147
|
+
const { scope, model } = input;
|
|
148
|
+
const activeDb = ctx.db || db;
|
|
149
|
+
const registry = ctx.serviceRegistry || serviceRegistry;
|
|
150
|
+
|
|
151
|
+
let ormCachePurged = false;
|
|
152
|
+
let serviceCacheCleared = false;
|
|
153
|
+
|
|
154
|
+
// 1. Flush ORM Cache
|
|
155
|
+
if (scope === 'all' || scope === 'orm') {
|
|
156
|
+
if (activeDb?.cache && typeof activeDb.cache.purge === 'function') {
|
|
157
|
+
activeDb.cache.purge(model);
|
|
158
|
+
ormCachePurged = true;
|
|
159
|
+
} else if (activeDb?.cache && typeof activeDb.cache.clear === 'function') {
|
|
160
|
+
activeDb.cache.clear();
|
|
161
|
+
ormCachePurged = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Clear Service Memoization Caches
|
|
166
|
+
if (scope === 'all' || scope === 'services') {
|
|
167
|
+
if (registry && typeof registry.clearCache === 'function') {
|
|
168
|
+
registry.clearCache();
|
|
169
|
+
serviceCacheCleared = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
scope,
|
|
176
|
+
model: model || null,
|
|
177
|
+
ormCachePurged,
|
|
178
|
+
serviceCacheCleared,
|
|
179
|
+
timestamp: new Date(),
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
'system.info': {
|
|
185
|
+
schema: z.object({}),
|
|
186
|
+
auth: 'admin',
|
|
187
|
+
async handler(input, ctx) {
|
|
188
|
+
const activeDb = ctx.db || db;
|
|
189
|
+
const registry = ctx.serviceRegistry || serviceRegistry;
|
|
190
|
+
const pm = ctx.pluginManager || pluginManager;
|
|
191
|
+
|
|
192
|
+
const models = activeDb && typeof activeDb.getAllModelInstances === 'function'
|
|
193
|
+
? activeDb.getAllModelInstances().map(m => m.name)
|
|
194
|
+
: [];
|
|
195
|
+
|
|
196
|
+
const services = registry && typeof registry.list === 'function'
|
|
197
|
+
? registry.list()
|
|
198
|
+
: [];
|
|
199
|
+
|
|
200
|
+
const plugins = pm && pm.plugins
|
|
201
|
+
? Array.from(pm.plugins.keys())
|
|
202
|
+
: [];
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
nodeVersion: process.version,
|
|
206
|
+
platform: os.platform(),
|
|
207
|
+
arch: os.arch(),
|
|
208
|
+
pid: process.pid,
|
|
209
|
+
uptime: process.uptime(),
|
|
210
|
+
models,
|
|
211
|
+
services,
|
|
212
|
+
plugins,
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
createSystemServices,
|
|
221
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Services Discovery
|
|
3
|
+
* Recursively scans directory and maps file paths to service names with cross-platform normalization & camelCase aliases
|
|
4
|
+
* @module src/services/discovery
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
|
|
10
|
+
|
|
11
|
+
const VALID_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert kebab-case or snake_case segment to camelCase
|
|
15
|
+
* @param {string} str
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function toCamelCase(str) {
|
|
19
|
+
return str.replace(/[-_]+([a-zA-Z0-9])/g, (_, char) => char.toUpperCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a file should be included as a service
|
|
24
|
+
* @param {string} filename - Base name of the file
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isValidServiceFile(filename) {
|
|
28
|
+
if (filename.startsWith('.') || filename.startsWith('_')) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (filename.endsWith('.test.js') || filename.endsWith('.spec.js') || filename.endsWith('.d.ts')) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const ext = path.extname(filename);
|
|
35
|
+
return VALID_EXTENSIONS.has(ext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert relative file path to dot-separated service name with cross-platform normalization & aliases
|
|
40
|
+
* @example
|
|
41
|
+
* filePathToServiceName('user/get.js') -> { name: 'user.get', aliases: [] }
|
|
42
|
+
* filePathToServiceName('order-items/get-by-id.js') -> { name: 'order-items.get-by-id', aliases: ['orderItems.getById'] }
|
|
43
|
+
* filePathToServiceName('user/index.js') -> { name: 'user', aliases: ['user.index'] }
|
|
44
|
+
*
|
|
45
|
+
* @param {string} relativePath - Path relative to services root
|
|
46
|
+
* @returns {{ name: string, aliases: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
function filePathToServiceName(relativePath) {
|
|
49
|
+
if (!relativePath || typeof relativePath !== 'string') {
|
|
50
|
+
return { name: '', aliases: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cross-platform slash normalization (Windows \ to Unix /)
|
|
54
|
+
const normalized = trimUrlPathSlashes(relativePath.replace(/\\/g, '/'));
|
|
55
|
+
const ext = path.extname(normalized);
|
|
56
|
+
const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
|
|
57
|
+
const segments = withoutExt.split('/').filter(Boolean);
|
|
58
|
+
|
|
59
|
+
if (segments.length === 0) {
|
|
60
|
+
return { name: '', aliases: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const aliases = new Set();
|
|
64
|
+
const isIndex = segments[segments.length - 1] === 'index';
|
|
65
|
+
|
|
66
|
+
let primarySegments;
|
|
67
|
+
if (isIndex) {
|
|
68
|
+
primarySegments = segments.slice(0, -1);
|
|
69
|
+
if (primarySegments.length === 0) {
|
|
70
|
+
primarySegments = ['index'];
|
|
71
|
+
} else {
|
|
72
|
+
aliases.add(segments.join('.')); // e.g. user.index alias
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
primarySegments = segments;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const primaryName = primarySegments.join('.');
|
|
79
|
+
|
|
80
|
+
// Generate camelCase alias if segments contain kebab-case or snake_case
|
|
81
|
+
const camelSegments = primarySegments.map(toCamelCase);
|
|
82
|
+
const camelName = camelSegments.join('.');
|
|
83
|
+
if (camelName !== primaryName) {
|
|
84
|
+
aliases.add(camelName);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
name: primaryName,
|
|
89
|
+
aliases: Array.from(aliases),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Recursively discover all service files in a directory
|
|
95
|
+
* @param {string} servicesDir - Absolute or relative path to services directory
|
|
96
|
+
* @returns {Array<{ name: string, aliases: string[], filePath: string, relativePath: string }>}
|
|
97
|
+
*/
|
|
98
|
+
function discoverServices(servicesDir) {
|
|
99
|
+
if (!servicesDir || !fs.existsSync(servicesDir)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const results = [];
|
|
104
|
+
|
|
105
|
+
function scan(currentDir, relativePrefix = '') {
|
|
106
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_') || entry.name === 'node_modules') {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
114
|
+
const relPath = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name;
|
|
115
|
+
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
scan(fullPath, relPath);
|
|
118
|
+
} else if (entry.isFile() && isValidServiceFile(entry.name)) {
|
|
119
|
+
const { name, aliases } = filePathToServiceName(relPath);
|
|
120
|
+
if (name) {
|
|
121
|
+
results.push({
|
|
122
|
+
name,
|
|
123
|
+
aliases,
|
|
124
|
+
filePath: fullPath,
|
|
125
|
+
relativePath: relPath.replace(/\\/g, '/'),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
scan(path.resolve(servicesDir));
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
isValidServiceFile,
|
|
138
|
+
toCamelCase,
|
|
139
|
+
filePathToServiceName,
|
|
140
|
+
discoverServices,
|
|
141
|
+
};
|