webspresso 0.0.106 → 0.0.107

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 (49) hide show
  1. package/bin/webspresso.js +17 -7
  2. package/core/app.js +38 -6
  3. package/core/auth/index.js +4 -1
  4. package/core/auth/jwt.js +85 -13
  5. package/core/auth/magic-link.js +23 -5
  6. package/core/auth/manager.js +7 -1
  7. package/core/auth/redis-session.js +62 -25
  8. package/core/auth/session-manager.js +28 -2
  9. package/core/auth/sessions.js +182 -52
  10. package/core/console/eval.js +4 -1
  11. package/core/content/service.js +1 -0
  12. package/core/errors/domain.js +20 -2
  13. package/core/errors/http.js +1 -0
  14. package/core/http/hono-adapter.js +1 -1
  15. package/core/http/pipeline.js +36 -2
  16. package/core/index.js +12 -1
  17. package/core/mail/mailer.js +1 -1
  18. package/core/orm/eager-loader.js +81 -1
  19. package/core/orm/index.js +27 -13
  20. package/core/orm/json-fields.js +44 -13
  21. package/core/orm/model.js +12 -0
  22. package/core/orm/query-builder.js +33 -15
  23. package/core/orm/repository.js +59 -12
  24. package/core/orm/scopes.js +54 -4
  25. package/core/services/index.js +9 -0
  26. package/core/shutdown/shutdown-manager.js +57 -38
  27. package/core/studio/inspector.js +9 -1
  28. package/core/studio/server.js +24 -8
  29. package/core/utils/string.js +77 -16
  30. package/core/validation/index.js +18 -0
  31. package/index.d.ts +172 -8
  32. package/index.js +4 -0
  33. package/package.json +18 -8
  34. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  35. package/plugins/admin-panel/index.js +24 -11
  36. package/plugins/admin-panel/modules/user-management.js +3 -2
  37. package/plugins/csrf/index.js +2 -1
  38. package/plugins/dashboard/index.js +27 -7
  39. package/src/app-context.js +9 -1
  40. package/src/file-router.js +12 -3
  41. package/src/plugin-manager.js +273 -48
  42. package/src/server.js +26 -33
  43. package/src/services/discovery.js +9 -1
  44. package/src/services/executor.js +271 -58
  45. package/src/services/index.js +52 -3
  46. package/src/services/loader.js +120 -0
  47. package/src/services/memoize.js +223 -49
  48. package/src/services/registry.js +492 -94
  49. package/src/services/validator.js +164 -13
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Webspresso Services Module Loader
3
+ * Handles synchronous CommonJS and asynchronous native dynamic import (ESM)
4
+ * with robust export normalization and dev-mode cache busting.
5
+ * @module src/services/loader
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const path = require('path');
11
+ const { pathToFileURL } = require('url');
12
+
13
+ /**
14
+ * Execute native dynamic import() via Function constructor to prevent
15
+ * compilers/bundlers from down-leveling import() into require().
16
+ * @param {string} specifier - Module URL string
17
+ * @returns {Promise<any>}
18
+ */
19
+ function nativeImport(specifier) {
20
+ return new Function('specifier', 'return import(specifier);')(specifier);
21
+ }
22
+
23
+ /**
24
+ * Load a module synchronously via CommonJS require
25
+ * @param {string} filePath - Absolute file path
26
+ * @param {Object} [options]
27
+ * @param {boolean} [options.isDev=false] - Invalidate require.cache if true
28
+ * @returns {any} Exported module
29
+ */
30
+ function loadModuleSync(filePath, options = {}) {
31
+ const resolved = require.resolve(filePath);
32
+ if (options.isDev) {
33
+ try {
34
+ delete require.cache[resolved];
35
+ } catch (_) {}
36
+ }
37
+ return require(resolved);
38
+ }
39
+
40
+ /**
41
+ * Load a module asynchronously supporting both CommonJS and native ES Modules (ESM)
42
+ * @param {string} filePath - Absolute file path
43
+ * @param {Object} [options]
44
+ * @param {boolean} [options.isDev=false] - Cache busting via timestamp query param
45
+ * @returns {Promise<any>} Exported module
46
+ */
47
+ async function loadModuleAsync(filePath, options = {}) {
48
+ const isDev = Boolean(options.isDev);
49
+ const ext = path.extname(filePath);
50
+
51
+ // If not explicitly .mjs, try synchronous CommonJS require first for maximum speed
52
+ if (ext !== '.mjs') {
53
+ try {
54
+ return loadModuleSync(filePath, { isDev });
55
+ } catch (err) {
56
+ // If Node.js throws ERR_REQUIRE_ESM, fall through to native dynamic import
57
+ if (err.code !== 'ERR_REQUIRE_ESM' && !err.message?.includes('ERR_REQUIRE_ESM')) {
58
+ throw err;
59
+ }
60
+ }
61
+ }
62
+
63
+ // Load via native dynamic import() with file URL
64
+ let specifier = pathToFileURL(path.resolve(filePath)).href;
65
+ if (isDev) {
66
+ specifier += `?t=${Date.now()}`;
67
+ }
68
+
69
+ return nativeImport(specifier);
70
+ }
71
+
72
+ /**
73
+ * Normalize and extract service definition from various module export conventions:
74
+ * 1. module.exports = defineService({ handler })
75
+ * 2. module.exports = async function() {}
76
+ * 3. export default defineService({ handler })
77
+ * 4. export default async function() {}
78
+ * 5. export const handler = async function() {}
79
+ * 6. export const service = defineService({ handler })
80
+ *
81
+ * @param {any} mod - Loaded module
82
+ * @returns {any} Extracted service definition or handler
83
+ */
84
+ function extractModuleExport(mod) {
85
+ if (mod === null || mod === undefined) {
86
+ return mod;
87
+ }
88
+
89
+ // TypeScript / Babel transpiled default
90
+ if (mod.__esModule && mod.default !== undefined) {
91
+ return mod.default;
92
+ }
93
+
94
+ // Native ESM default export
95
+ if (mod.default !== undefined) {
96
+ const d = mod.default;
97
+ if (typeof d === 'function' || (typeof d === 'object' && d !== null && typeof d.handler === 'function')) {
98
+ return d;
99
+ }
100
+ }
101
+
102
+ // Named export: export const handler = ...
103
+ if (typeof mod.handler === 'function') {
104
+ return mod;
105
+ }
106
+
107
+ // Named export: export const service = defineService(...)
108
+ if (mod.service && typeof mod.service === 'object' && typeof mod.service.handler === 'function') {
109
+ return mod.service;
110
+ }
111
+
112
+ return mod.default !== undefined ? mod.default : mod;
113
+ }
114
+
115
+ module.exports = {
116
+ nativeImport,
117
+ loadModuleSync,
118
+ loadModuleAsync,
119
+ extractModuleExport,
120
+ };
@@ -1,10 +1,5 @@
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
1
  const { parseDurationMs } = require('../../core/utils/time');
