mastercontroller 1.3.13 → 1.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MasterTimeout.js CHANGED
@@ -10,18 +10,52 @@
10
10
  *
11
11
  * Inspired by Rails ActionController::Timeout and Django MIDDLEWARE_TIMEOUT
12
12
  *
13
- * @version 1.0.0
13
+ * @version 1.1.0 - FAANG-level refactor with production hardening
14
14
  */
15
15
 
16
16
  const { logger } = require('./error/MasterErrorLogger');
17
17
 
18
+ // Configuration Constants
19
+ const TIMEOUT_CONFIG = {
20
+ DEFAULT_TIMEOUT: 120000, // 120 seconds
21
+ MIN_TIMEOUT: 1000, // 1 second minimum
22
+ MAX_TIMEOUT: 3600000, // 1 hour maximum
23
+ MAX_ACTIVE_REQUESTS: 10000, // Prevent memory exhaustion
24
+ HANDLER_TIMEOUT: 5000, // 5 seconds for custom handlers
25
+ CLEANUP_INTERVAL: 60000, // Clean up stale requests every minute
26
+ REQUEST_ID_LENGTH: 15 // Length of generated request IDs
27
+ };
28
+
29
+ const HTTP_STATUS = {
30
+ GATEWAY_TIMEOUT: 504,
31
+ INTERNAL_ERROR: 500
32
+ };
33
+
18
34
  class MasterTimeout {
19
35
  constructor() {
20
- this.globalTimeout = 120000; // 120 seconds default
36
+ this.globalTimeout = TIMEOUT_CONFIG.DEFAULT_TIMEOUT;
21
37
  this.routeTimeouts = new Map();
22
38
  this.activeRequests = new Map();
23
39
  this.timeoutHandlers = [];
24
40
  this.enabled = true;
41
+
42
+ // Metrics tracking
43
+ this.metrics = {
44
+ totalRequests: 0,
45
+ totalTimeouts: 0,
46
+ peakConcurrent: 0,
47
+ totalDuration: 0
48
+ };
49
+
50
+ // Start periodic cleanup
51
+ this.cleanupTimer = setInterval(() => {
52
+ this._cleanupStaleRequests();
53
+ }, TIMEOUT_CONFIG.CLEANUP_INTERVAL);
54
+
55
+ // Prevent cleanup timer from keeping process alive
56
+ if (this.cleanupTimer.unref) {
57
+ this.cleanupTimer.unref();
58
+ }
25
59
  }
26
60
 
27
61
  // Lazy-load master to avoid circular dependency (Google-style lazy initialization)
@@ -39,17 +73,43 @@ class MasterTimeout {
39
73
  * @param {Number} options.globalTimeout - Default timeout in ms (default: 120000)
40
74
  * @param {Boolean} options.enabled - Enable/disable timeouts (default: true)
41
75
  * @param {Function} options.onTimeout - Custom timeout handler
76
+ * @throws {TypeError} If options is not an object
77
+ * @throws {Error} If globalTimeout is out of valid range
78
+ * @throws {TypeError} If enabled is not a boolean
79
+ * @throws {TypeError} If onTimeout is not a function
42
80
  */
43
81
  init(options = {}) {
44
- if (options.globalTimeout) {
82
+ // Input validation
83
+ if (typeof options !== 'object' || options === null) {
84
+ throw new TypeError('Options must be an object');
85
+ }
86
+
87
+ if (options.globalTimeout !== undefined) {
88
+ if (typeof options.globalTimeout !== 'number' || isNaN(options.globalTimeout)) {
89
+ throw new TypeError('globalTimeout must be a number');
90
+ }
91
+
92
+ if (options.globalTimeout < TIMEOUT_CONFIG.MIN_TIMEOUT ||
93
+ options.globalTimeout > TIMEOUT_CONFIG.MAX_TIMEOUT) {
94
+ throw new Error(
95
+ `globalTimeout must be between ${TIMEOUT_CONFIG.MIN_TIMEOUT}ms and ${TIMEOUT_CONFIG.MAX_TIMEOUT}ms`
96
+ );
97
+ }
98
+
45
99
  this.globalTimeout = options.globalTimeout;
46
100
  }
47
101
 
48
102
  if (options.enabled !== undefined) {
103
+ if (typeof options.enabled !== 'boolean') {
104
+ throw new TypeError('enabled must be a boolean');
105
+ }
49
106
  this.enabled = options.enabled;
50
107
  }
51
108
 
52
- if (options.onTimeout && typeof options.onTimeout === 'function') {
109
+ if (options.onTimeout !== undefined) {
110
+ if (typeof options.onTimeout !== 'function') {
111
+ throw new TypeError('onTimeout must be a function');
112
+ }
53
113
  this.timeoutHandlers.push(options.onTimeout);
54
114
  }
55
115
 
@@ -66,16 +126,36 @@ class MasterTimeout {
66
126
  /**
67
127
  * Set timeout for specific route pattern
68
128
  *
69
- * @param {String} routePattern - Route pattern (e.g., '/api/*', '/admin/reports')
129
+ * @param {String|RegExp} routePattern - Route pattern (e.g., '/api/*', '/admin/reports')
70
130
  * @param {Number} timeout - Timeout in milliseconds
131
+ * @throws {TypeError} If routePattern is not string or RegExp
132
+ * @throws {Error} If routePattern is empty
133
+ * @throws {TypeError} If timeout is not a number
134
+ * @throws {Error} If timeout is out of valid range
71
135
  *
72
136
  * @example
73
137
  * this._master.timeout.setRouteTimeout('/api/*', 30000); // 30 seconds for APIs
74
138
  * this._master.timeout.setRouteTimeout('/admin/reports', 300000); // 5 minutes for reports
75
139
  */
76
140
  setRouteTimeout(routePattern, timeout) {
77
- if (typeof timeout !== 'number' || timeout <= 0) {
78
- throw new Error('Timeout must be a positive number in milliseconds');
141
+ // Validate routePattern
142
+ if (typeof routePattern !== 'string' && !(routePattern instanceof RegExp)) {
143
+ throw new TypeError('Route pattern must be a string or RegExp');
144
+ }
145
+
146
+ if (typeof routePattern === 'string' && (!routePattern || routePattern.trim() === '')) {
147
+ throw new Error('Route pattern cannot be empty');
148
+ }
149
+
150
+ // Validate timeout
151
+ if (typeof timeout !== 'number' || isNaN(timeout)) {
152
+ throw new TypeError('Timeout must be a number');
153
+ }
154
+
155
+ if (timeout < TIMEOUT_CONFIG.MIN_TIMEOUT || timeout > TIMEOUT_CONFIG.MAX_TIMEOUT) {
156
+ throw new Error(
157
+ `Timeout must be between ${TIMEOUT_CONFIG.MIN_TIMEOUT}ms and ${TIMEOUT_CONFIG.MAX_TIMEOUT}ms`
158
+ );
79
159
  }
80
160
 
81
161
  this.routeTimeouts.set(routePattern, timeout);
@@ -83,7 +163,7 @@ class MasterTimeout {
83
163
  logger.info({
84
164
  code: 'MC_TIMEOUT_ROUTE_SET',
85
165
  message: 'Route timeout configured',
86
- routePattern,
166
+ routePattern: routePattern.toString(),
87
167
  timeout
88
168
  });
89
169
 
@@ -98,6 +178,16 @@ class MasterTimeout {
98
178
  * @returns {Number} - Timeout in milliseconds
99
179
  */
100
180
  getTimeoutForPath(path) {
181
+ // Validate input
182
+ if (typeof path !== 'string') {
183
+ logger.warn({
184
+ code: 'MC_TIMEOUT_INVALID_PATH',
185
+ message: 'Invalid path provided to getTimeoutForPath',
186
+ path
187
+ });
188
+ return this.globalTimeout;
189
+ }
190
+
101
191
  // Check route-specific timeouts
102
192
  for (const [pattern, timeout] of this.routeTimeouts.entries()) {
103
193
  if (this._pathMatches(path, pattern)) {
@@ -113,15 +203,39 @@ class MasterTimeout {
113
203
  * Start timeout tracking for request
114
204
  *
115
205
  * @param {Object} ctx - Request context
116
- * @returns {String} - Request ID
206
+ * @returns {String|null} - Request ID or null if disabled/error
207
+ * @throws {TypeError} If ctx is not an object
208
+ * @throws {Error} If ctx.response is missing
209
+ * @throws {Error} If max active requests exceeded
117
210
  */
118
211
  startTracking(ctx) {
119
212
  if (!this.enabled) {
120
213
  return null;
121
214
  }
122
215
 
216
+ // Input validation
217
+ if (!ctx || typeof ctx !== 'object') {
218
+ throw new TypeError('Context must be an object');
219
+ }
220
+
221
+ if (!ctx.response || typeof ctx.response !== 'object') {
222
+ throw new Error('Context must have a response object');
223
+ }
224
+
225
+ // Check max active requests (DoS protection)
226
+ if (this.activeRequests.size >= TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS) {
227
+ logger.error({
228
+ code: 'MC_TIMEOUT_MAX_REQUESTS',
229
+ message: 'Maximum active requests exceeded',
230
+ current: this.activeRequests.size,
231
+ max: TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS
232
+ });
233
+ throw new Error(`Maximum active requests (${TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS}) exceeded`);
234
+ }
235
+
123
236
  const requestId = this._generateRequestId();
124
- const timeout = this.getTimeoutForPath(ctx.pathName || ctx.request.url);
237
+ const path = ctx.pathName || (ctx.request && ctx.request.url) || '/';
238
+ const timeout = this.getTimeoutForPath(path);
125
239
  const startTime = Date.now();
126
240
 
127
241
  const timer = setTimeout(() => {
@@ -132,18 +246,33 @@ class MasterTimeout {
132
246
  timer,
133
247
  timeout,
134
248
  startTime,
135
- path: ctx.pathName || ctx.request.url,
136
- method: ctx.type || ctx.request.method.toLowerCase()
249
+ path,
250
+ method: ctx.type || (ctx.request && ctx.request.method.toLowerCase()) || 'unknown'
137
251
  });
138
252
 
139
- // Attach cleanup to response finish
140
- ctx.response.once('finish', () => {
141
- this.stopTracking(requestId);
142
- });
253
+ // Update metrics
254
+ this.metrics.totalRequests++;
255
+ if (this.activeRequests.size > this.metrics.peakConcurrent) {
256
+ this.metrics.peakConcurrent = this.activeRequests.size;
257
+ }
143
258
 
144
- ctx.response.once('close', () => {
145
- this.stopTracking(requestId);
146
- });
259
+ // Attach cleanup to response finish (with error handling)
260
+ try {
261
+ ctx.response.once('finish', () => {
262
+ this.stopTracking(requestId);
263
+ });
264
+
265
+ ctx.response.once('close', () => {
266
+ this.stopTracking(requestId);
267
+ });
268
+ } catch (err) {
269
+ logger.warn({
270
+ code: 'MC_TIMEOUT_LISTENER_ATTACH_FAILED',
271
+ message: 'Failed to attach response listeners',
272
+ requestId,
273
+ error: err.message
274
+ });
275
+ }
147
276
 
148
277
  return requestId;
149
278
  }
@@ -152,22 +281,42 @@ class MasterTimeout {
152
281
  * Stop timeout tracking for request
153
282
  *
154
283
  * @param {String} requestId - Request ID
284
+ * @returns {Boolean} - True if request was found and stopped
285
+ * @throws {TypeError} If requestId is not a string
155
286
  */
156
287
  stopTracking(requestId) {
288
+ // Input validation
289
+ if (typeof requestId !== 'string' || !requestId) {
290
+ throw new TypeError('Request ID must be a non-empty string');
291
+ }
292
+
293
+ // Race condition protection: check if request still exists
157
294
  const tracked = this.activeRequests.get(requestId);
158
295
 
159
296
  if (tracked) {
160
297
  clearTimeout(tracked.timer);
298
+
299
+ // Update metrics
300
+ const duration = Date.now() - tracked.startTime;
301
+ this.metrics.totalDuration += duration;
302
+
161
303
  this.activeRequests.delete(requestId);
304
+ return true;
162
305
  }
306
+
307
+ return false;
163
308
  }
164
309
 
165
310
  /**
166
311
  * Handle request timeout
167
312
  *
168
313
  * @private
314
+ * @param {String} requestId - Request ID
315
+ * @param {Object} ctx - Request context
316
+ * @param {Number} startTime - Request start timestamp
169
317
  */
170
318
  _handleTimeout(requestId, ctx, startTime) {
319
+ // Race condition protection: check if request still exists
171
320
  const tracked = this.activeRequests.get(requestId);
172
321
 
173
322
  if (!tracked) {
@@ -176,6 +325,9 @@ class MasterTimeout {
176
325
 
177
326
  const duration = Date.now() - startTime;
178
327
 
328
+ // Update metrics
329
+ this.metrics.totalTimeouts++;
330
+
179
331
  // Log timeout
180
332
  logger.error({
181
333
  code: 'MC_REQUEST_TIMEOUT',
@@ -187,39 +339,138 @@ class MasterTimeout {
187
339
  duration
188
340
  });
189
341
 
190
- // Call custom handlers
342
+ // Call custom handlers with their own timeout protection
191
343
  for (const handler of this.timeoutHandlers) {
192
- try {
193
- handler(ctx, {
194
- requestId,
195
- path: tracked.path,
196
- method: tracked.method,
344
+ this._executeHandlerWithTimeout(handler, ctx, {
345
+ requestId,
346
+ path: tracked.path,
347
+ method: tracked.method,
348
+ timeout: tracked.timeout,
349
+ duration
350
+ });
351
+ }
352
+
353
+ // Send timeout response if not already sent
354
+ try {
355
+ if (ctx.response && !ctx.response.headersSent) {
356
+ ctx.response.statusCode = HTTP_STATUS.GATEWAY_TIMEOUT;
357
+ ctx.response.setHeader('Content-Type', 'application/json');
358
+ ctx.response.end(JSON.stringify({
359
+ error: 'Request Timeout',
360
+ message: 'The server did not receive a complete request within the allowed time',
361
+ code: 'MC_REQUEST_TIMEOUT',
197
362
  timeout: tracked.timeout,
198
- duration
199
- });
200
- } catch (err) {
363
+ path: tracked.path
364
+ }));
365
+ }
366
+ } catch (err) {
367
+ logger.error({
368
+ code: 'MC_TIMEOUT_RESPONSE_FAILED',
369
+ message: 'Failed to send timeout response',
370
+ requestId,
371
+ error: err.message
372
+ });
373
+ }
374
+
375
+ // Cleanup
376
+ try {
377
+ this.stopTracking(requestId);
378
+ } catch (err) {
379
+ // If stopTracking throws (invalid requestId), just delete directly
380
+ this.activeRequests.delete(requestId);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Execute custom handler with timeout protection
386
+ *
387
+ * @private
388
+ * @param {Function} handler - Custom timeout handler
389
+ * @param {Object} ctx - Request context
390
+ * @param {Object} info - Timeout information
391
+ */
392
+ _executeHandlerWithTimeout(handler, ctx, info) {
393
+ let handlerCompleted = false;
394
+
395
+ const handlerTimer = setTimeout(() => {
396
+ if (!handlerCompleted) {
201
397
  logger.error({
202
- code: 'MC_TIMEOUT_HANDLER_ERROR',
203
- message: 'Timeout handler threw error',
204
- error: err.message
398
+ code: 'MC_TIMEOUT_HANDLER_TIMEOUT',
399
+ message: 'Timeout handler exceeded maximum execution time',
400
+ maxTime: TIMEOUT_CONFIG.HANDLER_TIMEOUT
205
401
  });
206
402
  }
403
+ }, TIMEOUT_CONFIG.HANDLER_TIMEOUT);
404
+
405
+ try {
406
+ const result = handler(ctx, info);
407
+
408
+ // If handler returns a promise, handle it
409
+ if (result && typeof result.then === 'function') {
410
+ result
411
+ .then(() => {
412
+ handlerCompleted = true;
413
+ clearTimeout(handlerTimer);
414
+ })
415
+ .catch(err => {
416
+ handlerCompleted = true;
417
+ clearTimeout(handlerTimer);
418
+ logger.error({
419
+ code: 'MC_TIMEOUT_HANDLER_ERROR',
420
+ message: 'Timeout handler promise rejected',
421
+ error: err.message
422
+ });
423
+ });
424
+ } else {
425
+ handlerCompleted = true;
426
+ clearTimeout(handlerTimer);
427
+ }
428
+ } catch (err) {
429
+ handlerCompleted = true;
430
+ clearTimeout(handlerTimer);
431
+ logger.error({
432
+ code: 'MC_TIMEOUT_HANDLER_ERROR',
433
+ message: 'Timeout handler threw error',
434
+ error: err.message
435
+ });
207
436
  }
437
+ }
208
438
 
209
- // Send timeout response if not already sent
210
- if (!ctx.response.headersSent) {
211
- ctx.response.statusCode = 504; // Gateway Timeout
212
- ctx.response.setHeader('Content-Type', 'application/json');
213
- ctx.response.end(JSON.stringify({
214
- error: 'Request Timeout',
215
- message: 'The server did not receive a complete request within the allowed time',
216
- code: 'MC_REQUEST_TIMEOUT',
217
- timeout: tracked.timeout
218
- }));
439
+ /**
440
+ * Clean up stale requests that somehow weren't cleaned up properly
441
+ *
442
+ * @private
443
+ */
444
+ _cleanupStaleRequests() {
445
+ const now = Date.now();
446
+ let cleanedCount = 0;
447
+
448
+ for (const [requestId, tracked] of this.activeRequests.entries()) {
449
+ const elapsed = now - tracked.startTime;
450
+
451
+ // If request has been active for more than 2x its timeout, force cleanup
452
+ if (elapsed > tracked.timeout * 2) {
453
+ logger.warn({
454
+ code: 'MC_TIMEOUT_STALE_REQUEST',
455
+ message: 'Cleaning up stale request',
456
+ requestId,
457
+ elapsed,
458
+ timeout: tracked.timeout
459
+ });
460
+
461
+ clearTimeout(tracked.timer);
462
+ this.activeRequests.delete(requestId);
463
+ cleanedCount++;
464
+ }
219
465
  }
220
466
 
221
- // Cleanup
222
- this.stopTracking(requestId);
467
+ if (cleanedCount > 0) {
468
+ logger.info({
469
+ code: 'MC_TIMEOUT_CLEANUP',
470
+ message: 'Stale requests cleaned up',
471
+ count: cleanedCount
472
+ });
473
+ }
223
474
  }
224
475
 
225
476
  /**
@@ -236,14 +487,27 @@ class MasterTimeout {
236
487
  return;
237
488
  }
238
489
 
239
- const requestId = $that.startTracking(ctx);
240
- ctx.requestId = requestId;
490
+ let requestId = null;
241
491
 
242
492
  try {
493
+ requestId = $that.startTracking(ctx);
494
+ ctx.requestId = requestId;
495
+
243
496
  await next();
244
497
  } catch (err) {
245
- // Stop tracking on error
246
- $that.stopTracking(requestId);
498
+ // Stop tracking on error (with error handling)
499
+ if (requestId) {
500
+ try {
501
+ $that.stopTracking(requestId);
502
+ } catch (stopErr) {
503
+ logger.warn({
504
+ code: 'MC_TIMEOUT_STOP_FAILED',
505
+ message: 'Failed to stop tracking on error',
506
+ requestId,
507
+ error: stopErr.message
508
+ });
509
+ }
510
+ }
247
511
  throw err;
248
512
  }
249
513
  };
@@ -272,49 +536,138 @@ class MasterTimeout {
272
536
  }
273
537
 
274
538
  /**
275
- * Get current timeout statistics
539
+ * Shutdown timeout system and clean up all resources
540
+ *
541
+ * @returns {Object} - Cleanup statistics
542
+ */
543
+ shutdown() {
544
+ logger.info({
545
+ code: 'MC_TIMEOUT_SHUTDOWN',
546
+ message: 'Shutting down timeout system',
547
+ activeRequests: this.activeRequests.size
548
+ });
549
+
550
+ // Clear cleanup timer
551
+ if (this.cleanupTimer) {
552
+ clearInterval(this.cleanupTimer);
553
+ this.cleanupTimer = null;
554
+ }
555
+
556
+ // Clear all active request timers
557
+ let clearedCount = 0;
558
+ for (const [requestId, tracked] of this.activeRequests.entries()) {
559
+ clearTimeout(tracked.timer);
560
+ clearedCount++;
561
+ }
562
+
563
+ // Clear all tracked requests
564
+ this.activeRequests.clear();
565
+
566
+ // Clear handlers
567
+ const handlerCount = this.timeoutHandlers.length;
568
+ this.timeoutHandlers = [];
569
+
570
+ const stats = {
571
+ clearedRequests: clearedCount,
572
+ clearedHandlers: handlerCount,
573
+ finalMetrics: { ...this.metrics }
574
+ };
575
+
576
+ logger.info({
577
+ code: 'MC_TIMEOUT_SHUTDOWN_COMPLETE',
578
+ message: 'Timeout system shutdown complete',
579
+ ...stats
580
+ });
581
+
582
+ return stats;
583
+ }
584
+
585
+ /**
586
+ * Get current timeout statistics and metrics
276
587
  *
277
- * @returns {Object} - Timeout stats
588
+ * @returns {Object} - Comprehensive timeout stats
278
589
  */
279
590
  getStats() {
591
+ const now = Date.now();
592
+ const activeRequests = Array.from(this.activeRequests.entries()).map(([id, data]) => ({
593
+ requestId: id,
594
+ path: data.path,
595
+ method: data.method,
596
+ timeout: data.timeout,
597
+ elapsed: now - data.startTime,
598
+ remaining: Math.max(0, data.timeout - (now - data.startTime))
599
+ }));
600
+
280
601
  return {
281
602
  enabled: this.enabled,
282
603
  globalTimeout: this.globalTimeout,
283
604
  routeTimeouts: Array.from(this.routeTimeouts.entries()).map(([pattern, timeout]) => ({
284
- pattern,
605
+ pattern: pattern.toString(),
285
606
  timeout
286
607
  })),
287
608
  activeRequests: this.activeRequests.size,
288
- requests: Array.from(this.activeRequests.entries()).map(([id, data]) => ({
289
- requestId: id,
290
- path: data.path,
291
- method: data.method,
292
- timeout: data.timeout,
293
- elapsed: Date.now() - data.startTime,
294
- remaining: data.timeout - (Date.now() - data.startTime)
295
- }))
609
+ maxActiveRequests: TIMEOUT_CONFIG.MAX_ACTIVE_REQUESTS,
610
+ requests: activeRequests,
611
+
612
+ // Metrics
613
+ metrics: {
614
+ totalRequests: this.metrics.totalRequests,
615
+ totalTimeouts: this.metrics.totalTimeouts,
616
+ timeoutRate: this.metrics.totalRequests > 0
617
+ ? (this.metrics.totalTimeouts / this.metrics.totalRequests * 100).toFixed(2) + '%'
618
+ : '0%',
619
+ peakConcurrent: this.metrics.peakConcurrent,
620
+ averageResponseTime: this.metrics.totalRequests > 0
621
+ ? Math.round(this.metrics.totalDuration / this.metrics.totalRequests)
622
+ : 0
623
+ },
624
+
625
+ // Configuration
626
+ config: {
627
+ minTimeout: TIMEOUT_CONFIG.MIN_TIMEOUT,
628
+ maxTimeout: TIMEOUT_CONFIG.MAX_TIMEOUT,
629
+ handlerTimeout: TIMEOUT_CONFIG.HANDLER_TIMEOUT,
630
+ cleanupInterval: TIMEOUT_CONFIG.CLEANUP_INTERVAL
631
+ }
296
632
  };
297
633
  }
298
634
 
299
635
  /**
300
- * Check if path matches pattern
636
+ * Check if path matches pattern with enhanced wildcard support
301
637
  *
302
638
  * @private
639
+ * @param {String} path - Request path
640
+ * @param {String|RegExp} pattern - Pattern to match
641
+ * @returns {Boolean} - True if path matches pattern
303
642
  */
304
643
  _pathMatches(path, pattern) {
305
644
  if (typeof pattern === 'string') {
306
- // Normalize paths
645
+ // Normalize paths (remove leading/trailing slashes)
307
646
  const normalizedPath = '/' + path.replace(/^\/|\/$/g, '');
308
647
  const normalizedPattern = '/' + pattern.replace(/^\/|\/$/g, '');
309
648
 
310
- // Wildcard support
649
+ // Exact match
650
+ if (normalizedPath === normalizedPattern) {
651
+ return true;
652
+ }
653
+
654
+ // Wildcard support: /api/* matches /api/users, /api/posts, etc.
311
655
  if (normalizedPattern.endsWith('/*')) {
312
656
  const prefix = normalizedPattern.slice(0, -2);
313
657
  return normalizedPath === prefix || normalizedPath.startsWith(prefix + '/');
314
658
  }
315
659
 
316
- // Exact match
317
- return normalizedPath === normalizedPattern;
660
+ // Multiple wildcards: /api/*/posts matches /api/v1/posts, /api/v2/posts
661
+ if (normalizedPattern.includes('*')) {
662
+ const regexPattern = normalizedPattern
663
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special chars
664
+ .replace(/\*/g, '[^/]+'); // * matches any segment
665
+ const regex = new RegExp('^' + regexPattern + '$');
666
+ return regex.test(normalizedPath);
667
+ }
668
+
669
+ // Prefix match (for backwards compatibility)
670
+ return normalizedPath.startsWith(normalizedPattern + '/');
318
671
  }
319
672
 
320
673
  if (pattern instanceof RegExp) {
@@ -328,9 +681,12 @@ class MasterTimeout {
328
681
  * Generate unique request ID
329
682
  *
330
683
  * @private
684
+ * @returns {String} - Unique request ID
331
685
  */
332
686
  _generateRequestId() {
333
- return `req_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
687
+ const timestamp = Date.now();
688
+ const random = Math.random().toString(36).substring(2, TIMEOUT_CONFIG.REQUEST_ID_LENGTH);
689
+ return `req_${timestamp}_${random}`;
334
690
  }
335
691
  }
336
692