@mayurbhusare/resilient-queue-ui 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,936 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const fs = require("node:fs");
5
+
6
+ const { QueueRegistry, createQueueRegistry } = require("./registry");
7
+ const { handleGetMetrics } = require("./handlers/metrics");
8
+ const { handleGetDLQ } = require("./handlers/dlq");
9
+ const { handleGetJob, serializeJob } = require("./handlers/job");
10
+ const { handleGetQueues } = require("./handlers/queues");
11
+
12
+ const UI_DIR = path.resolve(__dirname, "ui");
13
+
14
+ const MIME_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".js": "application/javascript; charset=utf-8",
18
+ ".json": "application/json; charset=utf-8",
19
+ ".svg": "image/svg+xml",
20
+ ".ico": "image/x-icon"
21
+ };
22
+
23
+ /**
24
+ * Sanitizes internal error messages before including them in API responses.
25
+ * Redacts connection URI credentials, authentication tokens, and passwords based on known patterns,
26
+ * preventing accidental information leakage of infrastructure details while preserving ordinary diagnostics.
27
+ *
28
+ * Note: Pattern-based sanitization targets known credential formats and does not guarantee
29
+ * elimination of unknown, unstructured, or custom secret representations.
30
+ *
31
+ * @param {string|undefined|null} message
32
+ * @returns {string}
33
+ */
34
+ function sanitizeErrorMessage(message) {
35
+ if (typeof message !== "string" || message.trim().length === 0) {
36
+ return "Internal server error";
37
+ }
38
+
39
+ // 1. Redact credentials in connection URIs (e.g. redis://:pass@host:6379, redis://user:pass@host:6379, including %XX URL encoding)
40
+ let cleaned = message.replace(
41
+ /\b([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)(?:[^:\s/@]+:)?([^@\s/]+)@/g,
42
+ "$1[redacted]@"
43
+ );
44
+
45
+ // 2. Redact AUTH <secret> and Bearer <token>
46
+ cleaned = cleaned.replace(
47
+ /\b(AUTH|Bearer)\s+['"]?[^\s'";&]+['"]?/gi,
48
+ "$1 [redacted]"
49
+ );
50
+
51
+ // 3. Redact key-value secret pairs (password=..., token:..., apikey=...)
52
+ // Excludes '&' to preserve following query string parameters without corruption
53
+ cleaned = cleaned.replace(
54
+ /\b(password|passwd|pwd|token|secret|apikey|api_key)(\s*[:=]\s*)['"]?[^\s'";&]+['"]?/gi,
55
+ "$1$2[redacted]"
56
+ );
57
+
58
+ return cleaned;
59
+ }
60
+
61
+ /**
62
+ * Resolves safe absolute path to UI assets inside UI_DIR.
63
+ * Prevents directory traversal, encoded path bypasses, and prefix collisions.
64
+ *
65
+ * @param {string} relPath Path requested by client
66
+ * @returns {{ fullPath: string, contentType: string } | null}
67
+ */
68
+ function resolveUiAssetPath(relPath) {
69
+ if (typeof relPath !== "string") {
70
+ return null;
71
+ }
72
+
73
+ let decoded;
74
+ try {
75
+ decoded = decodeURIComponent(relPath);
76
+ } catch {
77
+ return null;
78
+ }
79
+
80
+ if (decoded.includes("\0")) {
81
+ return null;
82
+ }
83
+
84
+ // Normalize backslashes to forward slashes
85
+ decoded = decoded.replace(/\\/g, "/");
86
+
87
+ let clean = decoded.replace(/^\/ui\/?$/, "/index.html").replace(/^\/ui\//, "/").replace(/^\/+/, "");
88
+ if (!clean || clean === "index.html") {
89
+ clean = "index.html";
90
+ }
91
+
92
+ // Reject any segment that starts with a dot (dotfiles / hidden files / traversal)
93
+ const segments = clean.split("/");
94
+ if (segments.some((seg) => seg === "." || seg === ".." || seg.startsWith("."))) {
95
+ return null;
96
+ }
97
+
98
+ const fullPath = path.resolve(UI_DIR, clean);
99
+ const rel = path.relative(UI_DIR, fullPath);
100
+
101
+ // Traversal safeguard: relative path must not start with '..' and must not be absolute
102
+ if (rel.startsWith("..") || path.isAbsolute(rel) || fullPath === UI_DIR) {
103
+ return null;
104
+ }
105
+
106
+ // Ensure resolved path is strictly within UI_DIR directory hierarchy
107
+ if (!fullPath.startsWith(UI_DIR + path.sep)) {
108
+ return null;
109
+ }
110
+
111
+ try {
112
+ if (fs.existsSync(fullPath)) {
113
+ const stat = fs.statSync(fullPath);
114
+ if (stat.isFile()) {
115
+ const ext = path.extname(fullPath).toLowerCase();
116
+ if (!MIME_TYPES[ext]) {
117
+ return null;
118
+ }
119
+ return {
120
+ fullPath,
121
+ contentType: MIME_TYPES[ext]
122
+ };
123
+ }
124
+ }
125
+ } catch {
126
+ return null;
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Sends a static file response across Express, Connect, or native Node http.
134
+ *
135
+ * @param {object} res Response object
136
+ * @param {string} filePath Path to asset
137
+ * @param {string} contentType MIME type
138
+ */
139
+ function sendStaticFile(res, filePath, contentType) {
140
+ try {
141
+ const content = fs.readFileSync(filePath);
142
+ if (typeof res.type === "function") {
143
+ res.type(contentType);
144
+ } else if (typeof res.setHeader === "function") {
145
+ res.setHeader("Content-Type", contentType);
146
+ }
147
+
148
+ if (typeof res.header === "function") {
149
+ res.header("Cache-Control", "public, max-age=300");
150
+ res.header("X-Content-Type-Options", "nosniff");
151
+ res.header("Content-Length", Buffer.byteLength(content));
152
+ } else if (typeof res.setHeader === "function") {
153
+ res.setHeader("Cache-Control", "public, max-age=300");
154
+ res.setHeader("X-Content-Type-Options", "nosniff");
155
+ res.setHeader("Content-Length", Buffer.byteLength(content));
156
+ }
157
+
158
+ if (typeof res.status === "function") {
159
+ res.status(200);
160
+ } else if (typeof res.code === "function") {
161
+ res.code(200);
162
+ } else {
163
+ res.statusCode = 200;
164
+ }
165
+
166
+ if (typeof res.send === "function") {
167
+ return res.send(content);
168
+ }
169
+ if (typeof res.end === "function") {
170
+ return res.end(content);
171
+ }
172
+ } catch {
173
+ sendJson(
174
+ res,
175
+ 500,
176
+ createErrorResponse("INTERNAL_ERROR", "Failed to load UI asset")
177
+ );
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Creates the standard JSON error response envelope.
183
+ *
184
+ * @param {string} code Standard error code
185
+ * @param {string} message Descriptive message
186
+ * @param {object|null} [details=null] Optional diagnostic details
187
+ */
188
+ function createErrorResponse(code, message, details = null) {
189
+ return {
190
+ error: {
191
+ code,
192
+ message,
193
+ details
194
+ }
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Universal helper to write JSON responses across Express, Connect, or native Node http.ServerResponse.
200
+ * Ensures Cache-Control: no-store is applied to protect dynamic, sensitive queue telemetry.
201
+ *
202
+ * @param {object} res Response object
203
+ * @param {number} status HTTP status code
204
+ * @param {object} body JSON payload
205
+ */
206
+ function sendJson(res, status, body) {
207
+ if (typeof res.setHeader === "function") {
208
+ res.setHeader("Cache-Control", "no-store");
209
+ res.setHeader("X-Content-Type-Options", "nosniff");
210
+ } else if (typeof res.header === "function") {
211
+ res.header("Cache-Control", "no-store");
212
+ res.header("X-Content-Type-Options", "nosniff");
213
+ }
214
+
215
+ if (typeof res.status === "function") {
216
+ const chain = res.status(status);
217
+ if (chain && typeof chain.json === "function") {
218
+ return chain.json(body);
219
+ }
220
+ }
221
+
222
+ if (typeof res.json === "function") {
223
+ if (typeof res.status === "function") {
224
+ res.status(status);
225
+ } else {
226
+ res.statusCode = status;
227
+ }
228
+ return res.json(body);
229
+ }
230
+
231
+ const jsonStr = JSON.stringify(body);
232
+ res.statusCode = status;
233
+ if (typeof res.setHeader === "function") {
234
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
235
+ res.setHeader("Content-Length", Buffer.byteLength(jsonStr));
236
+ }
237
+ if (typeof res.end === "function") {
238
+ res.end(jsonStr);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Validates and normalizes DLQ query parameters.
244
+ *
245
+ * @param {object} [query={}] Raw query object
246
+ * @returns {{ start: number, limit: number, order: "asc"|"desc" } | { error: object }}
247
+ */
248
+ function parseDLQQueryParams(query = {}) {
249
+ let start = 0;
250
+ let limit = 50;
251
+ let order = "asc";
252
+
253
+ // Validate and parse 'start'
254
+ if (query.start !== undefined && query.start !== null && query.start !== "") {
255
+ const rawStart = query.start;
256
+ if (typeof rawStart === "number") {
257
+ if (!Number.isInteger(rawStart) || rawStart < 0) {
258
+ return {
259
+ error: createErrorResponse(
260
+ "VALIDATION_ERROR",
261
+ "start must be a non-negative integer",
262
+ { parameter: "start", value: rawStart }
263
+ )
264
+ };
265
+ }
266
+ start = rawStart;
267
+ } else if (typeof rawStart === "string") {
268
+ const trimmed = rawStart.trim();
269
+ if (!/^\d+$/.test(trimmed)) {
270
+ return {
271
+ error: createErrorResponse(
272
+ "VALIDATION_ERROR",
273
+ "start must be a non-negative integer",
274
+ { parameter: "start", value: rawStart }
275
+ )
276
+ };
277
+ }
278
+ start = parseInt(trimmed, 10);
279
+ } else {
280
+ return {
281
+ error: createErrorResponse(
282
+ "VALIDATION_ERROR",
283
+ "start must be a non-negative integer",
284
+ { parameter: "start", value: rawStart }
285
+ )
286
+ };
287
+ }
288
+ }
289
+
290
+ // Validate and parse 'limit'
291
+ if (query.limit !== undefined && query.limit !== null && query.limit !== "") {
292
+ const rawLimit = query.limit;
293
+ if (typeof rawLimit === "number") {
294
+ if (!Number.isInteger(rawLimit) || rawLimit < 0) {
295
+ return {
296
+ error: createErrorResponse(
297
+ "VALIDATION_ERROR",
298
+ "limit must be a non-negative integer",
299
+ { parameter: "limit", value: rawLimit }
300
+ )
301
+ };
302
+ }
303
+ limit = rawLimit;
304
+ } else if (typeof rawLimit === "string") {
305
+ const trimmed = rawLimit.trim();
306
+ if (!/^\d+$/.test(trimmed)) {
307
+ return {
308
+ error: createErrorResponse(
309
+ "VALIDATION_ERROR",
310
+ "limit must be a non-negative integer",
311
+ { parameter: "limit", value: rawLimit }
312
+ )
313
+ };
314
+ }
315
+ limit = parseInt(trimmed, 10);
316
+ } else {
317
+ return {
318
+ error: createErrorResponse(
319
+ "VALIDATION_ERROR",
320
+ "limit must be a non-negative integer",
321
+ { parameter: "limit", value: rawLimit }
322
+ )
323
+ };
324
+ }
325
+ }
326
+
327
+ // Validate and parse 'order'
328
+ if (query.order !== undefined && query.order !== null && query.order !== "") {
329
+ const rawOrder = query.order;
330
+ if (typeof rawOrder !== "string") {
331
+ return {
332
+ error: createErrorResponse(
333
+ "VALIDATION_ERROR",
334
+ 'order must be "asc" or "desc"',
335
+ { parameter: "order", value: rawOrder }
336
+ )
337
+ };
338
+ }
339
+ const normalized = rawOrder.trim().toLowerCase();
340
+ if (normalized !== "asc" && normalized !== "desc") {
341
+ return {
342
+ error: createErrorResponse(
343
+ "VALIDATION_ERROR",
344
+ 'order must be "asc" or "desc"',
345
+ { parameter: "order", value: rawOrder }
346
+ )
347
+ };
348
+ }
349
+ order = normalized;
350
+ }
351
+
352
+ return { start, limit, order };
353
+ }
354
+
355
+ class DashboardDispatcher {
356
+ constructor(options = {}) {
357
+ this.registry =
358
+ options instanceof QueueRegistry
359
+ ? options
360
+ : options?.registry instanceof QueueRegistry
361
+ ? options.registry
362
+ : createQueueRegistry(options);
363
+
364
+ this.basePath = options?.basePath
365
+ ? String(options.basePath).replace(/\/+$/, "")
366
+ : "";
367
+ this.localOnlyPaused = Boolean(options?.localOnlyPaused);
368
+ }
369
+
370
+ /**
371
+ * Executes the GET /api/queues route logic.
372
+ *
373
+ * @returns {Promise<{ status: number, body: object }>}
374
+ */
375
+ async executeQueuesRoute() {
376
+ try {
377
+ const result = await handleGetQueues(this.registry, {
378
+ localOnlyPaused: this.localOnlyPaused
379
+ });
380
+
381
+ return {
382
+ status: 200,
383
+ body: result
384
+ };
385
+ } catch (err) {
386
+ if (
387
+ err &&
388
+ (err.code === "QUEUE_CLOSED" ||
389
+ err.statusCode === 503 ||
390
+ (typeof err.message === "string" &&
391
+ /queue is closed/i.test(err.message)))
392
+ ) {
393
+ return {
394
+ status: 503,
395
+ body: createErrorResponse("QUEUE_CLOSED", "Queue is closed")
396
+ };
397
+ }
398
+
399
+ return {
400
+ status: 500,
401
+ body: createErrorResponse(
402
+ "INTERNAL_ERROR",
403
+ sanitizeErrorMessage(err?.message)
404
+ )
405
+ };
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Executes the GET /api/queues/:queueName/metrics route logic.
411
+ *
412
+ * @param {string} rawQueueName Queue identifier path parameter
413
+ * @returns {Promise<{ status: number, body: object }>}
414
+ */
415
+ async executeMetricsRoute(rawQueueName) {
416
+ if (
417
+ rawQueueName === undefined ||
418
+ rawQueueName === null ||
419
+ typeof rawQueueName !== "string"
420
+ ) {
421
+ return {
422
+ status: 400,
423
+ body: createErrorResponse(
424
+ "VALIDATION_ERROR",
425
+ "queueName must be a non-empty string"
426
+ )
427
+ };
428
+ }
429
+
430
+ let queueName;
431
+ try {
432
+ queueName = decodeURIComponent(rawQueueName);
433
+ } catch {
434
+ return {
435
+ status: 400,
436
+ body: createErrorResponse(
437
+ "VALIDATION_ERROR",
438
+ "Invalid URL-encoded queueName parameter"
439
+ )
440
+ };
441
+ }
442
+
443
+ if (queueName.includes("\0")) {
444
+ return {
445
+ status: 400,
446
+ body: createErrorResponse(
447
+ "VALIDATION_ERROR",
448
+ "queueName cannot contain null bytes"
449
+ )
450
+ };
451
+ }
452
+
453
+ const trimmedQueueName = queueName.trim();
454
+ if (trimmedQueueName.length === 0) {
455
+ return {
456
+ status: 400,
457
+ body: createErrorResponse(
458
+ "VALIDATION_ERROR",
459
+ "queueName must be a non-empty string"
460
+ )
461
+ };
462
+ }
463
+
464
+ const queue = this.registry.get(trimmedQueueName);
465
+ if (!queue) {
466
+ return {
467
+ status: 404,
468
+ body: createErrorResponse(
469
+ "QUEUE_NOT_FOUND",
470
+ `Queue '${trimmedQueueName}' is not registered with the dashboard adapter`
471
+ )
472
+ };
473
+ }
474
+
475
+ try {
476
+ const result = await handleGetMetrics(queue, {
477
+ localOnlyPaused: this.localOnlyPaused
478
+ });
479
+
480
+ return {
481
+ status: 200,
482
+ body: {
483
+ queue: trimmedQueueName,
484
+ isPaused: result.isPaused,
485
+ metrics: result.metrics
486
+ }
487
+ };
488
+ } catch (err) {
489
+ if (
490
+ err &&
491
+ (err.code === "QUEUE_CLOSED" ||
492
+ err.statusCode === 503 ||
493
+ (typeof err.message === "string" &&
494
+ /queue is closed/i.test(err.message)))
495
+ ) {
496
+ return {
497
+ status: 503,
498
+ body: createErrorResponse("QUEUE_CLOSED", "Queue is closed")
499
+ };
500
+ }
501
+
502
+ return {
503
+ status: 500,
504
+ body: createErrorResponse(
505
+ "INTERNAL_ERROR",
506
+ sanitizeErrorMessage(err?.message)
507
+ )
508
+ };
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Executes the GET /api/queues/:queueName/dlq route logic.
514
+ *
515
+ * @param {string} rawQueueName Queue identifier path parameter
516
+ * @param {object} [query={}] Query parameters (start, limit, order)
517
+ * @returns {Promise<{ status: number, body: object }>}
518
+ */
519
+ async executeDLQRoute(rawQueueName, query = {}) {
520
+ if (
521
+ rawQueueName === undefined ||
522
+ rawQueueName === null ||
523
+ typeof rawQueueName !== "string"
524
+ ) {
525
+ return {
526
+ status: 400,
527
+ body: createErrorResponse(
528
+ "VALIDATION_ERROR",
529
+ "queueName must be a non-empty string"
530
+ )
531
+ };
532
+ }
533
+
534
+ let queueName;
535
+ try {
536
+ queueName = decodeURIComponent(rawQueueName);
537
+ } catch {
538
+ return {
539
+ status: 400,
540
+ body: createErrorResponse(
541
+ "VALIDATION_ERROR",
542
+ "Invalid URL-encoded queueName parameter"
543
+ )
544
+ };
545
+ }
546
+
547
+ if (queueName.includes("\0")) {
548
+ return {
549
+ status: 400,
550
+ body: createErrorResponse(
551
+ "VALIDATION_ERROR",
552
+ "queueName cannot contain null bytes"
553
+ )
554
+ };
555
+ }
556
+
557
+ const trimmedQueueName = queueName.trim();
558
+ if (trimmedQueueName.length === 0) {
559
+ return {
560
+ status: 400,
561
+ body: createErrorResponse(
562
+ "VALIDATION_ERROR",
563
+ "queueName must be a non-empty string"
564
+ )
565
+ };
566
+ }
567
+
568
+ const queue = this.registry.get(trimmedQueueName);
569
+ if (!queue) {
570
+ return {
571
+ status: 404,
572
+ body: createErrorResponse(
573
+ "QUEUE_NOT_FOUND",
574
+ `Queue '${trimmedQueueName}' is not registered with the dashboard adapter`
575
+ )
576
+ };
577
+ }
578
+
579
+ const parsed = parseDLQQueryParams(query);
580
+ if (parsed.error) {
581
+ return {
582
+ status: 400,
583
+ body: parsed.error
584
+ };
585
+ }
586
+
587
+ const { start, limit, order } = parsed;
588
+
589
+ try {
590
+ const result = await handleGetDLQ(queue, { start, limit, order });
591
+
592
+ return {
593
+ status: 200,
594
+ body: {
595
+ queue: trimmedQueueName,
596
+ total: result.total,
597
+ start,
598
+ limit,
599
+ order,
600
+ jobs: result.jobs
601
+ }
602
+ };
603
+ } catch (err) {
604
+ if (
605
+ err &&
606
+ (err.code === "QUEUE_CLOSED" ||
607
+ err.statusCode === 503 ||
608
+ (typeof err.message === "string" &&
609
+ /queue is closed/i.test(err.message)))
610
+ ) {
611
+ return {
612
+ status: 503,
613
+ body: createErrorResponse("QUEUE_CLOSED", "Queue is closed")
614
+ };
615
+ }
616
+
617
+ return {
618
+ status: 500,
619
+ body: createErrorResponse(
620
+ "INTERNAL_ERROR",
621
+ sanitizeErrorMessage(err?.message)
622
+ )
623
+ };
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Executes the GET /api/queues/:queueName/jobs/:jobId route logic.
629
+ *
630
+ * @param {string} rawQueueName Queue identifier path parameter
631
+ * @param {string} rawJobId Job identifier path parameter
632
+ * @returns {Promise<{ status: number, body: object }>}
633
+ */
634
+ async executeJobRoute(rawQueueName, rawJobId) {
635
+ if (
636
+ rawQueueName === undefined ||
637
+ rawQueueName === null ||
638
+ typeof rawQueueName !== "string"
639
+ ) {
640
+ return {
641
+ status: 400,
642
+ body: createErrorResponse(
643
+ "VALIDATION_ERROR",
644
+ "queueName must be a non-empty string"
645
+ )
646
+ };
647
+ }
648
+
649
+ let queueName;
650
+ try {
651
+ queueName = decodeURIComponent(rawQueueName);
652
+ } catch {
653
+ return {
654
+ status: 400,
655
+ body: createErrorResponse(
656
+ "VALIDATION_ERROR",
657
+ "Invalid URL-encoded queueName parameter"
658
+ )
659
+ };
660
+ }
661
+
662
+ if (queueName.includes("\0")) {
663
+ return {
664
+ status: 400,
665
+ body: createErrorResponse(
666
+ "VALIDATION_ERROR",
667
+ "queueName cannot contain null bytes"
668
+ )
669
+ };
670
+ }
671
+
672
+ const trimmedQueueName = queueName.trim();
673
+ if (trimmedQueueName.length === 0) {
674
+ return {
675
+ status: 400,
676
+ body: createErrorResponse(
677
+ "VALIDATION_ERROR",
678
+ "queueName must be a non-empty string"
679
+ )
680
+ };
681
+ }
682
+
683
+ if (
684
+ rawJobId === undefined ||
685
+ rawJobId === null ||
686
+ typeof rawJobId !== "string"
687
+ ) {
688
+ return {
689
+ status: 400,
690
+ body: createErrorResponse(
691
+ "VALIDATION_ERROR",
692
+ "jobId must be a non-empty string"
693
+ )
694
+ };
695
+ }
696
+
697
+ let jobId;
698
+ try {
699
+ jobId = decodeURIComponent(rawJobId);
700
+ } catch {
701
+ return {
702
+ status: 400,
703
+ body: createErrorResponse(
704
+ "VALIDATION_ERROR",
705
+ "Invalid URL-encoded jobId parameter"
706
+ )
707
+ };
708
+ }
709
+
710
+ if (jobId.includes("\0")) {
711
+ return {
712
+ status: 400,
713
+ body: createErrorResponse(
714
+ "VALIDATION_ERROR",
715
+ "jobId cannot contain null bytes"
716
+ )
717
+ };
718
+ }
719
+
720
+ const trimmedJobId = jobId.trim();
721
+ if (trimmedJobId.length === 0) {
722
+ return {
723
+ status: 400,
724
+ body: createErrorResponse(
725
+ "VALIDATION_ERROR",
726
+ "jobId must be a non-empty string"
727
+ )
728
+ };
729
+ }
730
+
731
+ const queue = this.registry.get(trimmedQueueName);
732
+ if (!queue) {
733
+ return {
734
+ status: 404,
735
+ body: createErrorResponse(
736
+ "QUEUE_NOT_FOUND",
737
+ `Queue '${trimmedQueueName}' is not registered with the dashboard adapter`
738
+ )
739
+ };
740
+ }
741
+
742
+ try {
743
+ const job = await handleGetJob(queue, trimmedJobId);
744
+ if (!job) {
745
+ return {
746
+ status: 404,
747
+ body: createErrorResponse(
748
+ "JOB_NOT_FOUND",
749
+ `Job '${trimmedJobId}' not found or expired under retention policy`
750
+ )
751
+ };
752
+ }
753
+
754
+ return {
755
+ status: 200,
756
+ body: {
757
+ queue: trimmedQueueName,
758
+ job
759
+ }
760
+ };
761
+ } catch (err) {
762
+ if (
763
+ err &&
764
+ (err.code === "QUEUE_CLOSED" ||
765
+ err.statusCode === 503 ||
766
+ (typeof err.message === "string" &&
767
+ /queue is closed/i.test(err.message)))
768
+ ) {
769
+ return {
770
+ status: 503,
771
+ body: createErrorResponse("QUEUE_CLOSED", "Queue is closed")
772
+ };
773
+ }
774
+
775
+ if (
776
+ err &&
777
+ (err.code === "NOT_SUPPORTED" ||
778
+ err.statusCode === 501 ||
779
+ (typeof err.message === "string" && /does not implement getJob/i.test(err.message)))
780
+ ) {
781
+ return {
782
+ status: 501,
783
+ body: createErrorResponse(
784
+ "NOT_SUPPORTED",
785
+ err.message || "Job inspection is not supported by this queue instance"
786
+ )
787
+ };
788
+ }
789
+
790
+ return {
791
+ status: 500,
792
+ body: createErrorResponse(
793
+ "INTERNAL_ERROR",
794
+ sanitizeErrorMessage(err?.message)
795
+ )
796
+ };
797
+ }
798
+ }
799
+
800
+ /**
801
+ * Dispatches an incoming HTTP request (Express / Connect / native Node http).
802
+ *
803
+ * @param {object} req
804
+ * @param {object} res
805
+ * @param {function} [next]
806
+ */
807
+ async dispatch(req, res, next) {
808
+ let pathname = (this.basePath && req.originalUrl) ? req.originalUrl : (req.path || req.url || "/");
809
+ const queryIdx = pathname.indexOf("?");
810
+ if (queryIdx !== -1) {
811
+ pathname = pathname.slice(0, queryIdx);
812
+ }
813
+
814
+ if (this.basePath) {
815
+ if (pathname === this.basePath) {
816
+ pathname = "/";
817
+ } else if (pathname.startsWith(this.basePath + "/")) {
818
+ pathname = pathname.slice(this.basePath.length);
819
+ } else {
820
+ if (typeof next === "function") {
821
+ return next();
822
+ }
823
+ return sendJson(
824
+ res,
825
+ 404,
826
+ createErrorResponse("ROUTE_NOT_FOUND", "Route not found")
827
+ );
828
+ }
829
+ }
830
+
831
+ if (!pathname.startsWith("/")) {
832
+ pathname = "/" + pathname;
833
+ }
834
+
835
+ const queuesMatch = pathname.match(/^\/api\/queues\/?$/);
836
+ const metricsMatch = pathname.match(/^\/api\/queues\/([^/]*)\/metrics\/?$/);
837
+ const dlqMatch = pathname.match(/^\/api\/queues\/([^/]*)\/dlq\/?$/);
838
+ const jobMatch = pathname.match(/^\/api\/queues\/([^/]*)\/jobs\/([^/]*)\/?$/);
839
+
840
+ const isApiRoute = Boolean(queuesMatch || metricsMatch || dlqMatch || jobMatch);
841
+
842
+ let uiAsset = null;
843
+ if (!isApiRoute && req.method === "GET") {
844
+ uiAsset = resolveUiAssetPath(pathname);
845
+ }
846
+
847
+ if (!isApiRoute && !uiAsset) {
848
+ if (typeof next === "function") {
849
+ return next();
850
+ }
851
+ return sendJson(
852
+ res,
853
+ 404,
854
+ createErrorResponse("ROUTE_NOT_FOUND", "Route not found")
855
+ );
856
+ }
857
+
858
+ if (uiAsset) {
859
+ return sendStaticFile(res, uiAsset.fullPath, uiAsset.contentType);
860
+ }
861
+
862
+ if (req.method !== "GET") {
863
+ if (typeof next === "function") {
864
+ return next();
865
+ }
866
+ return sendJson(
867
+ res,
868
+ 405,
869
+ createErrorResponse("METHOD_NOT_ALLOWED", "Method not allowed")
870
+ );
871
+ }
872
+
873
+ try {
874
+ if (queuesMatch) {
875
+ const result = await this.executeQueuesRoute();
876
+ return sendJson(res, result.status, result.body);
877
+ }
878
+
879
+ if (metricsMatch) {
880
+ const rawQueueName = metricsMatch[1];
881
+ const result = await this.executeMetricsRoute(rawQueueName);
882
+ return sendJson(res, result.status, result.body);
883
+ }
884
+
885
+ if (dlqMatch) {
886
+ const rawQueueName = dlqMatch[1];
887
+ let query = req.query;
888
+ const rawUrl = req.originalUrl || req.url;
889
+ if (!query && rawUrl && rawUrl.includes("?")) {
890
+ try {
891
+ const parsedUrl = new URL(rawUrl, "http://localhost");
892
+ query = Object.fromEntries(parsedUrl.searchParams.entries());
893
+ } catch {
894
+ query = {};
895
+ }
896
+ }
897
+ const result = await this.executeDLQRoute(rawQueueName, query || {});
898
+ return sendJson(res, result.status, result.body);
899
+ }
900
+
901
+ if (jobMatch) {
902
+ const rawQueueName = jobMatch[1];
903
+ const rawJobId = jobMatch[2];
904
+ const result = await this.executeJobRoute(rawQueueName, rawJobId);
905
+ return sendJson(res, result.status, result.body);
906
+ }
907
+ } catch (err) {
908
+ sendJson(
909
+ res,
910
+ 500,
911
+ createErrorResponse(
912
+ "INTERNAL_ERROR",
913
+ sanitizeErrorMessage(err?.message)
914
+ )
915
+ );
916
+ }
917
+ }
918
+ }
919
+
920
+ function createDashboardDispatcher(options) {
921
+ return new DashboardDispatcher(options);
922
+ }
923
+
924
+ module.exports = {
925
+ DashboardDispatcher,
926
+ createDashboardDispatcher,
927
+ createErrorResponse,
928
+ handleGetQueues,
929
+ parseDLQQueryParams,
930
+ sanitizeErrorMessage,
931
+ sendJson,
932
+ serializeJob,
933
+ resolveUiAssetPath,
934
+ sendStaticFile,
935
+ UI_DIR
936
+ };