bunqueue 2.8.6 → 2.8.8

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 (50) hide show
  1. package/dist/application/operations/ack.d.ts +1 -1
  2. package/dist/application/operations/ack.js +2 -2
  3. package/dist/application/queueManager.d.ts +1 -1
  4. package/dist/application/queueManager.js +2 -2
  5. package/dist/application/statsManager.js +18 -8
  6. package/dist/cli/client.d.ts +3 -0
  7. package/dist/cli/client.js +13 -2
  8. package/dist/cli/commands/server.js +26 -2
  9. package/dist/cli/help.js +5 -0
  10. package/dist/cli/index.d.ts +5 -0
  11. package/dist/cli/index.js +53 -1
  12. package/dist/client/queue/dlq.js +1 -1
  13. package/dist/client/queue/operations/management.js +4 -2
  14. package/dist/client/queue/queue.js +2 -0
  15. package/dist/client/queue/scheduler.js +5 -0
  16. package/dist/client/tcp/client.js +1 -0
  17. package/dist/client/tcp/connection.d.ts +8 -1
  18. package/dist/client/tcp/connection.js +27 -1
  19. package/dist/client/tcp/index.d.ts +1 -1
  20. package/dist/client/tcp/shared.d.ts +6 -4
  21. package/dist/client/tcp/shared.js +27 -11
  22. package/dist/client/tcp/types.d.ts +13 -0
  23. package/dist/client/tcp/types.js +1 -0
  24. package/dist/client/tcpPool.js +11 -1
  25. package/dist/client/types.d.ts +8 -0
  26. package/dist/client/worker/worker.js +7 -2
  27. package/dist/client/worker/workerPull.d.ts +2 -0
  28. package/dist/client/worker/workerPull.js +12 -5
  29. package/dist/config/index.d.ts +1 -1
  30. package/dist/config/index.js +1 -1
  31. package/dist/config/resolve.d.ts +14 -0
  32. package/dist/config/resolve.js +19 -0
  33. package/dist/config/types.d.ts +4 -0
  34. package/dist/domain/types/command.d.ts +4 -0
  35. package/dist/infrastructure/server/handlers/advanced.js +60 -8
  36. package/dist/infrastructure/server/handlers/core.js +1 -1
  37. package/dist/infrastructure/server/handlers/cron.js +1 -0
  38. package/dist/infrastructure/server/handlers/monitoring.js +7 -2
  39. package/dist/infrastructure/server/http.d.ts +3 -0
  40. package/dist/infrastructure/server/http.js +30 -8
  41. package/dist/infrastructure/server/httpRouteJobs.js +14 -2
  42. package/dist/infrastructure/server/httpRouteQueueConfig.js +19 -3
  43. package/dist/infrastructure/server/httpRouteQueues.js +13 -1
  44. package/dist/infrastructure/server/httpRouteResources.js +4 -0
  45. package/dist/infrastructure/server/tcp.d.ts +6 -0
  46. package/dist/infrastructure/server/tcp.js +5 -1
  47. package/dist/infrastructure/server/tls.d.ts +21 -0
  48. package/dist/infrastructure/server/tls.js +19 -0
  49. package/dist/main.js +13 -1
  50. package/package.json +1 -1
@@ -20,6 +20,7 @@ export class TcpConnectionPool {
20
20
  host: options.host ?? 'localhost',
21
21
  port: options.port ?? 6789,
22
22
  token: options.token ?? '',
23
+ tls: options.tls ?? false,
23
24
  poolSize,
24
25
  maxReconnectAttempts: options.maxReconnectAttempts ?? Infinity,
25
26
  reconnectDelay: options.reconnectDelay ?? 100,
@@ -39,6 +40,7 @@ export class TcpConnectionPool {
39
40
  host: this.options.host,
40
41
  port: this.options.port,
41
42
  token: this.options.token,
43
+ tls: this.options.tls,
42
44
  maxReconnectAttempts: this.options.maxReconnectAttempts,
43
45
  reconnectDelay: this.options.reconnectDelay,
44
46
  maxReconnectDelay: this.options.maxReconnectDelay,
@@ -49,6 +51,11 @@ export class TcpConnectionPool {
49
51
  maxPingFailures: this.options.maxPingFailures,
50
52
  maxCommandTimeouts: this.options.maxCommandTimeouts,
51
53
  });
54
+ // Socket-level errors (e.g. TLS handshake failures, protocol garbage) are
55
+ // emitted as 'error' events; without a listener EventEmitter would throw
56
+ // and crash the process. Commands are still settled via the close/timeout
57
+ // paths, so observing the error here is enough.
58
+ client.on('error', () => { });
52
59
  this.clients.push(client);
53
60
  }
