bunqueue 2.8.8 → 2.8.10

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,72 @@
1
+ /**
2
+ * Forwarder — store-and-forward from a local (edge) queue to a remote
3
+ * bunqueue server.
4
+ *
5
+ * The edge pattern: jobs buffer in the local queue (typically embedded
6
+ * SQLite on a gateway); the Forwarder drains them to a central server over
7
+ * TCP/TLS. A remote failure makes the local job fail → local retry/backoff →
8
+ * local DLQ, so nothing is lost while the uplink is down.
9
+ *
10
+ * Idempotency: every forwarded job carries the deterministic remote jobId
11
+ * `fwd:<localQueueKey>:<localJobId>`. The server dedupes custom jobIds, so a
12
+ * re-forward after a crash or retry never duplicates the job remotely.
13
+ */
14
+ import { EventEmitter } from 'events';
15
+ import type { ConnectionOptions, QueueOptions } from './types';
16
+ /** Options for queue.forward() */
17
+ export interface ForwardOptions {
18
+ /** Remote server connection (host/port/tls/token...) */
19
+ to: ConnectionOptions;
20
+ /** Remote queue name (default: same as the source queue) */
21
+ queue?: string;
22
+ /** Parallel forwards (default: 4) */
23
+ concurrency?: number;
24
+ /** Push to the remote with durable: true (immediate fsync server-side) */
25
+ durable?: boolean;
26
+ }
27
+ /** Source queue identity + config, provided by Queue.forward() */
28
+ export interface ForwardSource {
29
+ /** Logical source queue name */
30
+ name: string;
31
+ /** Source queue key (prefixKey + name) — used in the deterministic jobId */
32
+ queueKey: string;
33
+ prefixKey?: string;
34
+ embedded: boolean;
35
+ dataPath?: string;
36
+ connection?: ConnectionOptions;
37
+ }
38
+ /** Minimal remote-queue surface the Forwarder needs (avoids a Queue import cycle) */
39
+ interface RemoteQueueLike {
40
+ add(name: string, data: unknown, opts?: {
41
+ jobId?: string;
42
+ priority?: number;
43
+ durable?: boolean;
44
+ }): Promise<unknown>;
45
+ close(): Promise<void> | void;
46
+ }
47
+ /** Constructor for the remote queue, injected by Queue.forward() */
48
+ export type RemoteQueueCtor = new (name: string, opts: QueueOptions) => RemoteQueueLike;
49
+ /** Info emitted with each 'forwarded' event */
50
+ export interface ForwardedInfo {
51
+ /** Local job id */
52
+ id: string;
53
+ /** Deterministic remote job id (fwd:<queueKey>:<id>) */
54
+ remoteId: string;
55
+ /** Job name */
56
+ name: string;
57
+ }
58
+ /**
59
+ * Drains a local queue into a remote bunqueue server.
60
+ * Create via `queue.forward(options)`; stop with `close()`.
61
+ */
62
+ export declare class Forwarder extends EventEmitter {
63
+ on(event: 'forwarded', listener: (info: ForwardedInfo) => void): this;
64
+ on(event: 'error', listener: (error: Error) => void): this;
65
+ private readonly remote;
66
+ private readonly worker;
67
+ private closed;
68
+ constructor(source: ForwardSource, options: ForwardOptions, RemoteQueue: RemoteQueueCtor);
69
+ /** Stop forwarding and release connections. Safe to call more than once. */
70
+ close(): Promise<void>;
71
+ }
72
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Forwarder — store-and-forward from a local (edge) queue to a remote
3
+ * bunqueue server.
4
+ *
5
+ * The edge pattern: jobs buffer in the local queue (typically embedded
6
+ * SQLite on a gateway); the Forwarder drains them to a central server over
7
+ * TCP/TLS. A remote failure makes the local job fail → local retry/backoff →
8
+ * local DLQ, so nothing is lost while the uplink is down.
9
+ *
10
+ * Idempotency: every forwarded job carries the deterministic remote jobId
11
+ * `fwd:<localQueueKey>:<localJobId>`. The server dedupes custom jobIds, so a
12
+ * re-forward after a crash or retry never duplicates the job remotely.
13
+ */
14
+ import { EventEmitter } from 'events';
15
+ import { Worker } from './worker';
16
+ /**
17
+ * Drains a local queue into a remote bunqueue server.
18
+ * Create via `queue.forward(options)`; stop with `close()`.
19
+ */
20
+ export class Forwarder extends EventEmitter {
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ on(event, listener) {
23
+ return super.on(event, listener);
24
+ }
25
+ remote;
26
+ worker;
27
+ closed = false;
28
+ constructor(source, options, RemoteQueue) {
29
+ super();
30
+ this.remote = new RemoteQueue(options.queue ?? source.name, {
31
+ embedded: false,
32
+ connection: options.to,
33
+ autoBatch: { enabled: false },
34
+ });
35
+ this.worker = new Worker(source.name, async (job) => {
36
+ const remoteId = `fwd:${source.queueKey}:${job.id}`;
37
+ await this.remote.add(job.name, job.data, {
38
+ jobId: remoteId,
39
+ ...(job.opts.priority !== undefined && { priority: job.opts.priority }),
40
+ ...(options.durable && { durable: true }),
41
+ });
42
+ const info = { id: job.id, remoteId, name: job.name };
43
+ // A throwing user listener must not fail a forward that already
44
+ // succeeded remotely (it would trigger a local retry of a done job).
45
+ try {
46
+ this.emit('forwarded', info);
47
+ }
48
+ catch {
49
+ /* listener error — forward itself succeeded */
50
+ }
51
+ return info;
52
+ }, {
53
+ embedded: source.embedded,
54
+ dataPath: source.dataPath,
55
+ connection: source.connection,
56
+ prefixKey: source.prefixKey,
57
+ concurrency: options.concurrency ?? 4,
58
+ });
59
+ // A remote failure is already handled by the local retry/DLQ path; the
60
+ // event is observability. Guard: EventEmitter throws on 'error' without
61
+ // listeners, and a transient uplink failure must never crash the process.
62
+ this.worker.on('failed', (_job, err) => {
63
+ if (this.listenerCount('error') > 0)
64
+ this.emit('error', err);
65
+ });
66
+ this.worker.on('error', (err) => {
67
+ if (this.listenerCount('error') > 0)
68
+ this.emit('error', err);
69
+ });
70
+ }
71
+ /** Stop forwarding and release connections. Safe to call more than once. */
72
+ async close() {
73
+ if (this.closed)
74
+ return;
75
+ this.closed = true;
76
+ await this.worker.close();
77
+ await this.remote.close();
78
+ }
79
+ }
@@ -26,6 +26,8 @@ export { Worker } from './worker';
26
26
  export { Bunqueue } from './bunqueue';
