webspresso 0.0.102 → 0.0.104

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 (91) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
@@ -5,12 +5,73 @@
5
5
  */
6
6
 
7
7
  const crypto = require('crypto');
8
+ const { getClientIp: resolveClientIp } = require('../../core/utils/ip');
8
9
  const { detectBot } = require('./bot-patterns');
9
10
  const { detectCountry } = require('./geo');
10
11
 
11
12
  const STATIC_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif|mp4|webm)$/i;
12
13
  const DEFAULT_EXCLUDE = ['/_admin', '/api/', '/health', '/_next', '/__'];
13
14
 
15
+ /**
16
+ * Permanent / Fatal DB error codes that cannot be recovered by immediate retries.
17
+ */
18
+ const PERMANENT_ERROR_CODES = new Set([
19
+ 'SQLITE_FULL',
20
+ 'SQLITE_IOERR',
21
+ 'SQLITE_CORRUPT',
22
+ 'SQLITE_CANTOPEN',
23
+ 'SQLITE_NOTADB',
24
+ 'ER_DISK_FULL',
25
+ 'ENOSPC',
26
+ ]);
27
+
28
+ /**
29
+ * Patterns matching fatal disk full or corrupted database errors.
30
+ */
31
+ const PERMANENT_ERROR_PATTERNS = [
32
+ /database or disk is full/i,
33
+ /disk full/i,
34
+ /no space left on device/i,
35
+ /disk i\/o error/i,
36
+ /database disk image is malformed/i,
37
+ /file is not a database/i,
38
+ ];
39
+
40
+ /**
41
+ * Sanitize and format database error messages to prevent raw SQL query leakage in logs.
42
+ * Knex appends executed queries ("insert into ... - <actual error>") which can be 50-100 KB.
43
+ * @param {Error|Object|string} e
44
+ * @returns {string}
45
+ */
46
+ function formatDbError(e) {
47
+ if (!e) return 'Unknown error';
48
+
49
+ let msg;
50
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
51
+ msg = e.message.split(' - ').pop().trim();
52
+ } else if (typeof e.message === 'string') {
53
+ msg = e.message.trim();
54
+ } else if (typeof e === 'string') {
55
+ msg = e.trim();
56
+ } else {
57
+ msg = String(e.code || 'Database error');
58
+ }
59
+
60
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
61
+ }
62
+
63
+ /**
64
+ * Detect whether a database error is permanent/fatal (e.g. disk full, corruption).
65
+ * @param {Error|Object|string} e
66
+ * @returns {boolean}
67
+ */
68
+ function isPermanentDbError(e) {
69
+ if (!e) return false;
70
+ if (e.code && PERMANENT_ERROR_CODES.has(e.code)) return true;
71
+ const msg = typeof e.message === 'string' ? e.message : (typeof e === 'string' ? e : '');
72
+ return PERMANENT_ERROR_PATTERNS.some((pattern) => pattern.test(msg));
73
+ }
74
+
14
75
  /**
15
76
  * Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
16
77
  */