54
61
  }
@@ -169,7 +176,10 @@ function getPoolKey(options) {
169
176
  const token = options?.token ?? '';
170
177
  // Include poolSize and token hash to prevent sharing pools with different configs
171
178
  const tokenHash = token ? String(Number(Bun.hash(token)) & 0xffff) : '0';
172
- return `${host}:${port}:${poolSize}:${tokenHash}`;
179
+ // TLS config must differentiate pools too: a TLS pool and a plaintext pool
180
+ // to the same host:port are NOT interchangeable.
181
+ const tlsKey = options?.tls ? JSON.stringify(options.tls) : '0';
182
+ return `${host}:${port}:${poolSize}:${tokenHash}:${tlsKey}`;
173
183
  }
174
184
  /** Get or create shared connection pool */
175
185
  export function getSharedPool(options) {
@@ -360,6 +360,12 @@ export interface JobOptions {
360
360
  /** Debounce configuration */
361
361
  debounce?: DebounceOptions;
362
362
  }
363
+ /**
364
+ * TLS options for client connections — single definition lives in tcp/types
365
+ * to prevent drift between the public and internal ConnectionOptions.
366
+ */
367
+ export type { ClientTlsOptions } from './tcp/types';
368
+ import type { ClientTlsOptions } from './tcp/types';
363
369
  /** Connection options for TCP mode */
364
370
  export interface ConnectionOptions {
365
371
  /** Server host (default: localhost, ignored if socketPath is set) */
@@ -368,6 +374,8 @@ export interface ConnectionOptions {
368
374
  port?: number;
369
375
  /** Unix socket path (takes priority over host/port) */
370
376
  socketPath?: string;
377
+ /** Enable TLS to the server: true (system CAs) or custom options (default: off) */
378
+ tls?: boolean | ClientTlsOptions;
371
379
  /** Auth token */
372
380
  token?: string;
373
381
  /** Connection pool size for parallel operations (default: 1, set >1 to enable pooling) */
@@ -44,6 +44,7 @@ function createTcpPool(opts, concurrency) {
44
44
  host: connOpts.host ?? 'localhost',
45
45
  port: connOpts.port ?? 6789,
46
46
  token,
47
+ tls: connOpts.tls,
47
48
  poolSize,
48
49
  pingInterval: connOpts.pingInterval,
49
50
  commandTimeout: connOpts.commandTimeout,
@@ -398,9 +399,12 @@ export class Worker extends EventEmitter {
398
399
  cmd: 'ExtendLocks',
399
400
  ids: jobIds,
400
401
  tokens,
401
- duration,
402
+ // Protocol expects a per-id `durations` array, and the handler returns
403
+ // `count` (not `extended`). Sending `duration`/reading `extended` made
404
+ // batch lock renewal silently keep the old TTL.
405
+ durations: jobIds.map(() => duration),
402
406
  });
403
- const extended = response.extended;
407
+ const extended = response.count;
404
408
  return extended ?? 0;
405
409
  }
406
410
  // ============ Lifecycle ============
@@ -735,6 +739,7 @@ export class Worker extends EventEmitter {
735
739
  workerId: this.workerId,
736
740
  useLocks: this.opts.useLocks,
737
741
  pollTimeout: this.opts.pollTimeout,
742
+ lockDuration: this.opts.lockDuration,
738
743
  };
739
744
  }
740
745
  /** Apply worker-level removeOnComplete/removeOnFail defaults to a job */
@@ -9,6 +9,8 @@ export interface PullConfig {
9
9
  readonly workerId: string;
10
10
  readonly useLocks: boolean;
11
11
  readonly pollTimeout: number;
12
+ /** Lock TTL in ms to request from the server on a lock-based pull. */
13
+ readonly lockDuration?: number;
12
14
  }
13
15
  export declare function pullEmbedded(config: PullConfig, count: number): Promise<Array<{
14
16
  job: InternalJob;
@@ -6,13 +6,14 @@ import { getSharedManager } from '../manager';
6
6
  import { parseJobFromResponse } from './jobParser';
7
7
  export async function pullEmbedded(config, count) {
8
8
  const manager = getSharedManager();
9
- // Use lock-based pull only when useLocks is enabled
9
+ // Use lock-based pull only when useLocks is enabled. Pass lockDuration so the
10
+ // configured lock TTL is honored in embedded mode too (undefined → server default).
10
11
  if (config.useLocks) {
11
12
  if (count === 1) {
12
- const { job, token } = await manager.pullWithLock(config.name, config.workerId, 0);
13
+ const { job, token } = await manager.pullWithLock(config.name, config.workerId, 0, config.lockDuration);
13
14
  return job ? [{ job, token }] : [];
14
15
  }
15
- const { jobs, tokens } = await manager.pullBatchWithLock(config.name, count, config.workerId, 0);
16
+ const { jobs, tokens } = await manager.pullBatchWithLock(config.name, count, config.workerId, 0, config.lockDuration);
16
17
  return jobs.map((job, i) => ({ job, token: tokens[i] || null }));
17
18
  }
18
19
  // No locks - use regular pull
@@ -26,16 +27,22 @@ export async function pullEmbedded(config, count) {
26
27
  export async function pullTcp(config, tcp, count, closing) {
27
28
  if (closing)
28
29
  return [];
29
- // Build pull command - only request locks if useLocks is enabled
30
+ // Build pull command - only request locks if useLocks is enabled.
31
+ // `count` belongs to the batch PULLB; a single PULL doesn't need it.
30
32
  const cmd = {
31
33
  cmd: count === 1 ? 'PULL' : 'PULLB',
32
34
  queue: config.name,
33
35
  timeout: config.pollTimeout,
34
- count,
35
36
  };
37
+ if (count > 1)
38
+ cmd.count = count;
36
39
  // Only request lock ownership when useLocks is enabled
37
40
  if (config.useLocks) {
38
41
  cmd.owner = config.workerId;
42
+ // Propagate the configured lock TTL so the server doesn't always fall back
43
+ // to its 30s default (WorkerOptions.lockDuration was previously ignored).
44
+ if (config.lockDuration !== undefined)
45
+ cmd.lockTtl = config.lockDuration;
39
46
  }
40
47
  const response = await tcp.send(cmd);
41
48
  if (!response.ok)
@@ -5,5 +5,5 @@
5
5
  export { defineConfig } from './types';
6
6
  export type { BunqueueConfig } from './types';
7
7
  export { loadConfigFile } from './loader';
8
- export { resolveServerConfig, resolveCloudConfig, resolveBackupConfig } from './resolve';
8
+ export { resolveServerConfig, resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from './resolve';
9
9
  export type { ResolvedConfig } from './resolve';
@@ -4,4 +4,4 @@
4
4
  */
5
5
  export { defineConfig } from './types';
6
6
  export { loadConfigFile } from './loader';
7
- export { resolveServerConfig, resolveCloudConfig, resolveBackupConfig } from './resolve';
7
+ export { resolveServerConfig, resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from './resolve';
@@ -12,6 +12,8 @@ export interface ResolvedConfig {
12
12
  hostname: string;
13
13
  tcpSocketPath: string | undefined;
14
14
  httpSocketPath: string | undefined;
15
+ tlsCertFile: string | undefined;
16
+ tlsKeyFile: string | undefined;
15
17
  authTokens: string[];
16
18
  dataPath: string | undefined;
17
19
  corsOrigins: string[];
@@ -22,6 +24,18 @@ export interface ResolvedConfig {
22
24
  }
23
25
  /** Resolve server config: config file > env vars > defaults */
24
26
  export declare function resolveServerConfig(fileConfig: BunqueueConfig | null): ResolvedConfig;
27
+ /**
28
+ * Resolve server TLS options from resolved config. Returns null when TLS is
29
+ * not configured; throws when only one of cert/key is set (fail fast at
30
+ * startup rather than serving plaintext when the operator expected TLS).
31
+ */
32
+ export declare function resolveTlsServerOptions(config: {
33
+ tlsCertFile?: string;
34
+ tlsKeyFile?: string;
35
+ }): {
36
+ certFile: string;
37
+ keyFile: string;
38
+ } | null;
25
39
  /** Resolve cloud config: config file > env vars. Returns null if disabled. */
26
40
  export declare function resolveCloudConfig(fileConfig: BunqueueConfig | null, dataPath?: string): CloudConfig | null;
27
41
  /** Resolve S3 backup config: config file > env vars */
@@ -14,6 +14,8 @@ export function resolveServerConfig(fileConfig) {
14
14
  hostname: fc?.server?.host ?? Bun.env.HOST ?? '0.0.0.0',
15
15
  tcpSocketPath: fc?.server?.tcpSocketPath ?? Bun.env.TCP_SOCKET_PATH,
16
16
  httpSocketPath: fc?.server?.httpSocketPath ?? Bun.env.HTTP_SOCKET_PATH,
17
+ tlsCertFile: fc?.server?.tlsCertFile ?? Bun.env.TLS_CERT_FILE,
18
+ tlsKeyFile: fc?.server?.tlsKeyFile ?? Bun.env.TLS_KEY_FILE,
17
19
  authTokens: fc?.auth?.tokens ?? Bun.env.AUTH_TOKENS?.split(',').filter(Boolean) ?? [],
18
20
  dataPath: fc?.storage?.dataPath ??
19
21
  Bun.env.BUNQUEUE_DATA_PATH ??
@@ -28,6 +30,23 @@ export function resolveServerConfig(fileConfig) {
28
30
  statsIntervalMs: fc?.timeouts?.stats ?? parseInt(Bun.env.STATS_INTERVAL_MS ?? '300000', 10),
29
31
  };
30
32
  }
33
+ /**
34
+ * Resolve server TLS options from resolved config. Returns null when TLS is
35
+ * not configured; throws when only one of cert/key is set (fail fast at
36
+ * startup rather than serving plaintext when the operator expected TLS).
37
+ */
38
+ export function resolveTlsServerOptions(config) {
39
+ const { tlsCertFile, tlsKeyFile } = config;
40
+ if (!tlsCertFile && !tlsKeyFile)
41
+ return null;
42
+ if (!tlsKeyFile) {
43
+ throw new Error('TLS misconfigured: tlsCertFile is set but tlsKeyFile (TLS_KEY_FILE) is missing');
44
+ }
45
+ if (!tlsCertFile) {
46
+ throw new Error('TLS misconfigured: tlsKeyFile is set but tlsCertFile (TLS_CERT_FILE) is missing');
47
+ }
48
+ return { certFile: tlsCertFile, keyFile: tlsKeyFile };
49
+ }
31
50
  /** Resolve cloud config: config file > env vars. Returns null if disabled. */
32
51
  export function resolveCloudConfig(fileConfig, dataPath) {
33
52
  const fc = fileConfig?.cloud;
@@ -10,6 +10,10 @@ export interface BunqueueConfig {
10
10
  host?: string;
11
11
  tcpSocketPath?: string;
12
12
  httpSocketPath?: string;
13
+ /** Path to PEM certificate file — enables native TLS on TCP + HTTP (with tlsKeyFile) */
14
+ tlsCertFile?: string;
15
+ /** Path to PEM private key file — enables native TLS on TCP + HTTP (with tlsCertFile) */
16
+ tlsKeyFile?: string;
13
17
  };
14
18
  auth?: {
15
19
  tokens?: string[];
@@ -98,6 +98,8 @@ export interface FailCommand extends BaseCommand {
98
98
  readonly id: string;
99
99
  readonly error?: string;
100
100
  readonly token?: string;
101
+ /** Skip all remaining retries and fail terminally (UnrecoverableError over TCP). */
102
+ readonly unrecoverable?: boolean;
101
103
  }
102
104
  export interface GetJobCommand extends BaseCommand {
103
105
  readonly cmd: 'GetJob';
@@ -309,6 +311,8 @@ export interface AddLogCommand extends BaseCommand {
309
311
  export interface GetLogsCommand extends BaseCommand {
310
312
  readonly cmd: 'GetLogs';
311
313
  readonly id: string;
314
+ readonly start?: number;
315
+ readonly end?: number;
312
316
  }
313
317
  export interface HeartbeatCommand extends BaseCommand {
314
318
  readonly cmd: 'Heartbeat';
@@ -4,6 +4,45 @@
4
4
  */
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
+ /**
8
+ * Coerce a value to a finite number, or return undefined if it can't be.
9
+ * Guards config endpoints against non-numeric input (e.g. `"abc"`) that would
10
+ * otherwise reach numeric comparisons as NaN and silently break behaviour
11
+ * (a string `stallInterval` disabled stall detection entirely).
12
+ */
13
+ function toFiniteNumber(value) {
14
+ if (value === undefined || value === null)
15
+ return undefined;
16
+ const n = typeof value === 'number' ? value : Number(value);
17
+ return Number.isFinite(n) ? n : undefined;
18
+ }
19
+ /**
20
+ * Sanitize the numeric fields of a config object: coerce numeric strings, drop
21
+ * non-numeric garbage (so the manager's merge keeps the existing/default value
22
+ * instead of storing NaN). Booleans and unknown keys pass through untouched.
23
+ */
24
+ function sanitizeConfigNumbers(config, numericKeys) {
25
+ if (!config || typeof config !== 'object')
26
+ return config;
27
+ const numeric = new Set(numericKeys);
28
+ const out = {};
29
+ for (const [key, value] of Object.entries(config)) {
30
+ if (!numeric.has(key)) {
31
+ out[key] = value; // booleans / unknown keys pass through untouched
32
+ continue;
33
+ }
34
+ if (value === null) {
35
+ out[key] = null; // valid for nullable fields (e.g. dlq maxAge)
36
+ continue;
37
+ }
38
+ const n = toFiniteNumber(value);
39
+ // coerce numeric strings; omit non-numeric garbage so the manager's merge
40
+ // keeps the existing/default value instead of storing NaN
41
+ if (n !== undefined)
42
+ out[key] = n;
43
+ }
44
+ return out;
45
+ }
7
46
  // ============ Job Management ============
8
47
  /** Handle Update command - update job data */
9
48
  export async function handleUpdate(cmd, ctx, reqId) {
@@ -122,8 +161,11 @@ export function handleCount(cmd, ctx, reqId) {
122
161
  // ============ Rate Limiting ============
123
162
  /** Handle RateLimit command */
124
163
  export function handleRateLimit(cmd, ctx, reqId) {
125
- ctx.queueManager.setRateLimit(cmd.queue, cmd.limit);
126
- ctx.queueManager.emitDashboardEvent('ratelimit:set', { queue: cmd.queue, max: cmd.limit });
164
+ const limit = toFiniteNumber(cmd.limit);
165
+ if (limit === undefined)
166
+ return resp.error('limit must be a finite number', reqId);
167
+ ctx.queueManager.setRateLimit(cmd.queue, limit);
168
+ ctx.queueManager.emitDashboardEvent('ratelimit:set', { queue: cmd.queue, max: limit });
127
169
  return resp.ok(undefined, reqId);
128
170
  }
129
171
  /** Handle RateLimitClear command */
@@ -134,10 +176,13 @@ export function handleRateLimitClear(cmd, ctx, reqId) {
134
176
  }
135
177
  /** Handle SetConcurrency command */
136
178
  export function handleSetConcurrency(cmd, ctx, reqId) {
137
- ctx.queueManager.setConcurrency(cmd.queue, cmd.limit);
179
+ const limit = toFiniteNumber(cmd.limit);
180
+ if (limit === undefined)
181
+ return resp.error('limit must be a finite number', reqId);
182
+ ctx.queueManager.setConcurrency(cmd.queue, limit);
138
183
  ctx.queueManager.emitDashboardEvent('concurrency:set', {
139
184
  queue: cmd.queue,
140
- concurrency: cmd.limit,
185
+ concurrency: limit,
141
186
  });
142
187
  return resp.ok(undefined, reqId);
143
188
  }
@@ -150,10 +195,11 @@ export function handleClearConcurrency(cmd, ctx, reqId) {
150
195
  // ============ Config Commands ============
151
196
  /** Handle SetStallConfig command */
152
197
  export function handleSetStallConfig(cmd, ctx, reqId) {
153
- ctx.queueManager.setStallConfig(cmd.queue, cmd.config);
198
+ const config = sanitizeConfigNumbers(cmd.config, ['stallInterval', 'maxStalls', 'gracePeriod']);
199
+ ctx.queueManager.setStallConfig(cmd.queue, config);
154
200
  ctx.queueManager.emitDashboardEvent('config:stall-changed', {
155
201
  queue: cmd.queue,
156
- config: cmd.config,
202
+ config,
157
203
  });
158
204
  return resp.ok(undefined, reqId);
159
205
  }
@@ -164,10 +210,16 @@ export function handleGetStallConfig(cmd, ctx, reqId) {
164
210
  }
165
211
  /** Handle SetDlqConfig command */
166
212
  export function handleSetDlqConfig(cmd, ctx, reqId) {
167
- ctx.queueManager.setDlqConfig(cmd.queue, cmd.config);
213
+ const config = sanitizeConfigNumbers(cmd.config, [
214
+ 'autoRetryInterval',
215
+ 'maxAutoRetries',
216
+ 'maxAge',
217
+ 'maxEntries',
218
+ ]);
219
+ ctx.queueManager.setDlqConfig(cmd.queue, config);
168
220
  ctx.queueManager.emitDashboardEvent('config:dlq-changed', {
169
221
  queue: cmd.queue,
170
- config: cmd.config,
222
+ config,
171
223
  });
172
224
  return resp.ok(undefined, reqId);
173
225
  }
@@ -189,7 +189,7 @@ export async function handleAckBatch(cmd, ctx, reqId) {
189
189
  export async function handleFail(cmd, ctx, reqId) {
190
190
  try {
191
191
  const jid = jobId(cmd.id);
192
- await ctx.queueManager.fail(jid, cmd.error, cmd.token);
192
+ await ctx.queueManager.fail(jid, cmd.error, cmd.token, cmd.unrecoverable);
193
193
  // Unregister job from client tracking
194
194
  ctx.queueManager.unregisterClientJob(ctx.clientId, jid);
195
195
  return resp.ok(undefined, reqId);
@@ -40,6 +40,7 @@ export function handleCron(cmd, ctx, reqId) {
40
40
  repeatEvery: cron.repeatEvery,
41
41
  nextRun: cron.nextRun,
42
42
  timezone: cron.timezone,
43
+ priority: cron.priority,
43
44
  },
44
45
  reqId,
45
46
  };
@@ -18,8 +18,13 @@ export function handleAddLog(cmd, ctx, reqId) {
18
18
  }
19
19
  export function handleGetLogs(cmd, ctx, reqId) {
20
20
  const jid = jobId(cmd.id);
21
- const logs = ctx.queueManager.getLogs(jid);
22
- return resp.data({ logs }, reqId);
21
+ const all = ctx.queueManager.getLogs(jid);
22
+ // Honor optional pagination (start/end inclusive) the client already sends.
23
+ const total = all.length;
24
+ const logs = cmd.start === undefined && cmd.end === undefined
25
+ ? all
26
+ : all.slice(cmd.start ?? 0, (cmd.end ?? total - 1) + 1);
27
+ return resp.data({ logs, count: total }, reqId);
23
28
  }
24
29
  // ============ Worker Heartbeat ============
25
30
  export function handleHeartbeat(cmd, ctx, reqId) {
@@ -5,6 +5,7 @@
5
5
  import type { Server, ServerWebSocket } from 'bun';
6
6
  import type { QueueManager } from '../../application/queueManager';
7
7
  import { type WsData } from './wsHandler';
8
+ import { type TlsServerOptions } from './tls';
8
9
  /** HTTP Server configuration */
9
10
  export interface HttpServerConfig {
10
11
  port?: number;
@@ -13,6 +14,8 @@ export interface HttpServerConfig {
13
14
  authTokens?: string[];
14
15
  corsOrigins?: string[];
15
16
  requireAuthForMetrics?: boolean;
17
+ /** Native TLS termination (https/wss). Protocol and routes are unchanged. */
18
+ tls?: TlsServerOptions;
16
19
  }
17
20
  /**
18
21
  * Create and start HTTP server
@@ -10,6 +10,7 @@ import { SseHandler } from './sseHandler';
10
10
  import { WsHandler } from './wsHandler';
11
11
  import { jsonResponse, corsResponse, healthEndpoint, gcEndpoint, heapStatsEndpoint, statsEndpoint, metricsEndpoint, dashboardOverviewEndpoint, dashboardQueuesEndpoint, dashboardQueueDetailEndpoint, } from './httpEndpoints';
12
12
  import { routeJobRoutes } from './httpRouteJobs';
13
+ import { loadTlsOptions } from './tls';
13
14
  import { routeQueueRoutes } from './httpRouteQueues';
14
15
  import { routeQueueConfigRoutes } from './httpRouteQueueConfig';
15
16
  import { routeResourceRoutes } from './httpRouteResources';
@@ -59,6 +60,17 @@ export function createHttpServer(queueManager, config) {
59
60
  });
60
61
  // Helper to get CORS origin string
61
62
  const getCorsOrigin = () => (corsOrigins.has('*') ? '*' : Array.from(corsOrigins).join(', '));
63
+ // Attach CORS to responses built outside the routeRequest pipeline (health,
64
+ // ready, prometheus, debug) so browser dashboards can read them cross-origin
65
+ // (audit #16-20). Response headers are mutable for normally-constructed
66
+ // Responses; this never overwrites an existing value set by the endpoint.
67
+ const withCors = async (r) => {
68
+ const res = await r;
69
+ if (!res.headers.has('Access-Control-Allow-Origin')) {
70
+ res.headers.set('Access-Control-Allow-Origin', getCorsOrigin());
71
+ }
72
+ return res;
73
+ };
62
74
  // Fetch handler
63
75
  const fetch = async (req, server) => {
64
76
  const url = new URL(req.url);
@@ -69,26 +81,26 @@ export function createHttpServer(queueManager, config) {
69
81
  }
70
82
  // Health endpoints (no auth, no rate limit)
71
83
  if (path === '/health') {
72
- return healthEndpoint(queueManager, wsHandler.size, sseHandler.size);
84
+ return withCors(healthEndpoint(queueManager, wsHandler.size, sseHandler.size));
73
85
  }
74
86
  if (path === '/healthz' || path === '/live') {
75
- return new Response('OK', { status: 200 });
87
+ return withCors(new Response('OK', { status: 200 }));
76
88
  }
77
89
  if (path === '/ready') {
78
- return jsonResponse({ ok: true, ready: true });
90
+ return jsonResponse({ ok: true, ready: true }, 200, corsOrigins);
79
91
  }
80
92
  // Debug endpoints (require auth)
81
93
  if (path === '/gc' && req.method === 'POST') {
82
94
  const denied = checkAuth(req, authTokens);
83
95
  if (denied)
84
96
  return denied;
85
- return gcEndpoint(queueManager);
97
+ return withCors(gcEndpoint(queueManager));
86
98
  }
87
99
  if (path === '/heapstats' && req.method === 'GET') {
88
100
  const denied = checkAuth(req, authTokens);
89
101
  if (denied)
90
102
  return denied;
91
- return heapStatsEndpoint(queueManager);
103
+ return withCors(heapStatsEndpoint(queueManager));
92
104
  }
93
105
  // Rate limiting
94
106
  const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
@@ -131,7 +143,10 @@ export function createHttpServer(queueManager, config) {
131
143
  return denied;
132
144
  }
133
145
  return new Response(queueManager.getPrometheusMetrics(), {
134
- headers: { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' },
146
+ headers: {
147
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
148
+ 'Access-Control-Allow-Origin': getCorsOrigin(),
149
+ },
135
150
  });
136
151
  }
137
152
  // Check authentication for other endpoints
@@ -188,15 +203,22 @@ export function createHttpServer(queueManager, config) {
188
203
  });
189
204
  },
190
205
  };
191
- // Create server
206
+ // Create server (validate TLS files BEFORE binding, fail fast on bad paths)
207
+ const tlsOptions = config.tls ? loadTlsOptions(config.tls) : undefined;
192
208
  let server;
193
209
  if (config.socketPath) {
194
- server = Bun.serve({ unix: config.socketPath, fetch, websocket });
210
+ server = Bun.serve({
211
+ unix: config.socketPath,
212
+ ...(tlsOptions && { tls: tlsOptions }),
213
+ fetch,
214
+ websocket,
215
+ });
195
216
  }
196
217
  else {
197
218
  server = Bun.serve({
198
219
  hostname: config.hostname ?? '0.0.0.0',
199
220
  port: config.port ?? 6790,
221
+ ...(tlsOptions && { tls: tlsOptions }),
200
222
  fetch,
201
223
  websocket,
202
224
  });
@@ -79,6 +79,7 @@ async function routeJobManagement(req, path, method, ctx, cors) {
79
79
  cmd: 'ChangePriority',
80
80
  id: priorityMatch[1],
81
81
  priority: body['priority'],
82
+ lifo: body['lifo'],
82
83
  }, ctx);
83
84
  return jsonResponse(r, r.ok ? 200 : 400, cors);
84
85
  }
@@ -250,7 +251,12 @@ export async function routeJobRoutes(req, path, method, ctx, cors) {
250
251
  const body = await parseJsonBody(req, cors);
251
252
  if (body instanceof Response)
252
253
  return body;
253
- const r = await handleCommand({ cmd: 'ACK', id: ackMatch[1], result: body['result'] }, ctx);
254
+ const r = await handleCommand({
255
+ cmd: 'ACK',
256
+ id: ackMatch[1],
257
+ result: body['result'],
258
+ token: body['token'],
259
+ }, ctx);
254
260
  return jsonResponse(r, r.ok ? 200 : 400, cors);
255
261
  }
256
262
  // POST /jobs/:id/fail
@@ -259,7 +265,13 @@ export async function routeJobRoutes(req, path, method, ctx, cors) {
259
265
  const body = await parseJsonBody(req, cors);
260
266
  if (body instanceof Response)
261
267
  return body;
262
- const r = await handleCommand({ cmd: 'FAIL', id: failMatch[1], error: body['error'] }, ctx);
268
+ const r = await handleCommand({
269
+ cmd: 'FAIL',
270
+ id: failMatch[1],
271
+ error: body['error'],
272
+ token: body['token'],
273
+ unrecoverable: body['unrecoverable'],
274
+ }, ctx);
263
275
  return jsonResponse(r, r.ok ? 200 : 400, cors);
264
276
  }
265
277
  // Delegate to sub-routers
@@ -26,8 +26,23 @@ export async function routeQueueConfigRoutes(req, path, method, ctx, cors) {
26
26
  const dlqMatch = path.match(RE_QUEUE_DLQ);
27
27
  if (dlqMatch && method === 'GET') {
28
28
  const queue = decodeURIComponent(dlqMatch[1]);
29
- const entries = ctx.queueManager.getDlqEntries(queue);
30
- return jsonResponse({ ok: true, entries }, 200, cors);
29
+ const all = ctx.queueManager.getDlqEntries(queue);
30
+ // Optional pagination so a dashboard can page large DLQs. Non-numeric params
31
+ // are ignored (treated as absent) rather than producing an empty/garbage slice.
32
+ const params = new URL(req.url).searchParams;
33
+ const toInt = (v) => {
34
+ if (v === null)
35
+ return undefined;
36
+ const n = Number(v);
37
+ return Number.isFinite(n) ? Math.trunc(n) : undefined;
38
+ };
39
+ const limit = toInt(params.get('limit'));
40
+ const offset = toInt(params.get('offset'));
41
+ const start = Math.max(0, offset ?? 0);
42
+ const entries = limit === undefined && offset === undefined
43
+ ? all
44
+ : all.slice(start, start + (limit !== undefined ? Math.max(0, limit) : all.length));
45
+ return jsonResponse({ ok: true, entries, total: all.length }, 200, cors);
31
46
  }
32
47
  // POST /queues/:queue/dlq/retry
33
48
  const dlqRetryMatch = path.match(RE_QUEUE_DLQ_RETRY);
@@ -79,7 +94,8 @@ export async function routeQueueConfigRoutes(req, path, method, ctx, cors) {
79
94
  const r = await handleCommand({
80
95
  cmd: 'SetConcurrency',
81
96
  queue,
82
- limit: body['limit'],
97
+ // Accept the natural `concurrency` field for this endpoint as well as `limit`.
98
+ limit: (body['concurrency'] ?? body['limit']),
83
99
  }, ctx);
84
100
  return jsonResponse(r, 200, cors);
85
101
  }
@@ -105,7 +105,19 @@ async function routeJobOps(req, path, method, ctx, cors) {
105
105
  if (listMatch && method === 'GET') {
106
106
  const queue = decodeURIComponent(listMatch[1]);
107
107
  const url = new URL(req.url);
108
- const stateValues = url.searchParams.getAll('state');
108
+ // Accept `state`, `status` (dashboard/REST convention), and `states` as
109
+ // aliases, each repeatable and comma-separated. Previously only `state` was
110
+ // read, so `?status=failed` silently fell through to an unfiltered list and
111
+ // returned the whole queue (#95). A state name never contains a comma, so
112
+ // splitting is safe.
113
+ const stateValues = [
114
+ ...url.searchParams.getAll('state'),
115
+ ...url.searchParams.getAll('status'),
116
+ ...url.searchParams.getAll('states'),
117
+ ]
118
+ .flatMap((v) => v.split(','))
119
+ .map((s) => s.trim())
120
+ .filter(Boolean);
109
121
  const state = stateValues.length === 0
110
122
  ? undefined
111
123
  : stateValues.length === 1
@@ -40,6 +40,10 @@ export async function routeResourceRoutes(req, path, method, ctx, cors) {
40
40
  uniqueKey: body['uniqueKey'],
41
41
  dedup: body['dedup'],
42
42
  skipMissedOnRestart: body['skipMissedOnRestart'],
43
+ immediately: body['immediately'],
44
+ skipIfNoWorker: body['skipIfNoWorker'],
45
+ preventOverlap: body['preventOverlap'],
46
+ jobOptions: body['jobOptions'],
43
47
  }, ctx);
44
48
  return jsonResponse(r, r.ok ? 200 : 400, cors);
45
49
  }
@@ -9,6 +9,7 @@ import { type HandlerContext } from './handler';
9
9
  import { FrameParser, type ConnectionState } from './protocol';
10
10
  import { Semaphore } from '../../shared/semaphore';
11
11
  import { SocketWriteQueue } from './socketWriteQueue';
12
+ import { type TlsServerOptions } from './tls';
12
13
  /** TCP Server configuration */
13
14
  export interface TcpServerConfig {
14
15
  /** TCP port */
@@ -29,6 +30,11 @@ export interface TcpServerConfig {
29
30
  * Mainly for tests.
30
31
  */
31
32
  maxWriteQueueBytes?: number;
33
+ /**
34
+ * Native TLS termination. When set, the server only accepts TLS clients —
35
+ * the msgpack protocol is unchanged, only the transport is wrapped.
36
+ */
37
+ tls?: TlsServerOptions;
32
38
  }
33
39
  /** Per-connection data */
34
40
  interface ConnectionData {