2
+ const { TaggedLruCache, normalizeTags } = require('../../core/cache/tagged-lru');
8
3
 
9
4
  /**
10
5
  * Parse TTL duration into milliseconds
@@ -94,8 +89,11 @@ function stableCacheKey(input, seen = new WeakSet()) {
94
89
  /**
95
90
  * Memoize an async or sync function with:
96
91
  * - Cache stampede / thundering herd protection (in-flight promise deduping)
97
- * - LRU cache eviction
92
+ * - Zero-allocation O(1) LRU cache eviction
98
93
  * - TTL expiration
94
+ * - Bidirectional tag-based invalidation with zero memory leaks
95
+ * - Dynamic tag resolvers: `(input, ctx) => ['user:' + input.id]`
96
+ * - Pluggable custom or distributed cache providers (Redis, memory, etc.)
99
97
  * - Cache mutation protection (safe cloning)
100
98
  * - Safe serialization (BigInt, circular refs, Buffers)
101
99
  *
@@ -105,7 +103,9 @@ function stableCacheKey(input, seen = new WeakSet()) {
105
103
  * @param {number} [options.maxSize=500] - Max cache entries before LRU eviction
106
104
  * @param {boolean} [options.clone=true] - Clone cached results to prevent mutation
107
105
  * @param {Function} [options.key] - Custom cache key generator `(input, ctx) => string`
108
- * @returns {Function} Memoized function with .clear(), .invalidate(), .delete(), .has(), .size
106
+ * @param {string|string[]|Function} [options.tags] - Static tags or dynamic tag resolver `(input, ctx) => string|string[]`
107
+ * @param {Object} [options.provider] - Pluggable cache provider (e.g. Redis, distributed store)
108
+ * @returns {Function} Memoized function with .clear(), .invalidate(), .delete(), .has(), .size, .invalidateByTag(), .invalidateByTags()
109
109
  */