@@ -22,24 +83,25 @@ function quickHash(str) {
22
83
  * Get client IP from request, handling proxies
23
84
  */
24
85
  function getClientIp(req) {
25
- const forwarded = req.headers['x-forwarded-for'];
26
- if (forwarded) {
27
- return forwarded.split(',')[0].trim();
28
- }
29
- return req.headers['x-real-ip'] || req.ip || req.connection?.remoteAddress || '';
86
+ return resolveClientIp(req, '');
30
87
  }
31
88
 
32
89
  /**
33
90
  * Create tracking middleware
34
- * @param {Object} options
35
- * @param {Object} options.knex - Knex instance
91
+ * @param {Object} [options={}]
92
+ * @param {Object} [options.knex] - Knex instance
36
93
  * @param {string[]} [options.excludePaths] - Paths to exclude from tracking
37
94
  * @param {boolean} [options.trackBots=true] - Whether to record bot visits
38
95
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
39
96
  * @param {number} [options.batchSize=20] - Flush when queue reaches this size
40
97
  * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
98
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts per batch item before dropping
99
+ * @param {number} [options.maxBackoffMs=60000] - Max exponential backoff cooldown
100
+ * @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
101
+ * @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
41
102
  */
42
- function createTrackingMiddleware(options) {
103
+ function createTrackingMiddleware(options = {}) {
104
+ const opts = options || {};
43
105
  const {
44
106
  knex,
45
107
  excludePaths = [],
@@ -47,12 +109,26 @@ function createTrackingMiddleware(options) {
47
109
  tableName = 'analytics_page_views',
48
110
  batchSize = 20,
49
111
  flushIntervalMs = 3000,
50
- } = options;
112
+ maxRetries = 3,
113
+ maxBackoffMs = 60000,
114
+ maxQueueSize = 5000,
115
+ errorLogIntervalMs = 60000,
116
+ } = opts;
51
117
 
52
118
  const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
53
119
  let tableReady = false;
54
120
  const queue = [];
55
- let flushScheduled = false;
121
+
122
+ // Circuit breaker & backoff state
123
+ let failureCount = 0;
124
+ let nextAllowedFlushTime = 0;
125
+ let isFlushing = false;
126
+ let flushPromise = null;
127
+ let flushTimeout = null;
128
+
129
+ // Log throttling state
130
+ let lastErrorLogTime = 0;
131
+ let suppressedErrorCount = 0;
56
132
 
57
133
  async function ensureTable() {
58
134
  if (tableReady) return;
@@ -75,7 +151,26 @@ function createTrackingMiddleware(options) {
75
151
  }
76
152
  tableReady = true;
77
153
  } catch (e) {
78
- console.error('[site-analytics] Failed to ensure table:', e.message);
154
+ logError(e, 'Failed to ensure table');
155
+ throw e;
156
+ }
157
+ }
158
+
159
+ function logError(e, prefix = 'Batch insert failed') {
160
+ const now = Date.now();
161
+ const formatted = formatDbError(e);
162
+
163
+ if (lastErrorLogTime === 0 || now - lastErrorLogTime >= errorLogIntervalMs) {
164
+ const elapsedSec = lastErrorLogTime > 0 ? Math.round((now - lastErrorLogTime) / 1000) : 0;
165
+ if (suppressedErrorCount > 0) {
166
+ console.error(`[site-analytics] ${prefix} (suppressed ${suppressedErrorCount} occurrences in last ${elapsedSec}s): ${formatted}`);
167
+ } else {
168
+ console.error(`[site-analytics] ${prefix}: ${formatted}`);
169
+ }
170
+ lastErrorLogTime = now;
171
+ suppressedErrorCount = 0;
172
+ } else {
173
+ suppressedErrorCount++;
79
174
  }
80
175
  }
81
176
 
@@ -83,17 +178,17 @@ function createTrackingMiddleware(options) {
83
178
  const sessionMap = new Map();
84
179
  const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
85
180
  const MAX_SESSIONS = 10000;
86
- const MAX_QUEUE_SIZE = 5000;
87
181
 
88
182
  // Periodic session cleanup
89
- setInterval(() => {
183
+ const sessionCleanupInterval = setInterval(() => {
90
184
  const now = Date.now();
91
185
  for (const [vid, data] of sessionMap) {
92
186
  if (now - data.lastSeen > SESSION_TIMEOUT * 2) {
93
187
  sessionMap.delete(vid);
94
188
  }
95
189
  }
96
- }, 5 * 60 * 1000).unref();
190
+ }, 5 * 60 * 1000);
191
+ if (sessionCleanupInterval.unref) sessionCleanupInterval.unref();
97
192
 
98
193
  function getSessionId(visitorId) {
99
194
  const now = Date.now();
@@ -115,86 +210,193 @@ function createTrackingMiddleware(options) {
115
210
  }
116
211
 
117
212
  async function flushQueue() {
118
- if (queue.length === 0) return;
119
- const batch = queue.splice(0, queue.length);
213
+ if (flushPromise) return flushPromise;
214
+ if (queue.length === 0 || !knex) return;
120
215
 
121
- try {
122
- await ensureTable();
123
- for (const row of batch) {
124
- row.created_at = knex.fn.now();
125
- }
126
- await knex(tableName).insert(batch);
127
- } catch (e) {
128
- console.error('[site-analytics] Batch insert failed:', e.message);
129
- // Re-queue failed items with max size limit to prevent memory leak
130
- if (queue.length + batch.length <= MAX_QUEUE_SIZE) {
131
- queue.unshift(...batch);
132
- } else {
133
- const available = Math.max(0, MAX_QUEUE_SIZE - queue.length);
134
- if (available > 0) {
135
- queue.unshift(...batch.slice(batch.length - available));
216
+ const now = Date.now();
217
+ if (now < nextAllowedFlushTime) {
218
+ scheduleFlush(nextAllowedFlushTime - now);
219
+ return;
220
+ }
221
+
222
+ isFlushing = true;
223
+ flushPromise = (async () => {
224
+ const batch = queue.splice(0, queue.length);
225
+
226
+ try {
227
+ await ensureTable();
228
+ const dbBatch = batch.map(({ _retries, ...row }) => ({
229
+ ...row,
230
+ created_at: knex.fn.now(),
231
+ }));
232
+ await knex(tableName).insert(dbBatch);
233
+
234
+ // Successfully inserted: reset circuit breaker & failure counters
235
+ failureCount = 0;
236
+ nextAllowedFlushTime = 0;
237
+ lastErrorLogTime = 0;
238
+ suppressedErrorCount = 0;
239
+ } catch (e) {
240
+ failureCount++;
241
+ const backoffDelay = Math.min(flushIntervalMs * Math.pow(2, failureCount), maxBackoffMs);
242
+ nextAllowedFlushTime = Date.now() + backoffDelay;
243
+
244
+ // Rate-limited sanitized logging (no raw SQL query leakage)
245
+ logError(e, 'Batch insert failed');
246
+
247
+ // Schedule next attempt respecting backoff
248
+ scheduleFlush(backoffDelay);
249
+
250
+ // Drop policy: If fatal/permanent error (disk full, corrupted DB, etc.), drop entire batch
251
+ if (!isPermanentDbError(e)) {
252
+ const retryable = [];
253
+ for (const item of batch) {
254
+ item._retries = (item._retries || 0) + 1;
255
+ if (item._retries < maxRetries) {
256
+ retryable.push(item);
257
+ }
258
+ }
259
+
260
+ // Re-queue items that haven't exceeded retry limits, respecting maxQueueSize
261
+ if (retryable.length > 0) {
262
+ const available = Math.max(0, maxQueueSize - queue.length);
263
+ if (available > 0) {
264
+ queue.unshift(...retryable.slice(0, available));
265
+ }
266
+ }
136
267
  }
268
+ } finally {
269
+ isFlushing = false;
270
+ flushPromise = null;
137
271
  }
138
- }
272
+ })();
273
+
274
+ return flushPromise;
139
275
  }
140
276
 
141
- function scheduleFlush() {
142
- if (flushScheduled || queue.length === 0) return;
143
- flushScheduled = true;
144
- setTimeout(() => {
145
- flushScheduled = false;
277
+ function scheduleFlush(customDelay) {
278
+ if (flushTimeout || queue.length === 0) return;
279
+ const now = Date.now();
280
+ const delay = customDelay !== undefined
281
+ ? customDelay
282
+ : Math.max(flushIntervalMs, nextAllowedFlushTime - now, 0);
283
+
284
+ flushTimeout = setTimeout(() => {
285
+ flushTimeout = null;
146
286
  flushQueue().catch(() => {});
147
- }, flushIntervalMs);
287
+ }, delay);
288
+ if (flushTimeout.unref) flushTimeout.unref();
148
289
  }
149
290
 
150
291
  // Periodic flush (catches low-traffic case)
151
- setInterval(() => {
152
- if (queue.length > 0) flushQueue().catch(() => {});
153
- }, flushIntervalMs).unref();
292
+ const periodicFlushInterval = setInterval(() => {
293
+ if (queue.length > 0 && Date.now() >= nextAllowedFlushTime && !isFlushing) {
294
+ flushQueue().catch(() => {});
295
+ }
296
+ }, flushIntervalMs);
297
+ if (periodicFlushInterval.unref) periodicFlushInterval.unref();
154
298
 
155
- return async function trackingMiddleware(req, res, next) {
156
- next();
299
+ async function trackingMiddleware(req, res, next) {
300
+ if (typeof next === 'function') next();
157
301
 
158
- if (req.method !== 'GET') return;
302
+ if (!req || req.method !== 'GET') return;
159
303
 
160
- const path = req.path;
161
- if (STATIC_EXT.test(path)) return;
304
+ const path = req.path || req.url || '';
305
+ if (!path || STATIC_EXT.test(path)) return;
162
306
  if (allExcludes.some(prefix => path.startsWith(prefix))) return;
163
307
 
164
308
  try {
165
- const userAgent = req.headers['user-agent'] || '';
309
+ const headers = req.headers || {};
310
+ const userAgent = headers['user-agent'] || '';
166
311
  const ip = getClientIp(req);
167
312
  const { isBot, botName } = detectBot(userAgent);
168
313
 
169
314
  if (isBot && !trackBots) return;
170
315
 
316
+ // Protect queue under pressure or during active database failures:
317
+ // Drop bots immediately to preserve queue capacity and avoid wasting DB I/O
318
+ if (isBot && (failureCount > 0 || queue.length >= maxQueueSize * 0.7)) {
319
+ return;
320
+ }
321
+
171
322
  const ipHash = quickHash(ip);
172
323
  const visitorId = quickHash(ip + '|' + userAgent);
173
- const sessionId = getSessionId(visitorId);
324
+
325
+ // Do not thrash human sessionMap with bot/crawler visits
326
+ const sessionId = isBot
327
+ ? quickHash('bot-' + (botName || 'crawler') + '-' + visitorId)
328
+ : getSessionId(visitorId);
329
+
174
330
  const country = detectCountry(req);
175
- const referrer = req.headers['referer'] || req.headers['referrer'] || null;
176
-
177
- queue.push({
178
- session_id: sessionId,
179
- visitor_id: visitorId,
180
- path,
181
- referrer: referrer ? referrer.slice(0, 500) : null,
182
- user_agent: userAgent.slice(0, 1000),
183
- ip_hash: ipHash,
184
- country,
185
- is_bot: isBot,
186
- bot_name: botName,
187
- });
331
+ const referrer = headers['referer'] || headers['referrer'] || null;
332
+
333
+ if (queue.length < maxQueueSize) {
334
+ queue.push({
335
+ session_id: sessionId,
336
+ visitor_id: visitorId,
337
+ path,
338
+ referrer: referrer ? referrer.slice(0, 500) : null,
339
+ user_agent: userAgent.slice(0, 1000),
340
+ ip_hash: ipHash,
341
+ country,
342
+ is_bot: isBot,
343
+ bot_name: botName,
344
+ });
345
+ }
188
346
 
189
347
  if (queue.length >= batchSize) {
190
- flushQueue().catch(() => {});
348
+ if (Date.now() >= nextAllowedFlushTime) {
349
+ flushQueue().catch(() => {});
350
+ } else {
351
+ scheduleFlush();
352
+ }
191
353
  } else {
192
354
  scheduleFlush();
193
355
  }
194
356
  } catch (e) {
195
357
  // Non-blocking: don't let tracking errors affect the app
196
358
  }
359
+ }
360
+
361
+ // Diagnostic / testing accessors
362
+ trackingMiddleware.flush = () => flushQueue();
363
+ trackingMiddleware.getQueue = () => queue;
364
+ trackingMiddleware.getFailureCount = () => failureCount;
365
+ trackingMiddleware.getNextAllowedFlushTime = () => nextAllowedFlushTime;
366
+ trackingMiddleware.resetState = (opts = {}) => {
367
+ failureCount = 0;
368
+ nextAllowedFlushTime = 0;
369
+ const resetLogs = typeof opts === 'boolean' ? opts : !!opts.resetLogs;
370
+ if (resetLogs) {
371
+ lastErrorLogTime = 0;
372
+ suppressedErrorCount = 0;
373
+ }
374
+ queue.length = 0;
375
+ if (flushTimeout) {
376
+ clearTimeout(flushTimeout);
377
+ flushTimeout = null;
378
+ }
197
379
  };
380
+ trackingMiddleware.stop = () => {
381
+ if (flushTimeout) {
382
+ clearTimeout(flushTimeout);
383
+ flushTimeout = null;
384
+ }
385
+ clearInterval(sessionCleanupInterval);
386
+ clearInterval(periodicFlushInterval);
387
+ if (queue.length > 0 && knex) {
388
+ return flushQueue().catch(() => {});
389
+ }
390
+ return Promise.resolve();
391
+ };
392
+
393
+ return trackingMiddleware;
198
394
  }
199
395
 
200
- module.exports = { createTrackingMiddleware };
396
+ module.exports = {
397
+ createTrackingMiddleware,
398
+ formatDbError,
399
+ isPermanentDbError,
400
+ PERMANENT_ERROR_CODES,
401
+ PERMANENT_ERROR_PATTERNS,
402
+ };
@@ -157,8 +157,9 @@ function sitemapPlugin(options = {}) {
157
157
  const dynamicExclusions = [...exclude];
158
158
  const registeredSources = [...dynamicSources];
159
159
 
160
- // Cached URLs (for dynamic sources)
160
+ // Cached URLs and pre-rendered XML (for dynamic sources)
161
161
  let cachedUrls = null;
162
+ let cachedXml = null;
162
163
  let cacheTime = null;
163
164
  const cacheMaxAge = options.cacheMaxAge || 5 * 60 * 1000; // 5 minutes default
164
165
 
@@ -326,7 +327,8 @@ function sitemapPlugin(options = {}) {
326
327
  */
327
328
  addUrl(path, opts = {}) {
328
329
  dynamicUrls.push({ path, ...opts });
329
- cachedUrls = null; // Invalidate cache
330
+ cachedUrls = null;
331
+ cachedXml = null;
330
332
  },
331
333
 
332
334
  /**
@@ -335,7 +337,8 @@ function sitemapPlugin(options = {}) {
335
337
  */
336
338
  exclude(pattern) {
337
339
  dynamicExclusions.push(pattern);
338
- cachedUrls = null; // Invalidate cache
340
+ cachedUrls = null;
341
+ cachedXml = null;
339
342
  },
340
343
 
341
344
  /**
@@ -347,7 +350,8 @@ function sitemapPlugin(options = {}) {
347
350
  throw new Error('urlPattern is required for dynamic source');
348
351
  }
349
352
  registeredSources.push(source);
350
- cachedUrls = null; // Invalidate cache
353
+ cachedUrls = null;
354
+ cachedXml = null;
351
355
  },
352
356
 
353
357
  /**
@@ -369,6 +373,7 @@ function sitemapPlugin(options = {}) {
369
373
  */
370
374
  invalidateCache() {
371
375
  cachedUrls = null;
376
+ cachedXml = null;
372
377
  cacheTime = null;
373
378
  }
374
379
  },
@@ -384,28 +389,26 @@ function sitemapPlugin(options = {}) {
384
389
  try {
385
390
  // Check cache
386
391
  const now = Date.now();
387
- if (cachedUrls && cacheTime && (now - cacheTime) < cacheMaxAge) {
392
+ if (cachedXml && cacheTime && (now - cacheTime) < cacheMaxAge) {
388
393
  res.type('application/xml');
389
- return res.send(generateSitemapXml(cachedUrls, {
390
- hostname,
391
- defaultChangefreq: changefreq,
392
- defaultPriority: priority
393
- }));
394
+ return res.send(cachedXml);
394
395
  }
395
396
 
396
397
  // Build URLs
397
398
  const urls = await buildAllUrls(ctx.routes, dbInstance);
399
+ const xml = generateSitemapXml(urls, {
400
+ hostname,
401
+ defaultChangefreq: changefreq,
402
+ defaultPriority: priority
403
+ });
398
404
 
399
405
  // Cache results
400
406
  cachedUrls = urls;
407
+ cachedXml = xml;
401
408
  cacheTime = now;
402
409
 
403
410
  res.type('application/xml');
404
- res.send(generateSitemapXml(urls, {
405
- hostname,
406
- defaultChangefreq: changefreq,
407
- defaultPriority: priority
408
- }));
411
+ res.send(xml);
409
412
  } catch (err) {
410
413
  console.error('Sitemap generation error:', err);
411
414
  res.status(500).send('Error generating sitemap');
@@ -11,20 +11,7 @@ const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes'
11
11
 
12
12
  const PKG = require(path.join(__dirname, '..', 'package.json'));
13
13
 
14
- /**
15
- * HTML Escaping
16
- * @param {string} str
17
- * @returns {string}
18
- */
19
- function escapeHtml(str) {
20
- if (!str) return '';
21
- return String(str)
22
- .replace(/&/g, '&amp;')
23
- .replace(/</g, '&lt;')
24
- .replace(/>/g, '&gt;')
25
- .replace(/"/g, '&quot;')
26
- .replace(/'/g, '&#039;');
27
- }
14
+ const { escapeHtml } = require('../core/utils/html');
28
15
 
29
16
  /**
30
17
  * Generates Swagger UI HTML page
@@ -258,10 +245,15 @@ function swaggerPlugin(options = {}) {
258
245
 
259
246
  const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
260
247
 
248
+ let cachedDoc = null;
249
+
261
250
  // 1. OpenAPI 3 JSON Endpoint
262
251
  ctx.addRoute('get', jsonPath, (req, res) => {
263
252
  if (forbid(req, res)) return;
264
253
  try {
254
+ if (cachedDoc && process.env.NODE_ENV === 'production') {
255
+ return res.json(cachedDoc);
256
+ }
265
257
  const doc = buildOpenApiDocument({
266
258
  routes: ctx.routes || [],
267
259
  pagesDir: ctx.options.pagesDir,
@@ -274,6 +266,9 @@ function swaggerPlugin(options = {}) {
274
266
  },
275
267
  servers: [{ url: server.replace(/\/$/, '') }],
276
268
  });
269
+ if (process.env.NODE_ENV === 'production') {
270
+ cachedDoc = doc;
271
+ }
277
272
  res.json(doc);
278
273
  } catch (err) {
279
274
  console.warn('[swagger] OpenAPI generation failed:', err.message);
@@ -20,24 +20,7 @@ function generateSecret(prefix = 'whsec_', byteLength = 24) {
20
20
  return `${prefix || ''}${bytes}`;
21
21
  }
22
22
 
23
- /**
24
- * Perform constant-time string comparison to prevent timing attacks
25
- *
26
- * @param {string} a
27
- * @param {string} b
28
- * @returns {boolean}
29
- */
30
- function safeCompare(a, b) {
31
- if (typeof a !== 'string' || typeof b !== 'string') {
32
- return false;
33
- }
34
- const bufA = Buffer.from(a, 'utf8');
35
- const bufB = Buffer.from(b, 'utf8');
36
- if (bufA.length !== bufB.length) {
37
- return false;
38
- }
39
- return crypto.timingSafeEqual(bufA, bufB);
40
- }
23
+ const { safeCompare } = require('../../core/utils/crypto');
41
24
 
42
25
  /**
43
26
  * Sign a webhook payload using HMAC-SHA256
@@ -461,9 +461,14 @@ class WebhookDispatcher extends EventEmitter {
461
461
  attempt: attempt + 1,
462
462
  autoRetry: true,
463
463
  }).catch((err) => {
464
- this.emit('error', err);
464
+ if (this.listenerCount('error') > 0) {
465
+ this.emit('error', err);
466
+ } else {
467
+ console.error('[webhooks] Delivery retry failed:', err?.message || err);
468
+ }
465
469
  });
466
470
  }, delayMs);
471
+ if (timer.unref) timer.unref();
467
472
  this._activeTimers.add(timer);
468
473
  }
469
474
  } else {
@@ -48,6 +48,25 @@ function normalizeEvents(events) {
48
48
  return ['*'];
49
49
  }
50
50
 
51
+ const regexCache = new Map();
52
+ const MAX_REGEX_CACHE = 1000;
53
+
54
+ function getPatternRegex(pattern) {
55
+ let cached = regexCache.get(pattern);
56
+ if (!cached) {
57
+ if (regexCache.size >= MAX_REGEX_CACHE) {
58
+ const first = regexCache.keys().next().value;
59
+ if (first !== undefined) regexCache.delete(first);
60
+ }
61
+ const escaped = pattern
62
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
63
+ .replace(/\*/g, '.*');
64
+ cached = new RegExp(`^${escaped}$`, 'i');
65
+ regexCache.set(pattern, cached);
66
+ }
67
+ return cached;
68
+ }
69
+
51
70
  /**
52
71
  * Test whether an event pattern matches an actual event name
53
72
  *
@@ -56,12 +75,15 @@ function normalizeEvents(events) {
56
75
  * @returns {boolean}
57
76
  */
58
77
  function matchEvent(pattern, eventName) {
59
- if (!pattern || !eventName) {
78
+ if (typeof pattern !== 'string' || typeof eventName !== 'string') {
60
79
  return false;
61
80
  }
62
81
 
63
82
  const p = pattern.trim();
64
83
  const e = eventName.trim();
84
+ if (!p || !e) {
85
+ return false;
86
+ }
65
87
 
66
88
  // 1. Wildcard matches all
67
89
  if (p === '*') {
@@ -73,15 +95,8 @@ function matchEvent(pattern, eventName) {
73
95
  return true;
74
96
  }
75
97
 
76
- // 3. Glob / Wildcard conversion
77
- // Convert 'user.*' to regular expression '^user\.[^.]+$' or '^user\..+$'
78
- // Support prefix, middle, or suffix wildcards safely
79
- const escaped = p
80
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
81
- .replace(/\*/g, '.*');
82
-
83
- const regex = new RegExp(`^${escaped}$`, 'i');
84
- return regex.test(e);
98
+ // 3. Glob / Wildcard conversion with bounded cache
99
+ return getPatternRegex(p).test(e);
85
100
  }
86
101
 
87
102
  /**