27
27
  export type { BunqueueOptions, BunqueueMiddleware, RetryStrategy, RetryConfig, CircuitBreakerConfig, TriggerRule, PriorityAgingConfig, BatchProcessor, BatchConfig, JobTtlConfig, BunqueueDeduplicationConfig, BunqueueDebounceConfig, BunqueueDlqConfig, } from './bunqueue';
28
28
  export { SandboxedWorker } from './sandboxedWorker';
29
+ export { Forwarder } from './forwarder';
30
+ export type { ForwardOptions, ForwardedInfo } from './forwarder';
29
31
  export { QueueEvents } from './events';
30
32
  export { QueueGroup } from './queueGroup';
31
33
  export { FlowProducer } from './flow';
@@ -25,6 +25,7 @@ export { Queue } from './queue';
25
25
  export { Worker } from './worker';
26
26
  export { Bunqueue } from './bunqueue';
27
27
  export { SandboxedWorker } from './sandboxedWorker';
28
+ export { Forwarder } from './forwarder';
28
29
  export { QueueEvents } from './events';
29
30
  export { QueueGroup } from './queueGroup';
30
31
  export { FlowProducer } from './flow';
@@ -3,6 +3,7 @@
3
3
  * BullMQ-style queue for job management
4
4
  */
5
5
  import type { Job, JobOptions, QueueOptions, StallConfig, DlqConfig, DlqEntry, DlqStats, DlqFilter, ChangePriorityOpts, GetDependenciesOpts, JobDependencies, JobDependenciesCount, JobStateType } from '../types';
6
+ import { Forwarder, type ForwardOptions } from '../forwarder';
6
7
  import * as countsOps from './operations/counts';
7
8
  import * as schedulerOps from './scheduler';
8
9
  import type { RepeatOpts, JobTemplate, SchedulerInfo } from './scheduler';
