webspresso 0.0.89 → 0.0.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -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
+ };