webspresso 0.0.103 → 0.0.105

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 (165) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +4 -3
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/orm-map-html.js +2 -8
  19. package/bin/utils/project.js +2 -2
  20. package/core/auth/hash.js +5 -4
  21. package/core/auth/jwt.js +6 -57
  22. package/core/auth/magic-link.js +4 -5
  23. package/core/auth/manager.js +3 -5
  24. package/core/auth/oauth.js +45 -30
  25. package/core/auth/totp.js +5 -16
  26. package/core/auth/user-agent.js +1 -21
  27. package/core/cache/byte-size.js +123 -0
  28. package/core/cache/index.js +21 -0
  29. package/core/cache/lru.js +477 -0
  30. package/core/cache/tagged-lru.js +346 -0
  31. package/core/commands/registry.js +6 -20
  32. package/core/content/renderer.js +1 -13
  33. package/core/early-hints/index.js +267 -0
  34. package/core/errors/domain.js +48 -0
  35. package/core/errors/http.js +31 -0
  36. package/core/errors/index.js +10 -0
  37. package/core/errors/middleware.js +2 -8
  38. package/core/errors/normalize.js +88 -2
  39. package/core/events/discovery.js +6 -24
  40. package/core/events/dispatcher.js +2 -7
  41. package/core/forms/form-builder.js +1 -9
  42. package/core/health/index.js +41 -0
  43. package/core/health/middleware.js +213 -0
  44. package/core/health/normalize.js +140 -0
  45. package/core/health/probes.js +203 -0
  46. package/core/limits/index.js +34 -0
  47. package/core/limits/middleware.js +317 -0
  48. package/core/limits/normalize.js +222 -0
  49. package/core/orm/batch.js +70 -0
  50. package/core/orm/cache/fingerprint.js +2 -2
  51. package/core/orm/cache/memory-provider.js +27 -110
  52. package/core/orm/index.js +76 -8
  53. package/core/orm/json-fields.js +22 -8
  54. package/core/orm/model.js +10 -0
  55. package/core/orm/query-builder.js +63 -9
  56. package/core/orm/query-filters.js +393 -0
  57. package/core/orm/repository.js +318 -19
  58. package/core/orm/schema-helpers.js +8 -10
  59. package/core/orm/scopes.js +321 -1
  60. package/core/orm/transaction.js +10 -2
  61. package/core/orm/utils.js +4 -86
  62. package/core/queue/adapters/database.js +4 -8
  63. package/core/queue/manager.js +55 -41
  64. package/core/resilience/circuit-breaker.js +391 -0
  65. package/core/resilience/errors.js +63 -0
  66. package/core/resilience/index.js +53 -0
  67. package/core/resilience/registry.js +97 -0
  68. package/core/resilience/retry.js +203 -0
  69. package/core/schema-cache.js +9 -2
  70. package/core/shutdown/http-adapter.js +4 -1
  71. package/core/shutdown/shutdown-manager.js +132 -31
  72. package/core/utils/array.js +12 -0
  73. package/core/utils/base64url.js +8 -0
  74. package/core/utils/crypto.js +191 -0
  75. package/core/utils/ensure-leading-slash.js +6 -0
  76. package/core/utils/escape-html.js +7 -0
  77. package/core/utils/escape-regexp.js +7 -0
  78. package/core/utils/fetch.js +121 -0
  79. package/core/utils/fs.js +144 -0
  80. package/core/utils/get-client-ip.js +6 -0
  81. package/core/utils/hmac.js +6 -0
  82. package/core/utils/html.js +72 -0
  83. package/core/utils/ip.js +86 -0
  84. package/core/utils/json.js +98 -0
  85. package/core/utils/object.js +106 -0
  86. package/core/utils/random.js +9 -0
  87. package/core/utils/regex.js +77 -0
  88. package/core/utils/safe-compare.js +3 -0
  89. package/core/utils/sha256.js +6 -0
  90. package/core/utils/string.js +87 -0
  91. package/core/utils/strip-trailing-slash.js +6 -0
  92. package/core/utils/time.js +96 -0
  93. package/core/utils/timeout.js +348 -0
  94. package/core/utils/url.js +106 -0
  95. package/core/utils/walk.js +7 -0
  96. package/index.d.ts +627 -2
  97. package/index.js +158 -0
  98. package/package.json +1 -2
  99. package/plugins/admin-panel/auth.js +15 -7
  100. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  101. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  102. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  103. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  104. package/plugins/admin-panel/core/api-extensions.js +3 -80
  105. package/plugins/admin-panel/index.js +1 -1
  106. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  107. package/plugins/admin-panel/modules/menu.js +1 -1
  108. package/plugins/api-key/crypto.js +3 -26
  109. package/plugins/api-key/scopes.js +5 -7
  110. package/plugins/audit-log/api-handlers.js +8 -7
  111. package/plugins/audit-log/middleware.js +11 -2
  112. package/plugins/audit-log/parse.js +3 -3
  113. package/plugins/basic-auth/index.js +2 -26
  114. package/plugins/cors.js +2 -2
  115. package/plugins/csrf/index.js +7 -23
  116. package/plugins/dashboard/app.js +2 -2
  117. package/plugins/dashboard/index.js +5 -5
  118. package/plugins/data-exchange/record-selection.js +2 -5
  119. package/plugins/email/api-handlers.js +5 -18
  120. package/plugins/email/auth-bridge.js +3 -2
  121. package/plugins/file-manager/admin-component.js +3 -1
  122. package/plugins/file-manager/core.js +34 -4
  123. package/plugins/health-check.js +2 -1
  124. package/plugins/index.js +2 -0
  125. package/plugins/lazy.js +5 -6
  126. package/plugins/mcp/auth.js +1 -13
  127. package/plugins/mcp/discovery.js +18 -17
  128. package/plugins/mcp/index.js +23 -1
  129. package/plugins/mcp/transports/sse.js +10 -1
  130. package/plugins/orm-cache-admin/admin-component.js +3 -2
  131. package/plugins/polar/src/api.js +11 -3
  132. package/plugins/polar/src/webhooks.js +7 -13
  133. package/plugins/rate-limit/index.js +3 -1
  134. package/plugins/recaptcha/helpers.js +1 -7
  135. package/plugins/recaptcha/verify.js +37 -23
  136. package/plugins/redirect/index.js +57 -7
  137. package/plugins/rest-resources/index.js +1 -207
  138. package/plugins/rls/index.js +170 -0
  139. package/plugins/schema-explorer/admin-component.js +4 -2
  140. package/plugins/schema-explorer.js +40 -29
  141. package/plugins/seo-checker/index.js +5 -2
  142. package/plugins/site-analytics/tracking.js +21 -18
  143. package/plugins/sitemap.js +21 -17
  144. package/plugins/swagger.js +13 -17
  145. package/plugins/upload/index.js +4 -3
  146. package/plugins/upload/orphan-pruner.js +4 -3
  147. package/plugins/webhooks/crypto.js +6 -28
  148. package/plugins/webhooks/dispatcher.js +14 -9
  149. package/plugins/webhooks/index.js +6 -0
  150. package/plugins/webhooks/matcher.js +28 -17
  151. package/plugins/webhooks/sender.js +40 -13
  152. package/src/app-context.js +41 -5
  153. package/src/dev/banner.js +6 -2
  154. package/src/dev-reload/index.js +47 -6
  155. package/src/file-router.js +1 -3
  156. package/src/helpers.js +180 -33
  157. package/src/modules/module-discovery.js +1 -9
  158. package/src/pages/page-loader.js +53 -1
  159. package/src/plugin-manager.js +90 -17
  160. package/src/server.js +137 -26
  161. package/src/services/discovery.js +19 -40
  162. package/src/services/executor.js +86 -25
  163. package/src/services/memoize.js +3 -25
  164. package/src/services/registry.js +6 -0
  165. package/src/template/precompile.js +8 -19