110
110
  function memoize(fn, options = {}) {
111
111
  if (typeof fn !== 'function') {
@@ -116,13 +116,53 @@ function memoize(fn, options = {}) {
116
116
  const maxSize = Number(options.maxSize) || 500;
117
117
  const shouldClone = options.clone !== false;
118
118
  const customKeyFn = typeof options.key === 'function' ? options.key : null;
119
+ const tagsOption = options.tags;
120
+ const provider = options.provider || null;
119
121
 
120
- /** @type {Map<string, { value: *, expiresAt: number }>} */
121
- const cache = new Map();
122
-
123
- /** @type {Map<string, Promise<*>>} In-flight request deduplication map */
122
+ /** @type {Map<string, Promise<*>>} In-flight request deduplication map (Stampede protection) */
124
123
  const inFlight = new Map();
125
124
 
125
+ /** @type {TaggedLruCache|null} Local zero-leak tagged LRU cache if no external provider */
126
+ const localCache = provider
127
+ ? null
128
+ : new TaggedLruCache({
129
+ maxEntries: maxSize,
130
+ defaultTtl: ttlMs,
131
+ });
132
+
133
+ // Fallback tag tracking for basic external providers that lack native tag support
134
+ const providerTagToKeys = new Map();
135
+ const providerKeyToTags = new Map();
136
+
137
+ function trackProviderTags(key, tags) {
138
+ if (!tags || tags.length === 0) return;
139
+ untrackProviderKey(key);
140
+ providerKeyToTags.set(key, new Set(tags));
141
+ for (const t of tags) {
142
+ let set = providerTagToKeys.get(t);
143
+ if (!set) {
144
+ set = new Set();
145
+ providerTagToKeys.set(t, set);
146
+ }
147
+ set.add(key);
148
+ }
149
+ }
150
+
151
+ function untrackProviderKey(key) {
152
+ const tags = providerKeyToTags.get(key);
153
+ if (!tags) return;
154
+ for (const t of tags) {
155
+ const set = providerTagToKeys.get(t);
156
+ if (set) {
157
+ set.delete(key);
158
+ if (set.size === 0) {
159
+ providerTagToKeys.delete(t);
160
+ }
161
+ }
162
+ }
163
+ providerKeyToTags.delete(key);
164
+ }
165
+
126
166
  function getKey(input, ctx) {
127
167
  if (customKeyFn) {
128
168
  return String(customKeyFn(input, ctx));
@@ -130,20 +170,20 @@ function memoize(fn, options = {}) {
130
170
  return stableCacheKey(input);
131
171
  }
132
172
 
133
- async function memoized(input, ctx) {
134
- const key = getKey(input, ctx);
135
- const now = Date.now();
136
- const hit = cache.get(key);
137
-
138
- if (hit) {
139
- if (hit.expiresAt > now) {
140
- // Refresh LRU position
141
- cache.delete(key);
142
- cache.set(key, hit);
143
- return shouldClone ? safeClone(hit.value) : hit.value;
173
+ function resolveTags(input, ctx) {
174
+ if (!tagsOption) return [];
175
+ if (typeof tagsOption === 'function') {
176
+ try {
177
+ return normalizeTags(tagsOption(input, ctx));
178
+ } catch (_) {
179
+ return [];
144
180
  }
145
- cache.delete(key);
146
181
  }
182
+ return normalizeTags(tagsOption);
183
+ }
184
+
185
+ async function memoized(input, ctx) {
186
+ const key = getKey(input, ctx);
147
187
 
148
188
  // 1. In-flight Promise Deduping (Thundering Herd / Cache Stampede Protection)
149
189
  if (inFlight.has(key)) {
@@ -151,7 +191,29 @@ function memoize(fn, options = {}) {
151
191
  return shouldClone ? safeClone(inFlightResult) : inFlightResult;
152
192
  }
153
193
 
154
- // 2. Execute and track in-flight promise
194
+ // 2. Check Cache Hit
195
+ if (provider) {
196
+ const hit = await provider.get(key);
197
+ if (hit !== undefined && hit !== null) {
198
+ return shouldClone ? safeClone(hit) : hit;
199
+ }
200
+ if (typeof provider.has === 'function') {
201
+ const exists = await provider.has(key);
202
+ if (exists) {
203
+ return shouldClone ? safeClone(hit) : hit;
204
+ }
205
+ }
206
+ } else {
207
+ const hit = localCache.get(key);
208
+ if (hit !== undefined) {
209
+ return shouldClone ? safeClone(hit) : hit;
210
+ }
211
+ if (localCache.has(key)) {
212
+ return undefined;
213
+ }
214
+ }
215
+
216
+ // 3. Execute and track in-flight promise
155
217
  const executionPromise = (async () => {
156
218
  try {
157
219
  return await fn(input, ctx);
@@ -170,18 +232,34 @@ function memoize(fn, options = {}) {
170
232
  throw execErr;
171
233
  }
172
234
 
173
- // 3. LRU Eviction when exceeding maxSize
174
- if (cache.size >= maxSize) {
175
- const oldestKey = cache.keys().next().value;
176
- if (oldestKey !== undefined) {
177
- cache.delete(oldestKey);
235
+ // 4. Store result with tags
236
+ const entryTags = resolveTags(input, ctx);
237
+ const valToStore = shouldClone ? safeClone(result) : result;
238
+
239
+ if (provider) {
240
+ const hasNativeTagSupport = typeof provider.invalidateTag === 'function' ||
241
+ typeof provider.invalidateByTag === 'function' ||
242
+ typeof provider.invalidateTags === 'function' ||
243
+ typeof provider.invalidateByTags === 'function';
244
+
245
+ if (!hasNativeTagSupport && entryTags.length > 0) {
246
+ trackProviderTags(key, entryTags);
178
247
  }
179
- }
180
248
 
181
- cache.set(key, {
182
- value: shouldClone ? safeClone(result) : result,
183
- expiresAt: Date.now() + ttlMs,
184
- });
249
+ if (typeof provider.set === 'function') {
250
+ await provider.set(key, valToStore, {
251
+ ttl: ttlMs,
252
+ ttlMs,
253
+ tags: entryTags,
254
+ });
255
+ }
256
+ } else {
257
+ localCache.set(key, valToStore, {
258
+ tags: entryTags,
259
+ ttlMs,
260
+ ttl: ttlMs,
261
+ });
262
+ }
185
263
 
186
264
  return shouldClone ? safeClone(result) : result;
187
265
  }
@@ -189,40 +267,135 @@ function memoize(fn, options = {}) {
189
267
  memoized.invalidate = function invalidate(input, ctx) {
190
268
  const key = getKey(input, ctx);
191
269
  inFlight.delete(key);
192
- return cache.delete(key);
270
+ if (provider) {
271
+ untrackProviderKey(key);
272
+ if (typeof provider.delete === 'function') return provider.delete(key);
273
+ if (typeof provider.del === 'function') return provider.del(key);
274
+ return false;
275
+ }
276
+ return localCache.delete(key);
193
277
  };
194
278
 
195
279
  memoized.delete = memoized.invalidate;
196
280
 
281
+ memoized.invalidateByTag = function invalidateByTag(tag) {
282
+ const tagStr = String(tag);
283
+
284
+ if (provider) {
285
+ if (typeof provider.invalidateTag === 'function') {
286
+ return provider.invalidateTag(tagStr);
287
+ }
288
+ if (typeof provider.invalidateByTag === 'function') {
289
+ return provider.invalidateByTag(tagStr);
290
+ }
291
+ if (typeof provider.invalidateTags === 'function') {
292
+ return provider.invalidateTags([tagStr]);
293
+ }
294
+ if (typeof provider.invalidateByTags === 'function') {
295
+ return provider.invalidateByTags([tagStr]);
296
+ }
297
+
298
+ // Fallback tag tracking for basic providers
299
+ const keys = providerTagToKeys.get(tagStr);
300
+ if (!keys || keys.size === 0) return 0;
301
+ const keyArr = Array.from(keys);
302
+ providerTagToKeys.delete(tagStr);
303
+ for (const k of keyArr) {
304
+ inFlight.delete(k);
305
+ untrackProviderKey(k);
306
+ }
307
+ const delOps = keyArr.map(k => {
308
+ if (typeof provider.delete === 'function') return provider.delete(k);
309
+ if (typeof provider.del === 'function') return provider.del(k);
310
+ return false;
311
+ });
312
+ const hasPromise = delOps.some(p => p && typeof p.then === 'function');
313
+ if (hasPromise) {
314
+ return Promise.all(delOps).then(() => keyArr.length);
315
+ }
316
+ return keyArr.length;
317
+ }
318
+
319
+ // TaggedLruCache
320
+ const keys = localCache.getKeysForTag(tagStr);
321
+ for (const k of keys) {
322
+ inFlight.delete(k);
323
+ }
324
+ return localCache.invalidateTag(tagStr);
325
+ };
326
+
327
+ memoized.invalidateByTags = function invalidateByTags(tags) {
328
+ const normTags = normalizeTags(tags);
329
+ if (normTags.length === 0) return 0;
330
+
331
+ if (provider) {
332
+ if (typeof provider.invalidateTags === 'function') {
333
+ return provider.invalidateTags(normTags);
334
+ }
335
+ if (typeof provider.invalidateByTags === 'function') {
336
+ return provider.invalidateByTags(normTags);
337
+ }
338
+ const results = [];
339
+ for (const t of normTags) {
340
+ results.push(memoized.invalidateByTag(t));
341
+ }
342
+ const hasPromise = results.some(r => r && typeof r.then === 'function');
343
+ if (hasPromise) {
344
+ return Promise.all(results).then(counts =>
345
+ counts.reduce((acc, c) => acc + (typeof c === 'number' ? c : 0), 0)
346
+ );
347
+ }
348
+ return results.reduce((acc, c) => acc + (typeof c === 'number' ? c : 0), 0);
349
+ }
350
+
351
+ // TaggedLruCache
352
+ for (const t of normTags) {
353
+ const keys = localCache.getKeysForTag(t);
354
+ for (const k of keys) {
355
+ inFlight.delete(k);
356
+ }
357
+ }
358
+ return localCache.invalidateTags(normTags);
359
+ };
360
+
361
+ memoized.invalidateTag = memoized.invalidateByTag;
362
+ memoized.invalidateTags = memoized.invalidateByTags;
363
+
197
364
  memoized.clear = function clear() {
198
365
  inFlight.clear();
199
- cache.clear();
366
+ if (provider) {
367
+ providerTagToKeys.clear();
368
+ providerKeyToTags.clear();
369
+ if (typeof provider.clear === 'function') return provider.clear();
370
+ return true;
371
+ }
372
+ return localCache.clear();
200
373
  };
201
374
 
202
375
  memoized.has = function has(input, ctx) {
203
376
  const key = getKey(input, ctx);
204
- const hit = cache.get(key);
205
- if (!hit) return false;
206
- if (hit.expiresAt <= Date.now()) {
207
- cache.delete(key);
377
+ if (provider) {
378
+ if (typeof provider.has === 'function') return provider.has(key);
208
379
  return false;
209
380
  }
210
- return true;
381
+ return localCache.has(key);
211
382
  };
212
383
 
213
384
  Object.defineProperty(memoized, 'size', {
214
385
  get() {
215
- // Clean expired entries on size access
216
- const now = Date.now();
217
- for (const [k, v] of cache.entries()) {
218
- if (v.expiresAt <= now) {
219
- cache.delete(k);
220
- }
386
+ if (provider) {
387
+ if (typeof provider.size === 'number') return provider.size;
388
+ if (typeof provider.getSizeStats === 'function') return provider.getSizeStats().entries;
389
+ return providerKeyToTags.size;
221
390
  }
222
- return cache.size;
391
+ localCache.pruneExpired();
392
+ return localCache.size;
223
393
  },
394
+ configurable: true,
224
395
  });
225
396
 
397
+ memoized.cache = provider || localCache;
398
+
226
399
  return memoized;
227
400
  }
228
401
 
@@ -231,4 +404,5 @@ module.exports = {
231
404
  parseTtlMs,
232
405
  stableCacheKey,
233
406
  safeClone,
407
+ normalizeTags,
234
408
  };