@@ -168,6 +169,14 @@ export declare class Queue<T = unknown> {
168
169
  };
169
170
  data: number[];
170
171
  }>;
172
+ /**
173
+ * Drain this queue's jobs to a remote bunqueue server (store-and-forward).
174
+ * Typical edge pattern: embedded local queue as offline buffer, forwarded
175
+ * to a central server over TCP/TLS. Remote failures leave jobs local
176
+ * (retry → DLQ) — nothing is lost while the uplink is down. Forwarded jobs
177
+ * carry a deterministic remote jobId so re-forwards never duplicate.
178
+ */
179
+ forward(options: ForwardOptions): Forwarder;
171
180
  disconnect(): Promise<void>;
172
181
  close(): void;
173
182
  }
@@ -5,6 +5,7 @@
5
5
  import { TcpConnectionPool, getSharedPool, releaseSharedPool } from '../tcpPool';
6
6
  import { FORCE_EMBEDDED } from './helpers';
7
7
  import { AddBatcher } from './addBatcher';
8
+ import { Forwarder } from '../forwarder';
8
9
  import { resolveToken } from '../resolveToken';
9
10
  import { getSharedManager } from '../manager';
10
11
  // Import operation modules
@@ -483,6 +484,24 @@ export class Queue {
483
484
  getMetrics(type, start, end) {
484
485
  return workersOps.getMetrics(this.ctx, type, start, end);
485
486
  }
487
+ // ============ Forwarding ============
488
+ /**
489
+ * Drain this queue's jobs to a remote bunqueue server (store-and-forward).
490
+ * Typical edge pattern: embedded local queue as offline buffer, forwarded
491
+ * to a central server over TCP/TLS. Remote failures leave jobs local
492
+ * (retry → DLQ) — nothing is lost while the uplink is down. Forwarded jobs
493
+ * carry a deterministic remote jobId so re-forwards never duplicate.
494
+ */
495
+ forward(options) {
496
+ return new Forwarder({
497
+ name: this.name,
498
+ queueKey: this.queueKey,
499
+ prefixKey: this.prefixKey || undefined,
500
+ embedded: this.embedded,
501
+ dataPath: this.opts.dataPath,
502
+ connection: this.opts.connection,
503
+ }, options, Queue);
504
+ }
486
505
  // ============ Connection ============
487
506
  async disconnect() {
488
507
  if (this.addBatcher) {
@@ -16,7 +16,9 @@ export function createCronJob(input, nextRun) {
16
16
  timezone: input.timezone ?? null,
17
17
  nextRun,
18
18
  executions: 0,
19
- maxLimit: input.maxLimit ?? null,
19
+ // 0/negative mean "no limit" on every surface (CLI/HTTP/TCP/MCP): storing
20
+ // 0 would make isAtLimit treat the cron as already exhausted (0 >= 0).
21
+ maxLimit: input.maxLimit !== undefined && input.maxLimit > 0 ? input.maxLimit : null,
20
22
  uniqueKey: input.uniqueKey ?? null,
21
23
  dedup: input.dedup ?? null,
22
24
  skipMissedOnRestart: input.skipMissedOnRestart ?? true,
@@ -3,8 +3,18 @@
3
3
  */
4
4
  /** Webhook ID type */
5
5
  export type WebhookId = string;
6
- /** Webhook event types */
7
- export type WebhookEvent = 'job.pushed' | 'job.started' | 'job.completed' | 'job.failed' | 'job.progress' | 'job.stalled';
6
+ /**
7
+ * Canonical list of webhook events the server actually triggers single
8
+ * source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
9
+ * completed/failed flow through eventsManager.mapEventToWebhook; job.progress
10
+ * is triggered directly by updateProgress (jobManagement).
11
+ */
12
+ export declare const WEBHOOK_EVENTS: readonly ["job.pushed", "job.started", "job.completed", "job.failed", "job.progress"];
13
+ /**
14
+ * Webhook event types. Includes 'job.stalled' for backward compatibility with
15
+ * stored webhooks, but it is never emitted and not accepted on new ones.
16
+ */
17
+ export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] | 'job.stalled';
8
18
  /** Webhook configuration */
9
19
  export interface Webhook {
10
20
  id: WebhookId;
@@ -2,6 +2,19 @@
2
2
  * Webhook domain types
3
3
  */
4
4
  import { uuid } from '../../shared/hash';
5
+ /**
6
+ * Canonical list of webhook events the server actually triggers — single
7
+ * source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
8
+ * completed/failed flow through eventsManager.mapEventToWebhook; job.progress
9
+ * is triggered directly by updateProgress (jobManagement).
10
+ */
11
+ export const WEBHOOK_EVENTS = [
12
+ 'job.pushed',
13
+ 'job.started',
14
+ 'job.completed',
15
+ 'job.failed',
16
+ 'job.progress',
17
+ ];
5
18
  /** Create a new webhook */
6
19
  export function createWebhook(url, events, queue, secret) {
7
20
  return {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Server bootstrap — the ONE place that boots a full bunqueue server.
3
+ * Used by both entry points (`bunqueue` bare via main.ts and
4
+ * `bunqueue start` via the CLI) so they cannot drift: S3 backup, cloud
5
+ * agent, stats interval, crash handlers and graceful shutdown are always on.
6
+ */
7
+ import { type BunqueueConfig, type ResolvedConfig } from '../../config';
8
+ /** Boot the full server from resolved configuration. Runs until shutdown. */
9
+ export declare function bootServer(fileConfig: BunqueueConfig | null, config: ResolvedConfig): void;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Server bootstrap — the ONE place that boots a full bunqueue server.
3
+ * Used by both entry points (`bunqueue` bare via main.ts and
4
+ * `bunqueue start` via the CLI) so they cannot drift: S3 backup, cloud
5
+ * agent, stats interval, crash handlers and graceful shutdown are always on.
6
+ */
7
+ import { QueueManager } from '../../application/queueManager';
8
+ import { createTcpServer } from './tcp';
9
+ import { createHttpServer } from './http';
10
+ import { Logger, serverLog, statsLog } from '../../shared/logger';
11
+ import { stopRateLimiter } from './rateLimiter';
12
+ import { VERSION } from '../../shared/version';
13
+ import { S3BackupManager } from '../backup';
14
+ import { CloudAgent } from '../cloud';
15
+ import { SHARD_COUNT } from '../../shared/hash';
16
+ import { resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from '../../config';
17
+ /** Print startup banner */
18
+ function printBanner(config, cloudUrl) {
19
+ const dim = '\x1b[2m';
20
+ const reset = '\x1b[0m';
21
+ const bold = '\x1b[1m';
22
+ const magenta = '\x1b[35m';
23
+ const green = '\x1b[32m';
24
+ const yellow = '\x1b[33m';
25
+ // Format TCP endpoint display
26
+ const tcpDisplay = config.tcpSocketPath
27
+ ? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
28
+ : `${bold}${config.hostname}:${config.tcpPort}${reset}`;
29
+ // Format HTTP endpoint display
30
+ const httpDisplay = config.httpSocketPath
31
+ ? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
32
+ : `${bold}${config.hostname}:${config.httpPort}${reset}`;
33
+ // Socket mode display
34
+ const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
35
+ const socketDisplay = hasUnixSockets
36
+ ? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
37
+ : `${dim}disabled${reset}`;
38
+ console.log(`
39
+ ${magenta} (\\(\\ ${reset}
40
+ ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
41
+ ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
42
+
43
+ ${dim}─────────────────────────────────────────────────${reset}
44
+
45
+ ${green}●${reset} TCP ${tcpDisplay}
46
+ ${green}●${reset} HTTP ${httpDisplay}
47
+ ${yellow}●${reset} Socket ${socketDisplay}
48
+ ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
49
+ ${yellow}●${reset} TLS ${config.tlsCertFile ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
50
+ ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
51
+ ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
52
+ ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
53
+ ${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
54
+
55
+ ${dim}─────────────────────────────────────────────────${reset}
56
+
57
+ `);
58
+ }
59
+ /** Boot the full server from resolved configuration. Runs until shutdown. */
60
+ export function bootServer(fileConfig, config) {
61
+ // Apply logging config before anything else
62
+ const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
63
+ const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
64
+ if (logFormat === 'json')
65
+ Logger.enableJsonMode();
66
+ if (logLevel) {
67
+ const validLevels = ['debug', 'info', 'warn', 'error'];
68
+ if (validLevels.includes(logLevel))
69
+ Logger.setLevel(logLevel);
70
+ }
71
+ // Resolve cloud config
72
+ const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
73
+ // Resolve TLS config — fail fast on partial cert/key before binding anything
74
+ let tlsConfig;
75
+ try {
76
+ tlsConfig = resolveTlsServerOptions(config);
77
+ }
78
+ catch (err) {
79
+ serverLog.error(err instanceof Error ? err.message : String(err));
80
+ process.exit(1);
81
+ }
82
+ printBanner(config, cloudConfig?.url);
83
+ // Create queue manager
84
+ const queueManager = new QueueManager({
85
+ dataPath: config.dataPath,
86
+ });
87
+ // Start TCP + HTTP servers; a bind failure must not leave a half-started process
88
+ let tcpServer;
89
+ let httpServer;
90
+ try {
91
+ tcpServer = createTcpServer(queueManager, {
92
+ port: config.tcpPort,
93
+ hostname: config.hostname,
94
+ authTokens: config.authTokens,
95
+ ...(tlsConfig && { tls: tlsConfig }),
96
+ });
97
+ httpServer = createHttpServer(queueManager, {
98
+ port: config.httpPort,
99
+ hostname: config.hostname,
100
+ socketPath: config.httpSocketPath,
101
+ authTokens: config.authTokens,
102
+ corsOrigins: config.corsOrigins,
103
+ requireAuthForMetrics: config.requireAuthForMetrics,
104
+ ...(tlsConfig && { tls: tlsConfig }),
105
+ });
106
+ }
107
+ catch (err) {
108
+ const msg = err instanceof Error ? err.message : 'Unknown error';
109
+ console.error(`Failed to start server: ${msg}`);
110
+ queueManager.shutdown();
111
+ process.exit(1);
112
+ }
113
+ // Initialize S3 backup manager
114
+ let backupManager = null;
115
+ if (config.dataPath) {
116
+ const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
117
+ backupManager = new S3BackupManager(backupConfig);
118
+ backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
119
+ backupManager.start();
120
+ }
121
+ // Initialize bunqueue Cloud agent (remote dashboard telemetry)
122
+ const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
123
+ if (cloudAgent) {
124
+ cloudAgent.setServerHandles({
125
+ getConnectionCount: () => tcpServer.getConnectionCount(),
126
+ getWsClientCount: () => httpServer.getWsClientCount(),
127
+ getSseClientCount: () => httpServer.getSseClientCount(),
128
+ getBackupStatus: () => backupManager?.getStatus() ?? null,
129
+ });
130
+ }
131
+ queueManager.emitDashboardEvent('server:started', {
132
+ tcpPort: config.tcpPort,
133
+ httpPort: config.httpPort,
134
+ shards: SHARD_COUNT,
135
+ });
136
+ // Graceful shutdown
137
+ let shuttingDown = false;
138
+ const shutdown = async (signal) => {
139
+ if (shuttingDown)
140
+ return;
141
+ shuttingDown = true;
142
+ serverLog.info(`Received ${signal}, shutting down...`);
143
+ // Stop stats interval immediately
144
+ clearInterval(statsInterval);
145
+ tcpServer.stop();
146
+ httpServer.stop();
147
+ const shutdownTimeout = config.shutdownTimeoutMs;
148
+ const start = Date.now();
149
+ while (Date.now() - start < shutdownTimeout) {
150
+ const stats = queueManager.getStats();
151
+ if (stats.active === 0)
152
+ break;
153
+ serverLog.info(`Waiting for ${stats.active} active jobs...`);
154
+ await Bun.sleep(1000);
155
+ }
156
+ // Stop backup manager
157
+ if (backupManager) {
158
+ backupManager.stop();
159
+ }
160
+ // Stop Cloud agent (sends final shutdown snapshot)
161
+ if (cloudAgent) {
162
+ await cloudAgent.stop();
163
+ }
164
+ queueManager.emitDashboardEvent('server:shutdown', { signal });
165
+ queueManager.shutdown();
166
+ stopRateLimiter();
167
+ serverLog.info('Shutdown complete');
168
+ process.exit(0);
169
+ };
170
+ process.on('SIGINT', () => void shutdown('SIGINT'));
171
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
172
+ process.on('uncaughtException', (err) => {
173
+ serverLog.error('Uncaught exception - initiating shutdown', {
174
+ error: err.message,
175
+ stack: err.stack,
176
+ });
177
+ void shutdown('uncaughtException');
178
+ });
179
+ process.on('unhandledRejection', (reason) => {
180
+ serverLog.error('Unhandled promise rejection - initiating shutdown', {
181
+ reason: reason instanceof Error ? reason.message : String(reason),
182
+ stack: reason instanceof Error ? reason.stack : undefined,
183
+ });
184
+ void shutdown('unhandledRejection');
185
+ });
186
+ // Print stats periodically
187
+ const statsInterval = setInterval(() => {
188
+ const stats = queueManager.getStats();
189
+ const memStats = queueManager.getMemoryStats();
190
+ const workerStats = queueManager.workerManager.getStats();
191
+ const mem = process.memoryUsage();
192
+ const now = new Date();
193
+ const timestamp = now.toLocaleTimeString('en-GB', {
194
+ hour: '2-digit',
195
+ minute: '2-digit',
196
+ second: '2-digit',
197
+ });
198
+ statsLog.info('Queue statistics', {
199
+ time: timestamp,
200
+ waiting: stats.waiting,
201
+ active: stats.active,
202
+ delayed: stats.delayed,
203
+ completed: stats.completed,
204
+ dlq: stats.dlq,
205
+ tcp: tcpServer.getConnectionCount(),
206
+ ws: httpServer.getWsClientCount(),
207
+ sse: httpServer.getSseClientCount(),
208
+ workers: `${workerStats.active}/${workerStats.total}`,
209
+ mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
210
+ rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
211
+ // Internal collection sizes (for memory debugging)
212
+ idx: memStats.jobIndex,
213
+ locks: memStats.jobLocks,
214
+ clients: memStats.clientJobsTotal,
215
+ });
216
+ }, config.statsIntervalMs);
217
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
+ import { validateNumericField } from '../protocol';
7
8
  /**
8
9
  * Coerce a value to a finite number, or return undefined if it can't be.
9
10
  * Guards config endpoints against non-numeric input (e.g. `"abc"`) that would
@@ -108,6 +109,13 @@ export async function handleDiscard(cmd, ctx, reqId) {
108
109
  /** Handle WaitJob command - wait for job completion (event-driven, no polling) */
109
110
  export async function handleWaitJob(cmd, ctx, reqId) {
110
111
  const jid = jobId(cmd.id);
112
+ // Bounded like PULL (which caps at 60s); waiting for completion is
113
+ // legitimately longer, so the cap here is 10 min. validateNumericField also
114
+ // rejects NaN/Infinity from hostile clients (a hand-rolled <0/>max check
115
+ // would let NaN through and resolve instantly as a false "not completed").
116
+ const timeoutError = validateNumericField(cmd.timeout, 'timeout', { min: 0, max: 600000 });
117
+ if (timeoutError)
118
+ return resp.error(timeoutError, reqId);
111
119
  const timeout = cmd.timeout ?? 30000;
112
120
  // First check if job exists and is already completed
113
121
  const job = await ctx.queueManager.getJob(jid);
@@ -6,6 +6,7 @@ import { jobId } from '../../../domain/types/job';
6
6
  import { VERSION } from '../../../shared/version';
7
7
  import * as resp from '../../../domain/types/response';
8
8
  import { validateWebhookUrl } from '../protocol';
9
+ import { WEBHOOK_EVENTS } from '../../../domain/types/webhook';
9
10
  // ============ Job Logs ============
10
11
  export function handleAddLog(cmd, ctx, reqId) {
11
12
  const jid = jobId(cmd.id);
@@ -140,6 +141,12 @@ export function handleAddWebhook(cmd, ctx, reqId) {
140
141
  const urlError = validateWebhookUrl(cmd.url);
141
142
  if (urlError)
142
143
  return resp.error(urlError, reqId);
144
+ // Reject events that are never triggered — a webhook subscribed to a dead
145
+ // event would be created "ok" and then never fire (silent failure).
146
+ const invalidEvents = cmd.events.filter((e) => !WEBHOOK_EVENTS.includes(e));
147
+ if (invalidEvents.length > 0) {
148
+ return resp.error(`Invalid webhook event(s): ${invalidEvents.join(', ')}. Valid: ${WEBHOOK_EVENTS.join(', ')}`, reqId);
149
+ }
143
150
  const webhook = ctx.queueManager.webhookManager.add(cmd.url, cmd.events, cmd.queue, cmd.secret);
144
151
  ctx.queueManager.emitDashboardEvent('webhook:added', {
145
152
  id: webhook.id,