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.
- package/dist/application/operations/ack.d.ts +1 -1
- package/dist/application/operations/ack.js +2 -2
- package/dist/application/queueManager.d.ts +1 -1
- package/dist/application/queueManager.js +2 -2
- package/dist/application/statsManager.js +18 -8
- package/dist/cli/client.d.ts +3 -0
- package/dist/cli/client.js +13 -2
- package/dist/cli/commands/server.js +26 -2
- package/dist/cli/help.js +5 -0
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/index.js +53 -1
- package/dist/client/queue/dlq.js +1 -1
- package/dist/client/queue/operations/management.js +4 -2
- package/dist/client/queue/queue.js +2 -0
- package/dist/client/queue/scheduler.js +5 -0
- package/dist/client/tcp/client.js +1 -0
- package/dist/client/tcp/connection.d.ts +8 -1
- package/dist/client/tcp/connection.js +27 -1
- package/dist/client/tcp/index.d.ts +1 -1
- package/dist/client/tcp/shared.d.ts +6 -4
- package/dist/client/tcp/shared.js +27 -11
- package/dist/client/tcp/types.d.ts +13 -0
- package/dist/client/tcp/types.js +1 -0
- package/dist/client/tcpPool.js +11 -1
- package/dist/client/types.d.ts +8 -0
- package/dist/client/worker/worker.js +7 -2
- package/dist/client/worker/workerPull.d.ts +2 -0
- package/dist/client/worker/workerPull.js +12 -5
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/resolve.d.ts +14 -0
- package/dist/config/resolve.js +19 -0
- package/dist/config/types.d.ts +4 -0
- package/dist/domain/types/command.d.ts +4 -0
- package/dist/infrastructure/server/handlers/advanced.js +60 -8
- package/dist/infrastructure/server/handlers/core.js +1 -1
- package/dist/infrastructure/server/handlers/cron.js +1 -0
- package/dist/infrastructure/server/handlers/monitoring.js +7 -2
- package/dist/infrastructure/server/http.d.ts +3 -0
- package/dist/infrastructure/server/http.js +30 -8
- package/dist/infrastructure/server/httpRouteJobs.js +14 -2
- package/dist/infrastructure/server/httpRouteQueueConfig.js +19 -3
- package/dist/infrastructure/server/httpRouteQueues.js +13 -1
- package/dist/infrastructure/server/httpRouteResources.js +4 -0
- package/dist/infrastructure/server/tcp.d.ts +6 -0
- package/dist/infrastructure/server/tcp.js +5 -1
- package/dist/infrastructure/server/tls.d.ts +21 -0
- package/dist/infrastructure/server/tls.js +19 -0
- package/dist/main.js +13 -1
- package/package.json +1 -1
package/dist/client/tcpPool.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/client/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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)
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/config/index.js
CHANGED
|
@@ -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';
|
package/dist/config/resolve.d.ts
CHANGED
|
@@ -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 */
|
package/dist/config/resolve.js
CHANGED
|
@@ -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;
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
|
22
|
-
|
|
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: {
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|