@@ -0,0 +1,477 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso - Zero-Dependency High-Performance LRU Cache
5
+ * O(1) Least-Recently-Used in-memory cache with memory byte budget, TTL expiration,
6
+ * stampede protection (thundering herd prevention), and detailed observability metrics.
7
+ * @module core/cache/lru
8
+ */
9
+
10
+ const { approximateByteSize } = require('./byte-size');
11
+ const { parseDurationMs } = require('../utils/time');
12
+
13
+ /**
14
+ * Deep clone utility to isolate cached values if cloning is requested
15
+ * @param {*} value
16
+ * @returns {*}
17
+ */
18
+ function safeClone(value) {
19
+ if (value === null || typeof value !== 'object') {
20
+ return value;
21
+ }
22
+ if (typeof structuredClone === 'function') {
23
+ try {
24
+ return structuredClone(value);
25
+ } catch {
26
+ // Fallback if structuredClone fails on non-serializable elements
27
+ }
28
+ }
29
+ if (Array.isArray(value)) {
30
+ return value.map(item => safeClone(item));
31
+ }
32
+ if (value instanceof Date) {
33
+ return new Date(value.getTime());
34
+ }
35
+ const cloned = {};
36
+ for (const [k, v] of Object.entries(value)) {
37
+ cloned[k] = safeClone(v);
38
+ }
39
+ return cloned;
40
+ }
41
+
42
+ /**
43
+ * @typedef {Object} LruCacheOptions
44
+ * @property {number} [maxEntries=1000] - Maximum entry count (0 = unlimited count)
45
+ * @property {number} [maxBytes=0] - Maximum byte capacity (0 = disabled)
46
+ * @property {number} [maxMemoryMb=0] - Maximum megabytes capacity (converts to bytes)
47
+ * @property {string|number} [defaultTtl=0] - Default TTL in ms or string duration ('5m', '1h', 0 = no expiry)
48
+ * @property {function(*, string): number} [sizeCalculation] - Custom byte sizer function
49
+ * @property {boolean} [clone=false] - Whether to deep-clone items on read/write
50
+ * @property {function(string, *, 'evict'|'expire'|'delete'|'clear'): void} [onEvict] - Eviction callback
51
+ */
52
+
53
+ class LruCache {
54
+ /**
55
+ * @param {LruCacheOptions} [options]
56
+ */
57
+ constructor(options = {}) {
58
+ this.maxEntries = typeof options.maxEntries === 'number' ? Math.max(0, options.maxEntries) : 1000;
59
+
60
+ // Memory budget configuration
61
+ if (typeof options.maxMemoryMb === 'number' && options.maxMemoryMb > 0) {
62
+ this.maxBytes = Math.floor(options.maxMemoryMb * 1024 * 1024);
63
+ } else if (typeof options.maxBytes === 'number' && options.maxBytes > 0) {
64
+ this.maxBytes = Math.floor(options.maxBytes);
65
+ } else {
66
+ this.maxBytes = 0; // 0 = unlimited bytes
67
+ }
68
+
69
+ this.defaultTtlMs = options.defaultTtl ? parseDurationMs(options.defaultTtl, 0) : 0;
70
+ this.sizeCalculation = typeof options.sizeCalculation === 'function' ? options.sizeCalculation : null;
71
+ this.clone = options.clone === true;
72
+ this.onEvict = typeof options.onEvict === 'function' ? options.onEvict : null;
73
+
74
+ /** @type {Map<string, { value: *, size: number, expiresAt: number }>} */
75
+ this._store = new Map();
76
+
77
+ /** @type {Map<string, Promise<*>>} In-flight promises for stampede protection */
78
+ this._inFlight = new Map();
79
+
80
+ this.currentBytes = 0;
81
+
82
+ // Observability metrics
83
+ this.hits = 0;
84
+ this.misses = 0;
85
+ this.sets = 0;
86
+ this.evictions = 0;
87
+ this.expirations = 0;
88
+ }
89
+
90
+ /**
91
+ * Number of active entries stored in the cache
92
+ * @type {number}
93
+ */
94
+ get size() {
95
+ return this._store.size;
96
+ }
97
+
98
+ /**
99
+ * Hit rate ratio (0.0 to 1.0)
100
+ * @type {number}
101
+ */
102
+ get hitRate() {
103
+ const total = this.hits + this.misses;
104
+ return total > 0 ? this.hits / total : 0;
105
+ }
106
+
107
+ /**
108
+ * Calculate size of an item
109
+ * @private
110
+ */
111
+ _calcSize(value, key, explicitSize) {
112
+ if (typeof explicitSize === 'number' && explicitSize >= 0) {
113
+ return explicitSize;
114
+ }
115
+ if (this.sizeCalculation) {
116
+ return Math.max(0, this.sizeCalculation(value, key));
117
+ }
118
+ return Math.max(0, approximateByteSize(value));
119
+ }
120
+
121
+ /**
122
+ * Check if an entry has expired
123
+ * @private
124
+ */
125
+ _isExpired(entry) {
126
+ return entry.expiresAt > 0 && Date.now() > entry.expiresAt;
127
+ }
128
+
129
+ /**
130
+ * Remove an entry and invoke eviction handler
131
+ * @private
132
+ */
133
+ _removeEntry(key, entry, reason) {
134
+ this._store.delete(key);
135
+ this.currentBytes = Math.max(0, this.currentBytes - entry.size);
136
+
137
+ if (this.onEvict) {
138
+ try {
139
+ this.onEvict(key, entry.value, reason);
140
+ } catch (err) {
141
+ // Eviction callbacks must never crash the cache operations
142
+ process.nextTick(() => {
143
+ console.error(`[LruCache] onEvict callback failed for key "${key}":`, err);
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Evict the least recently used item (the first key in Map insertion order)
151
+ * @private
152
+ */
153
+ _evictLru() {
154
+ const oldestKey = this._store.keys().next().value;
155
+ if (oldestKey === undefined) return false;
156
+
157
+ const entry = this._store.get(oldestKey);
158
+ if (!entry) return false;
159
+
160
+ this.evictions++;
161
+ this._removeEntry(oldestKey, entry, 'evict');
162
+ return true;
163
+ }
164
+
165
+ /**
166
+ * Retrieve an item from the cache. Updates its LRU position to most-recently used.
167
+ *
168
+ * @param {string} key
169
+ * @returns {*} Cached value or undefined
170
+ */
171
+ get(key) {
172
+ const entry = this._store.get(key);
173
+
174
+ if (!entry) {
175
+ this.misses++;
176
+ return undefined;
177
+ }
178
+
179
+ if (this._isExpired(entry)) {
180
+ this.expirations++;
181
+ this.misses++;
182
+ this._removeEntry(key, entry, 'expire');
183
+ return undefined;
184
+ }
185
+
186
+ // Refresh LRU position by deleting and re-inserting at the end of the Map
187
+ this._store.delete(key);
188
+ this._store.set(key, entry);
189
+
190
+ this.hits++;
191
+ return this.clone ? safeClone(entry.value) : entry.value;
192
+ }
193
+
194
+ /**
195
+ * Read value without updating LRU position
196
+ *
197
+ * @param {string} key
198
+ * @returns {*}
199
+ */
200
+ peek(key) {
201
+ const entry = this._store.get(key);
202
+ if (!entry) return undefined;
203
+
204
+ if (this._isExpired(entry)) {
205
+ this.expirations++;
206
+ this._removeEntry(key, entry, 'expire');
207
+ return undefined;
208
+ }
209
+
210
+ return this.clone ? safeClone(entry.value) : entry.value;
211
+ }
212
+
213
+ /**
214
+ * Check if a key exists and is not expired
215
+ *
216
+ * @param {string} key
217
+ * @returns {boolean}
218
+ */
219
+ has(key) {
220
+ const entry = this._store.get(key);
221
+ if (!entry) return false;
222
+
223
+ if (this._isExpired(entry)) {
224
+ this.expirations++;
225
+ this._removeEntry(key, entry, 'expire');
226
+ return false;
227
+ }
228
+
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Store a key/value pair in the cache
234
+ *
235
+ * @param {string} key
236
+ * @param {*} value
237
+ * @param {Object} [options]
238
+ * @param {number|string} [options.ttl] - TTL duration string or ms
239
+ * @param {number} [options.ttlMs] - TTL duration in ms
240
+ * @param {number} [options.size] - Explicit byte size
241
+ * @returns {this}
242
+ */
243
+ set(key, value, options = {}) {
244
+ const entrySize = this._calcSize(value, key, options.size);
245
+
246
+ // If a single entry exceeds the entire byte budget, it cannot fit
247
+ if (this.maxBytes > 0 && entrySize > this.maxBytes) {
248
+ return this;
249
+ }
250
+
251
+ // If key already exists, subtract its previous size before calculating space
252
+ const existing = this._store.get(key);
253
+ if (existing) {
254
+ this._removeEntry(key, existing, 'delete');
255
+ }
256
+
257
+ // Capacity enforcement loop: evict LRU entries until under entry and byte limits
258
+ while (
259
+ (this.maxEntries > 0 && this._store.size >= this.maxEntries) ||
260
+ (this.maxBytes > 0 && (this.currentBytes + entrySize) > this.maxBytes)
261
+ ) {
262
+ const evicted = this._evictLru();
263
+ if (!evicted) break;
264
+ }
265
+
266
+ let ttlMs = this.defaultTtlMs;
267
+ if (typeof options.ttlMs === 'number') {
268
+ ttlMs = Math.max(0, options.ttlMs);
269
+ } else if (options.ttl !== undefined) {
270
+ ttlMs = parseDurationMs(options.ttl, 0);
271
+ }
272
+
273
+ const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
274
+ const storedValue = this.clone ? safeClone(value) : value;
275
+
276
+ const entry = {
277
+ value: storedValue,
278
+ size: entrySize,
279
+ expiresAt,
280
+ };
281
+
282
+ this._store.set(key, entry);
283
+ this.currentBytes += entrySize;
284
+ this.sets++;
285
+
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * Delete an entry by key
291
+ *
292
+ * @param {string} key
293
+ * @returns {boolean} True if deleted, false if not found
294
+ */
295
+ delete(key) {
296
+ const entry = this._store.get(key);
297
+ if (!entry) return false;
298
+
299
+ this._removeEntry(key, entry, 'delete');
300
+ return true;
301
+ }
302
+
303
+ /**
304
+ * Alias for delete
305
+ */
306
+ del(key) {
307
+ return this.delete(key);
308
+ }
309
+
310
+ /**
311
+ * Purge all items from the cache
312
+ * @returns {this}
313
+ */
314
+ clear() {
315
+ if (this.onEvict) {
316
+ for (const [k, entry] of this._store.entries()) {
317
+ try {
318
+ this.onEvict(k, entry.value, 'clear');
319
+ } catch {
320
+ // Ignore clear errors
321
+ }
322
+ }
323
+ }
324
+ this._store.clear();
325
+ this._inFlight.clear();
326
+ this.currentBytes = 0;
327
+ return this;
328
+ }
329
+
330
+ /**
331
+ * Sweeps and removes all expired entries actively
332
+ * @returns {number} Number of expired items pruned
333
+ */
334
+ pruneExpired() {
335
+ let count = 0;
336
+ const now = Date.now();
337
+ for (const [key, entry] of this._store.entries()) {
338
+ if (entry.expiresAt > 0 && now > entry.expiresAt) {
339
+ this.expirations++;
340
+ this._removeEntry(key, entry, 'expire');
341
+ count++;
342
+ }
343
+ }
344
+ return count;
345
+ }
346
+
347
+ /**
348
+ * Thundering herd / cache stampede protection.
349
+ * If item exists, returns it. If not, calls asyncFactory once and deduplicates
350
+ * concurrent requests awaiting the exact same key.
351
+ *
352
+ * @param {string} key
353
+ * @param {function(string): Promise<*>} asyncFactory
354
+ * @param {Object} [options] - Options passed to .set()
355
+ * @returns {Promise<*>}
356
+ */
357
+ async getOrSet(key, asyncFactory, options = {}) {
358
+ // 1. Check immediate cache hit
359
+ const cached = this.get(key);
360
+ if (cached !== undefined) {
361
+ return cached;
362
+ }
363
+
364
+ // 2. Check if another concurrent request is already fetching this key
365
+ if (this._inFlight.has(key)) {
366
+ const inFlightVal = await this._inFlight.get(key);
367
+ return this.clone ? safeClone(inFlightVal) : inFlightVal;
368
+ }
369
+
370
+ // 3. Launch execution and track in-flight promise
371
+ const executionPromise = (async () => {
372
+ try {
373
+ return await asyncFactory(key);
374
+ } finally {
375
+ this._inFlight.delete(key);
376
+ }
377
+ })();
378
+
379
+ this._inFlight.set(key, executionPromise);
380
+
381
+ let result;
382
+ try {
383
+ result = await executionPromise;
384
+ } catch (err) {
385
+ // Do not cache rejected promises / errors
386
+ throw err;
387
+ }
388
+
389
+ this.set(key, result, options);
390
+ return this.clone ? safeClone(result) : result;
391
+ }
392
+
393
+ /**
394
+ * Retrieve telemetry snapshot
395
+ */
396
+ getStats() {
397
+ return {
398
+ entries: this._store.size,
399
+ currentBytes: this.currentBytes,
400
+ maxEntries: this.maxEntries,
401
+ maxBytes: this.maxBytes,
402
+ hits: this.hits,
403
+ misses: this.misses,
404
+ hitRate: this.hitRate,
405
+ sets: this.sets,
406
+ evictions: this.evictions,
407
+ expirations: this.expirations,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Reset observability counters
413
+ */
414
+ resetStats() {
415
+ this.hits = 0;
416
+ this.misses = 0;
417
+ this.sets = 0;
418
+ this.evictions = 0;
419
+ this.expirations = 0;
420
+ }
421
+
422
+ /**
423
+ * Iterate over keys
424
+ */
425
+ keys() {
426
+ return this._store.keys();
427
+ }
428
+
429
+ /**
430
+ * Iterate over values
431
+ */
432
+ *values() {
433
+ const now = Date.now();
434
+ for (const entry of this._store.values()) {
435
+ if (!this._isExpired(entry)) {
436
+ yield this.clone ? safeClone(entry.value) : entry.value;
437
+ }
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Iterate over [key, value] pairs
443
+ */
444
+ *entries() {
445
+ const now = Date.now();
446
+ for (const [key, entry] of this._store.entries()) {
447
+ if (!this._isExpired(entry)) {
448
+ yield [key, this.clone ? safeClone(entry.value) : entry.value];
449
+ }
450
+ }
451
+ }
452
+
453
+ [Symbol.iterator]() {
454
+ return this.entries();
455
+ }
456
+
457
+ forEach(callback, thisArg) {
458
+ for (const [key, val] of this.entries()) {
459
+ callback.call(thisArg, val, key, this);
460
+ }
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Factory helper
466
+ * @param {LruCacheOptions} [options]
467
+ * @returns {LruCache}
468
+ */
469
+ function createLruCache(options) {
470
+ return new LruCache(options);
471
+ }
472
+
473
+ module.exports = {
474
+ LruCache,
475
+ createLruCache,
476
+ safeClone,
477
+ };