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,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Services Executor
|
|
3
|
+
* Executes services with shared context, auth/role guards, timeout handling, transaction boundaries, sensitive data masking, and branch-safe call stacks
|
|
4
|
+
* @module src/services/executor
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
NotFoundError,
|
|
9
|
+
WebspressoError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
UnauthorizedError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
} = require('../../core/errors');
|
|
14
|
+
const { validateServiceInput } = require('./validator');
|
|
15
|
+
const { parseTtlMs } = require('./memoize');
|
|
16
|
+
const {
|
|
17
|
+
runWithAmbientTransaction,
|
|
18
|
+
hasAmbientTransaction,
|
|
19
|
+
} = require('../../core/orm/transaction');
|
|
20
|
+
|
|
21
|
+
const SERVICE_CALL_STACK_SYMBOL = Symbol.for('webspresso.service.call_stack');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_REDACT_KEYS = new Set([
|
|
24
|
+
'password',
|
|
25
|
+
'pass',
|
|
26
|
+
'token',
|
|
27
|
+
'secret',
|
|
28
|
+
'apikey',
|
|
29
|
+
'api_key',
|
|
30
|
+
'authorization',
|
|
31
|
+
'creditcard',
|
|
32
|
+
'credit_card',
|
|
33
|
+
'cardnumber',
|
|
34
|
+
'card_number',
|
|
35
|
+
'cvv',
|
|
36
|
+
'ssn',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mask sensitive fields in payloads for safe logging
|
|
41
|
+
* @param {*} data
|
|
42
|
+
* @param {string[]} [customRedactKeys=[]]
|
|
43
|
+
* @returns {*} Masked payload
|
|
44
|
+
*/
|
|
45
|
+
function maskSensitiveData(data, customRedactKeys = []) {
|
|
46
|
+
if (data === null || typeof data !== 'object') {
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const redactKeys = new Set([...DEFAULT_REDACT_KEYS, ...customRedactKeys.map(k => k.toLowerCase())]);
|
|
51
|
+
|
|
52
|
+
if (Array.isArray(data)) {
|
|
53
|
+
return data.map(item => maskSensitiveData(item, customRedactKeys));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const masked = {};
|
|
57
|
+
for (const [k, v] of Object.entries(data)) {
|
|
58
|
+
if (redactKeys.has(k.toLowerCase())) {
|
|
59
|
+
masked[k] = '[REDACTED]';
|
|
60
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
61
|
+
masked[k] = maskSensitiveData(v, customRedactKeys);
|
|
62
|
+
} else {
|
|
63
|
+
masked[k] = v;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return masked;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check service authorization and role permissions
|
|
71
|
+
* @param {Object} serviceDef - Service definition
|
|
72
|
+
* @param {Object} ctx - Context object
|
|
73
|
+
* @param {Object} options - Execution options
|
|
74
|
+
*/
|
|
75
|
+
async function checkServiceAuth(serviceDef, ctx, options = {}) {
|
|
76
|
+
if (options.skipAuth === true || !serviceDef.auth) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const authRequirement = serviceDef.auth;
|
|
81
|
+
const user = ctx.user || ctx.auth?.user || (ctx.req && (ctx.req.user || ctx.req.session?.user)) || null;
|
|
82
|
+
|
|
83
|
+
// 1. Boolean check (must be authenticated)
|
|
84
|
+
if (authRequirement === true) {
|
|
85
|
+
if (!user) {
|
|
86
|
+
throw new UnauthorizedError(`Authentication required to execute service '${serviceDef.name}'`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. Single role string check (e.g. 'admin')
|
|
92
|
+
if (typeof authRequirement === 'string') {
|
|
93
|
+
if (!user) {
|
|
94
|
+
throw new UnauthorizedError(`Authentication required to execute service '${serviceDef.name}'`);
|
|
95
|
+
}
|
|
96
|
+
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []);
|
|
97
|
+
if (!roles.includes(authRequirement)) {
|
|
98
|
+
throw new ForbiddenError(
|
|
99
|
+
`Insufficient permissions to execute service '${serviceDef.name}' (required role: '${authRequirement}')`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 3. Array of allowed roles check (e.g. ['admin', 'manager'])
|
|
106
|
+
if (Array.isArray(authRequirement)) {
|
|
107
|
+
if (!user) {
|
|
108
|
+
throw new UnauthorizedError(`Authentication required to execute service '${serviceDef.name}'`);
|
|
109
|
+
}
|
|
110
|
+
const roles = Array.isArray(user.roles) ? user.roles : (user.role ? [user.role] : []);
|
|
111
|
+
const hasAny = authRequirement.some(r => roles.includes(r));
|
|
112
|
+
if (!hasAny) {
|
|
113
|
+
throw new ForbiddenError(
|
|
114
|
+
`Insufficient permissions to execute service '${serviceDef.name}' (required one of: '${authRequirement.join(', ')}')`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. Custom predicate function check: (user, ctx) => boolean
|
|
121
|
+
if (typeof authRequirement === 'function') {
|
|
122
|
+
let isAuthorized = false;
|
|
123
|
+
try {
|
|
124
|
+
isAuthorized = await authRequirement(user, ctx);
|
|
125
|
+
} catch (authFnErr) {
|
|
126
|
+
throw new ForbiddenError(
|
|
127
|
+
`Authorization check failed for service '${serviceDef.name}': ${authFnErr.message}`,
|
|
128
|
+
{ cause: authFnErr }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (!isAuthorized) {
|
|
132
|
+
throw new ForbiddenError(
|
|
133
|
+
`Access denied to execute service '${serviceDef.name}' by authorization policy`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Execute a service with schema validation, auth checks, and error boundary
|
|
141
|
+
* @param {ServiceRegistry} registry - Service registry instance
|
|
142
|
+
* @param {string} name - Service name
|
|
143
|
+
* @param {unknown} [input] - Raw input payload
|
|
144
|
+
* @param {Object} [ctx={}] - Context object
|
|
145
|
+
* @param {Object} [options={}] - Execution options
|
|
146
|
+
* @returns {Promise<unknown>} Service execution result
|
|
147
|
+
*/
|
|
148
|
+
async function executeService(registry, name, input = {}, ctx = {}, options = {}) {
|
|
149
|
+
if (!name || typeof name !== 'string') {
|
|
150
|
+
throw new WebspressoError('Service name must be a non-empty string');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const serviceDef = registry.get(name);
|
|
154
|
+
|
|
155
|
+
if (!serviceDef) {
|
|
156
|
+
const notFoundErr = new NotFoundError(`Service not found: ${name}`);
|
|
157
|
+
notFoundErr.code = 'SERVICE_NOT_FOUND';
|
|
158
|
+
notFoundErr.service = name;
|
|
159
|
+
throw notFoundErr;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check authorization requirements
|
|
163
|
+
await checkServiceAuth(serviceDef, ctx, options);
|
|
164
|
+
|
|
165
|
+
// Check call stack depth for circular service invocations
|
|
166
|
+
const activeStack = Array.isArray(ctx[SERVICE_CALL_STACK_SYMBOL])
|
|
167
|
+
? ctx[SERVICE_CALL_STACK_SYMBOL]
|
|
168
|
+
: [];
|
|
169
|
+
|
|
170
|
+
if (activeStack.includes(name)) {
|
|
171
|
+
const cycle = [...activeStack, name].join(' -> ');
|
|
172
|
+
const circularErr = new WebspressoError(`Circular service call detected:\n${cycle}`);
|
|
173
|
+
circularErr.code = 'CIRCULAR_SERVICE_CALL';
|
|
174
|
+
circularErr.service = name;
|
|
175
|
+
circularErr.callStack = [...activeStack, name];
|
|
176
|
+
throw circularErr;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Configure execution context
|
|
180
|
+
const executionContext = Object.create(ctx);
|
|
181
|
+
executionContext[SERVICE_CALL_STACK_SYMBOL] = [...activeStack, name];
|
|
182
|
+
executionContext.service = (subName, subInput, subOpts) =>
|
|
183
|
+
executeService(registry, subName, subInput, executionContext, subOpts);
|
|
184
|
+
executionContext.service.invalidate = (subName, subInput, subCtx) =>
|
|
185
|
+
registry.invalidate(subName, subInput, subCtx || executionContext);
|
|
186
|
+
executionContext.service.clearCache = (subName) =>
|
|
187
|
+
registry.clearCache(subName);
|
|
188
|
+
|
|
189
|
+
// Validate input
|
|
190
|
+
const validatedInput = await validateServiceInput(serviceDef.schema, input, name);
|
|
191
|
+
|
|
192
|
+
const startTime = Date.now();
|
|
193
|
+
const logger = executionContext.logger || registry.logger || null;
|
|
194
|
+
|
|
195
|
+
const timeoutConfig = options.timeout ?? serviceDef.timeout;
|
|
196
|
+
const timeoutMs = timeoutConfig ? parseTtlMs(timeoutConfig) : null;
|
|
197
|
+
|
|
198
|
+
// Helper to execute handler with optional database transaction
|
|
199
|
+
const executeHandler = async (activeCtx) => {
|
|
200
|
+
const shouldUseTx = (options.transaction === true || serviceDef.transaction === true);
|
|
201
|
+
const db = activeCtx.db;
|
|
202
|
+
|
|
203
|
+
// Check if knex or transaction-capable database is present
|
|
204
|
+
const knexInstance = db && (db.knex || (typeof db.transaction === 'function' ? db : null));
|
|
205
|
+
const alreadyInTx = Boolean(activeCtx.trx || hasAmbientTransaction());
|
|
206
|
+
|
|
207
|
+
if (shouldUseTx && knexInstance && !alreadyInTx) {
|
|
208
|
+
return await knexInstance.transaction(async (trx) => {
|
|
209
|
+
return await runWithAmbientTransaction(trx, async () => {
|
|
210
|
+
const txCtx = Object.create(activeCtx);
|
|
211
|
+
txCtx.trx = trx;
|
|
212
|
+
if (activeCtx.db && typeof activeCtx.db.getRepository === 'function') {
|
|
213
|
+
const { createRepository } = require('../../core/orm/repository');
|
|
214
|
+
txCtx.db = Object.assign(Object.create(activeCtx.db), {
|
|
215
|
+
knex: trx,
|
|
216
|
+
getRepository: (modelName, scopeCtx) => {
|
|
217
|
+
const origRepo = activeCtx.db.getRepository(modelName, scopeCtx);
|
|
218
|
+
return createRepository(origRepo.model, trx, scopeCtx);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
} else {
|
|
222
|
+
txCtx.db = trx;
|
|
223
|
+
}
|
|
224
|
+
txCtx.service = (subName, subInput, subOpts) =>
|
|
225
|
+
executeService(registry, subName, subInput, txCtx, subOpts);
|
|
226
|
+
txCtx.service.invalidate = (subName, subInput, subCtx) =>
|
|
227
|
+
registry.invalidate(subName, subInput, subCtx || txCtx);
|
|
228
|
+
txCtx.service.clearCache = (subName) =>
|
|
229
|
+
registry.clearCache(subName);
|
|
230
|
+
return await serviceDef.handler(validatedInput, txCtx);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return await serviceDef.handler(validatedInput, activeCtx);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
let result;
|
|
240
|
+
|
|
241
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
242
|
+
let timerId;
|
|
243
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
244
|
+
timerId = setTimeout(() => {
|
|
245
|
+
const timeoutErr = new WebspressoError(`Service '${name}' timed out after ${timeoutMs}ms`);
|
|
246
|
+
timeoutErr.code = 'SERVICE_TIMEOUT';
|
|
247
|
+
timeoutErr.statusCode = 504;
|
|
248
|
+
timeoutErr.service = name;
|
|
249
|
+
reject(timeoutErr);
|
|
250
|
+
}, timeoutMs);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
result = await Promise.race([
|
|
255
|
+
executeHandler(executionContext),
|
|
256
|
+
timeoutPromise,
|
|
257
|
+
]);
|
|
258
|
+
} finally {
|
|
259
|
+
clearTimeout(timerId);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
result = await executeHandler(executionContext);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const duration = Date.now() - startTime;
|
|
266
|
+
if (logger && typeof logger.debug === 'function') {
|
|
267
|
+
logger.debug(`[service] ${name} executed successfully (${duration}ms)`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return result;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const duration = Date.now() - startTime;
|
|
273
|
+
|
|
274
|
+
if (logger && typeof logger.error === 'function' && !(err instanceof ValidationError)) {
|
|
275
|
+
logger.error(`[service] ${name} execution failed (${duration}ms): ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Attach service name to error if not present
|
|
279
|
+
if (!err.service) {
|
|
280
|
+
err.service = name;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
throw err;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
SERVICE_CALL_STACK_SYMBOL,
|
|
289
|
+
maskSensitiveData,
|
|
290
|
+
checkServiceAuth,
|
|
291
|
+
executeService,
|
|
292
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Services Layer
|
|
3
|
+
* Lightweight, reusable business logic services with zero dependency sprawl
|
|
4
|
+
* @module src/services
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { ServiceRegistry } = require('./registry');
|
|
8
|
+
const { discoverServices, filePathToServiceName, toCamelCase } = require('./discovery');
|
|
9
|
+
const { validateServiceInput, sanitizeInput } = require('./validator');
|
|
10
|
+
const { executeService, SERVICE_CALL_STACK_SYMBOL, maskSensitiveData } = require('./executor');
|
|
11
|
+
|
|
12
|
+
const { memoize, parseTtlMs, stableCacheKey } = require('./memoize');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Define a service definition with optional schema and metadata
|
|
16
|
+
* @param {Object|Function} definition - Service definition or handler function
|
|
17
|
+
* @returns {Object} Normalized service definition
|
|
18
|
+
*/
|
|
19
|
+
function defineService(definition) {
|
|
20
|
+
if (typeof definition === 'function') {
|
|
21
|
+
return { handler: definition };
|
|
22
|
+
}
|
|
23
|
+
return definition;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Factory function to create and optionally load a ServiceRegistry
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {string} [options.servicesDir] - Path to services directory
|
|
30
|
+
* @param {boolean} [options.isDev=false] - Whether development mode is active
|
|
31
|
+
* @param {Object} [options.logger] - Optional logger
|
|
32
|
+
* @param {boolean} [options.autoLoad=true] - Whether to load immediately if servicesDir exists
|
|
33
|
+
* @returns {ServiceRegistry}
|
|
34
|
+
*/
|
|
35
|
+
function createServiceRegistry(options = {}) {
|
|
36
|
+
const registry = new ServiceRegistry(options);
|
|
37
|
+
if (options.autoLoad !== false && options.servicesDir) {
|
|
38
|
+
registry.load();
|
|
39
|
+
}
|
|
40
|
+
return registry;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { createAuthServices } = require('./builtins/auth');
|
|
44
|
+
const { createMailServices } = require('./builtins/mail');
|
|
45
|
+
const { createMediaServices } = require('./builtins/media');
|
|
46
|
+
const { createSystemServices } = require('./builtins/system');
|
|
47
|
+
const { createExchangeServices } = require('./builtins/exchange');
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
ServiceRegistry,
|
|
51
|
+
createServiceRegistry,
|
|
52
|
+
defineService,
|
|
53
|
+
service: defineService, // alias
|
|
54
|
+
createAuthServices,
|
|
55
|
+
createMailServices,
|
|
56
|
+
createMediaServices,
|
|
57
|
+
createSystemServices,
|
|
58
|
+
createExchangeServices,
|
|
59
|
+
memoize,
|
|
60
|
+
parseTtlMs,
|
|
61
|
+
stableCacheKey,
|
|
62
|
+
sanitizeInput,
|
|
63
|
+
maskSensitiveData,
|
|
64
|
+
toCamelCase,
|
|
65
|
+
discoverServices,
|
|
66
|
+
filePathToServiceName,
|
|
67
|
+
validateServiceInput,
|
|
68
|
+
executeService,
|
|
69
|
+
SERVICE_CALL_STACK_SYMBOL,
|
|
70
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Services Memoization & Caching
|
|
3
|
+
* Lightweight, zero-dependency in-memory cache with stampede protection, LRU eviction, and safe serialization
|
|
4
|
+
* @module src/services/memoize
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse TTL duration into milliseconds
|
|
9
|
+
* @param {string|number|boolean} ttl - Duration string ('5s', '1m', '5m', '1h', '1d'), ms number, or boolean
|
|
10
|
+
* @returns {number} Milliseconds
|
|
11
|
+
*/
|
|
12
|
+
function parseTtlMs(ttl) {
|
|
13
|
+
if (typeof ttl === 'number') {
|
|
14
|
+
return ttl > 0 ? ttl : 60_000;
|
|
15
|
+
}
|
|
16
|
+
if (ttl === true || !ttl) {
|
|
17
|
+
return 60_000; // Default 1 minute
|
|
18
|
+
}
|
|
19
|
+
if (typeof ttl === 'string') {
|
|
20
|
+
const match = ttl.trim().match(/^(\d+)\s*([smhd])?$/i);
|
|
21
|
+
if (match) {
|
|
22
|
+
const val = parseInt(match[1], 10);
|
|
23
|
+
const unit = (match[2] || 's').toLowerCase();
|
|
24
|
+
switch (unit) {
|
|
25
|
+
case 's': return val * 1_000;
|
|
26
|
+
case 'm': return val * 60_000;
|
|
27
|
+
case 'h': return val * 3_600_000;
|
|
28
|
+
case 'd': return val * 86_400_000;
|
|
29
|
+
default: return val * 1_000;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const num = parseInt(ttl, 10);
|
|
33
|
+
return !Number.isNaN(num) && num > 0 ? num : 60_000;
|
|
34
|
+
}
|
|
35
|
+
return 60_000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Safely deep clone an object or array to prevent cache mutation
|
|
40
|
+
* @param {*} value
|
|
41
|
+
* @returns {*} Cloned value
|
|
42
|
+
*/
|
|
43
|
+
function safeClone(value) {
|
|
44
|
+
if (value === null || typeof value !== 'object') {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
if (typeof structuredClone === 'function') {
|
|
48
|
+
try {
|
|
49
|
+
return structuredClone(value);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
// Fallback for non-cloneable objects (buffers, functions, etc.)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(value)) {
|
|
55
|
+
return value.map(item => safeClone(item));
|
|
56
|
+
}
|
|
57
|
+
if (value instanceof Date) {
|
|
58
|
+
return new Date(value.getTime());
|
|
59
|
+
}
|
|
60
|
+
const cloned = {};
|
|
61
|
+
for (const [k, v] of Object.entries(value)) {
|
|
62
|
+
cloned[k] = safeClone(v);
|
|
63
|
+
}
|
|
64
|
+
return cloned;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Safe deterministic cache key serializer
|
|
69
|
+
* Handles BigInt, Buffer, Date, RegExp, Symbols, and circular references safely
|
|
70
|
+
* @param {*} input - Input arguments
|
|
71
|
+
* @param {WeakSet} [seen] - Seen objects for circular reference detection
|
|
72
|
+
* @returns {string} Serialized cache key
|
|
73
|
+
*/
|
|
74
|
+
function stableCacheKey(input, seen = new WeakSet()) {
|
|
75
|
+
if (input === undefined) return '__undefined__';
|
|
76
|
+
if (input === null) return '__null__';
|
|
77
|
+
|
|
78
|
+
const type = typeof input;
|
|
79
|
+
|
|
80
|
+
if (type === 'string') return JSON.stringify(input);
|
|
81
|
+
if (type === 'number' || type === 'boolean') return String(input);
|
|
82
|
+
if (type === 'bigint') return `${input.toString()}n`;
|
|
83
|
+
if (type === 'symbol') return input.toString();
|
|
84
|
+
if (type === 'function') return `[Function: ${input.name || 'anonymous'}]`;
|
|
85
|
+
|
|
86
|
+
if (type === 'object') {
|
|
87
|
+
if (seen.has(input)) {
|
|
88
|
+
return '[Circular]';
|
|
89
|
+
}
|
|
90
|
+
seen.add(input);
|
|
91
|
+
|
|
92
|
+
if (Buffer.isBuffer(input)) {
|
|
93
|
+
return `[Buffer:${input.toString('hex')}]`;
|
|
94
|
+
}
|
|
95
|
+
if (input instanceof Date) {
|
|
96
|
+
return `[Date:${input.toISOString()}]`;
|
|
97
|
+
}
|
|
98
|
+
if (input instanceof RegExp) {
|
|
99
|
+
return `[RegExp:${input.toString()}]`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (Array.isArray(input)) {
|
|
103
|
+
const items = input.map(item => stableCacheKey(item, seen));
|
|
104
|
+
return `[${items.join(',')}]`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Deterministically sort keys
|
|
108
|
+
const keys = Object.keys(input).sort();
|
|
109
|
+
const pairs = keys.map(k => `${JSON.stringify(k)}:${stableCacheKey(input[k], seen)}`);
|
|
110
|
+
return `{${pairs.join(',')}}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return String(input);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Memoize an async or sync function with:
|
|
118
|
+
* - Cache stampede / thundering herd protection (in-flight promise deduping)
|
|
119
|
+
* - LRU cache eviction
|
|
120
|
+
* - TTL expiration
|
|
121
|
+
* - Cache mutation protection (safe cloning)
|
|
122
|
+
* - Safe serialization (BigInt, circular refs, Buffers)
|
|
123
|
+
*
|
|
124
|
+
* @param {Function} fn - Function to memoize
|
|
125
|
+
* @param {Object} [options]
|
|
126
|
+
* @param {string|number|boolean} [options.ttl='1m'] - Time to live
|
|
127
|
+
* @param {number} [options.maxSize=500] - Max cache entries before LRU eviction
|
|
128
|
+
* @param {boolean} [options.clone=true] - Clone cached results to prevent mutation
|
|
129
|
+
* @param {Function} [options.key] - Custom cache key generator `(input, ctx) => string`
|
|
130
|
+
* @returns {Function} Memoized function with .clear(), .invalidate(), .delete(), .has(), .size
|
|
131
|
+
*/
|
|
132
|
+
function memoize(fn, options = {}) {
|
|
133
|
+
if (typeof fn !== 'function') {
|
|
134
|
+
throw new TypeError('memoize requires a function as first argument');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ttlMs = parseTtlMs(options.ttl ?? options.cache ?? '1m');
|
|
138
|
+
const maxSize = Number(options.maxSize) || 500;
|
|
139
|
+
const shouldClone = options.clone !== false;
|
|
140
|
+
const customKeyFn = typeof options.key === 'function' ? options.key : null;
|
|
141
|
+
|
|
142
|
+
/** @type {Map<string, { value: *, expiresAt: number }>} */
|
|
143
|
+
const cache = new Map();
|
|
144
|
+
|
|
145
|
+
/** @type {Map<string, Promise<*>>} In-flight request deduplication map */
|
|
146
|
+
const inFlight = new Map();
|
|
147
|
+
|
|
148
|
+
function getKey(input, ctx) {
|
|
149
|
+
if (customKeyFn) {
|
|
150
|
+
return String(customKeyFn(input, ctx));
|
|
151
|
+
}
|
|
152
|
+
return stableCacheKey(input);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function memoized(input, ctx) {
|
|
156
|
+
const key = getKey(input, ctx);
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
const hit = cache.get(key);
|
|
159
|
+
|
|
160
|
+
if (hit) {
|
|
161
|
+
if (hit.expiresAt > now) {
|
|
162
|
+
// Refresh LRU position
|
|
163
|
+
cache.delete(key);
|
|
164
|
+
cache.set(key, hit);
|
|
165
|
+
return shouldClone ? safeClone(hit.value) : hit.value;
|
|
166
|
+
}
|
|
167
|
+
cache.delete(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 1. In-flight Promise Deduping (Thundering Herd / Cache Stampede Protection)
|
|
171
|
+
if (inFlight.has(key)) {
|
|
172
|
+
const inFlightResult = await inFlight.get(key);
|
|
173
|
+
return shouldClone ? safeClone(inFlightResult) : inFlightResult;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 2. Execute and track in-flight promise
|
|
177
|
+
const executionPromise = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
return await fn(input, ctx);
|
|
180
|
+
} finally {
|
|
181
|
+
inFlight.delete(key);
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
|
|
185
|
+
inFlight.set(key, executionPromise);
|
|
186
|
+
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
result = await executionPromise;
|
|
190
|
+
} catch (execErr) {
|
|
191
|
+
// Do not cache failed executions
|
|
192
|
+
throw execErr;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 3. LRU Eviction when exceeding maxSize
|
|
196
|
+
if (cache.size >= maxSize) {
|
|
197
|
+
const oldestKey = cache.keys().next().value;
|
|
198
|
+
if (oldestKey !== undefined) {
|
|
199
|
+
cache.delete(oldestKey);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
cache.set(key, {
|
|
204
|
+
value: shouldClone ? safeClone(result) : result,
|
|
205
|
+
expiresAt: Date.now() + ttlMs,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return shouldClone ? safeClone(result) : result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
memoized.invalidate = function invalidate(input, ctx) {
|
|
212
|
+
const key = getKey(input, ctx);
|
|
213
|
+
inFlight.delete(key);
|
|
214
|
+
return cache.delete(key);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
memoized.delete = memoized.invalidate;
|
|
218
|
+
|
|
219
|
+
memoized.clear = function clear() {
|
|
220
|
+
inFlight.clear();
|
|
221
|
+
cache.clear();
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
memoized.has = function has(input, ctx) {
|
|
225
|
+
const key = getKey(input, ctx);
|
|
226
|
+
const hit = cache.get(key);
|
|
227
|
+
if (!hit) return false;
|
|
228
|
+
if (hit.expiresAt <= Date.now()) {
|
|
229
|
+
cache.delete(key);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
Object.defineProperty(memoized, 'size', {
|
|
236
|
+
get() {
|
|
237
|
+
// Clean expired entries on size access
|
|
238
|
+
const now = Date.now();
|
|
239
|
+
for (const [k, v] of cache.entries()) {
|
|
240
|
+
if (v.expiresAt <= now) {
|
|
241
|
+
cache.delete(k);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return cache.size;
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return memoized;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
memoize,
|
|
253
|
+
parseTtlMs,
|
|
254
|
+
stableCacheKey,
|
|
255
|
+
safeClone,
|
|
256
|
+
};
|