bunqueue 2.8.7 → 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.
@@ -2,6 +2,7 @@
2
2
  * CLI TCP Client
3
3
  * Connects to bunQ server and executes commands (msgpack binary protocol)
4
4
  */
5
+ import type { ClientTlsOptions } from '../client/tcp/types';
5
6
  /** Client options */
6
7
  export interface ClientOptions {
7
8
  /** Server host */
@@ -10,6 +11,8 @@ export interface ClientOptions {
10
11
  port: number;
11
12
  /** Auth token */
12
13
  token?: string;
14
+ /** TLS to the server (true = system CAs, object = custom CA / no-verify) */
15
+ tls?: boolean | ClientTlsOptions;
13
16
  /** Output as JSON */
14
17
  json: boolean;
15
18
  }
@@ -6,6 +6,7 @@ import { formatOutput, formatError } from './output';
6
6
  import { pack, unpack } from 'msgpackr';
7
7
  import { FrameParser, FrameSizeError } from '../infrastructure/server/protocol';
8
8
  import { CommandError } from './commands/types';
9
+ import { buildClientTls } from '../client/tcp/connection';
9
10
  /** Send a command and wait for response */
10
11
  async function sendCommand(socket, command) {
11
12
  return new Promise((resolve, reject) => {
@@ -45,7 +46,10 @@ async function connect(options) {
45
46
  catch (err) {
46
47
  if (err instanceof FrameSizeError) {
47
48
  if (socketData.reject) {
48
- socketData.reject(new Error(`Frame too large: ${err.requestedSize} bytes exceeds maximum ${err.maxSize}`));
49
+ // A plaintext client reading a TLS handshake sees garbage that
50
+ // parses as an absurd frame size — hint at the likely cause.
51
+ socketData.reject(new Error(`Frame too large: ${err.requestedSize} bytes exceeds maximum ${err.maxSize}` +
52
+ ' (is the server running with TLS? try --tls, --tls-ca or --tls-no-verify)'));
49
53
  socketData.resolve = null;
50
54
  socketData.reject = null;
51
55
  }
@@ -95,11 +99,18 @@ async function connect(options) {
95
99
  reject(new Error(`Failed to connect to ${targetDesc}: ${error.message}`));
96
100
  },
97
101
  };
98
- // Connect via TCP
102
+ // Connect via TCP (optionally wrapped in TLS)
103
+ const tlsValue = buildClientTls(options.tls);
99
104
  void Bun.connect({
100
105
  hostname: options.host,
101
106
  port: options.port,
107
+ ...(tlsValue !== undefined && { tls: tlsValue }),
102
108
  socket: socketHandlers,
109
+ }).catch((err) => {
110
+ // Bun.connect can reject directly (e.g. TLS handshake refused) instead of
111
+ // firing connectError — surface it instead of waiting out the timeout.
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ reject(new Error(`Failed to connect to ${targetDesc}: ${message}`));
103
114
  });
104
115
  // Handle connection timeout
105
116
  const connectionTimeoutId = setTimeout(() => {
@@ -5,7 +5,7 @@
5
5
  import { parseArgs } from 'node:util';
6
6
  import { printServerHelp } from '../help';
7
7
  import { VERSION } from '../../shared/version';
8
- import { loadConfigFile, resolveServerConfig, resolveCloudConfig } from '../../config';
8
+ import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveTlsServerOptions, } from '../../config';
9
9
  /** Validate port number */
10
10
  function validatePort(value, name, defaultPort) {
11
11
  const port = parseInt(value, 10);
@@ -25,6 +25,8 @@ function parseCliFlags(args) {
25
25
  host: { type: 'string' },
26
26
  'data-path': { type: 'string' },
27
27
  'auth-tokens': { type: 'string' },
28
+ 'tls-cert': { type: 'string' },
29
+ 'tls-key': { type: 'string' },
28
30
  config: { type: 'string', short: 'c' },
29
31
  },
30
32
  allowPositionals: false,
@@ -46,6 +48,12 @@ function parseCliFlags(args) {
46
48
  if (values['auth-tokens']) {
47
49
  flags.authTokens = values['auth-tokens'].split(',').filter(Boolean);
48
50
  }
51
+ if (values['tls-cert']) {
52
+ flags.tlsCertFile = values['tls-cert'];
53
+ }
54
+ if (values['tls-key']) {
55
+ flags.tlsKeyFile = values['tls-key'];
56
+ }
49
57
  if (values.config) {
50
58
  flags.configPath = values.config;
51
59
  }
@@ -58,7 +66,9 @@ function applyCliFlags(fileConfig, flags) {
58
66
  flags.httpPort !== undefined ||
59
67
  flags.host !== undefined ||
60
68
  flags.dataPath !== undefined ||
61
- flags.authTokens !== undefined;
69
+ flags.authTokens !== undefined ||
70
+ flags.tlsCertFile !== undefined ||
71
+ flags.tlsKeyFile !== undefined;
62
72
  if (!hasFlags && !fileConfig)
63
73
  return null;
64
74
  const base = fileConfig ?? {};
@@ -69,6 +79,8 @@ function applyCliFlags(fileConfig, flags) {
69
79
  ...(flags.tcpPort !== undefined && { tcpPort: flags.tcpPort }),
70
80
  ...(flags.httpPort !== undefined && { httpPort: flags.httpPort }),
71
81
  ...(flags.host !== undefined && { host: flags.host }),
82
+ ...(flags.tlsCertFile !== undefined && { tlsCertFile: flags.tlsCertFile }),
83
+ ...(flags.tlsKeyFile !== undefined && { tlsKeyFile: flags.tlsKeyFile }),
72
84
  },
73
85
  storage: {
74
86
  ...base.storage,
@@ -92,6 +104,15 @@ export async function runServer(args, showHelp) {
92
104
  const mergedConfig = applyCliFlags(fileConfig, flags);
93
105
  const config = resolveServerConfig(mergedConfig);
94
106
  const cloudConfig = resolveCloudConfig(mergedConfig, config.dataPath);
107
+ // Resolve TLS — fail fast on partial cert/key before binding anything
108
+ let tlsConfig;
109
+ try {
110
+ tlsConfig = resolveTlsServerOptions(config);
111
+ }
112
+ catch (err) {
113
+ console.error(err instanceof Error ? err.message : String(err));
114
+ process.exit(1);
115
+ }
95
116
  // Import and start the server components
96
117
  const { QueueManager } = await import('../../application/queueManager');
97
118
  const { createTcpServer } = await import('../../infrastructure/server/tcp');
@@ -110,11 +131,13 @@ export async function runServer(args, showHelp) {
110
131
  port: config.tcpPort,
111
132
  hostname: config.hostname,
112
133
  authTokens,
134
+ ...(tlsConfig && { tls: tlsConfig }),
113
135
  });
114
136
  httpServer = createHttpServer(qm, {
115
137
  port: config.httpPort,
116
138
  hostname: config.hostname,
117
139
  authTokens,
140
+ ...(tlsConfig && { tls: tlsConfig }),
118
141
  });
119
142
  }
120
143
  catch (err) {
@@ -159,6 +182,7 @@ ${dim}────────────────────────
159
182
  ${green}●${reset} TCP ${tcpDisplay}
160
183
  ${green}●${reset} HTTP ${httpDisplay}
161
184
  ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
185
+ ${yellow}●${reset} TLS ${tlsConfig ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
162
186
  ${yellow}●${reset} Auth ${authTokens ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
163
187
  ${yellow}●${reset} Cloud ${cloudConfig ? `${green}enabled${reset} ${dim}→ ${cloudConfig.url}${reset}` : `${dim}disabled${reset}`}
164
188
 
package/dist/cli/help.js CHANGED
@@ -98,6 +98,9 @@ GLOBAL OPTIONS:
98
98
  -H, --host <host> Server host (default: localhost)
99
99
  -p, --port <port> TCP port (default: 6789)
100
100
  -t, --token <token> Authentication token (env: BQ_TOKEN, BUNQUEUE_TOKEN)
101
+ --tls Connect with TLS (verify with system CAs)
102
+ --tls-ca <file> Trust a custom CA cert (implies --tls)
103
+ --tls-no-verify TLS without cert verification (self-signed, dev only)
101
104
  --json Output as JSON
102
105
  --help Show help
103
106
  --version Show version
@@ -125,6 +128,8 @@ Options:
125
128
  --host <host> Bind address (default: 0.0.0.0, env: HOST)
126
129
  --data-path <path> SQLite database path (env: DATA_PATH)
127
130
  --auth-tokens <list> Comma-separated auth tokens (env: AUTH_TOKENS)
131
+ --tls-cert <file> PEM certificate for native TLS (env: TLS_CERT_FILE)
132
+ --tls-key <file> PEM private key for native TLS (env: TLS_KEY_FILE)
128
133
  --help Show this help
129
134
 
130
135
  Examples:
@@ -8,6 +8,11 @@ interface GlobalOptions {
8
8
  host: string;
9
9
  port: number;
10
10
  token?: string;
11
+ /** TLS to the server: true = verify with system CAs, object = custom CA / no-verify */
12
+ tls?: boolean | {
13
+ rejectUnauthorized?: boolean;
14
+ caFile?: string;
15
+ };
11
16
  json: boolean;
12
17
  help: boolean;
13
18
  version: boolean;
package/dist/cli/index.js CHANGED
@@ -35,6 +35,53 @@ function resolveEnvPort(currentPort) {
35
35
  function resolveEnvHost(currentHost) {
36
36
  return Bun.env.HOST ?? Bun.env.BUNQUEUE_HOST ?? Bun.env.BQ_HOST ?? currentHost;
37
37
  }
38
+ /**
39
+ * Handle a `--tls*` global client flag and return the index to continue
40
+ * scanning from. Flags that are NOT client TLS flags (e.g. the server's
41
+ * --tls-cert/--tls-key) are passed through to `commandArgs`.
42
+ */
43
+ function applyTlsFlag(arg, allArgs, i, state, commandArgs) {
44
+ if (arg === '--tls') {
45
+ state.enabled = true;
46
+ return i;
47
+ }
48
+ if (arg === '--tls-no-verify') {
49
+ state.noVerify = true;
50
+ return i;
51
+ }
52
+ if (arg === '--tls-ca') {
53
+ const nextArg = allArgs[i + 1];
54
+ // A following flag is not a path: don't swallow it (--tls-ca --json ...)
55
+ if (nextArg === undefined || nextArg.startsWith('-')) {
56
+ console.warn('Warning: --tls-ca requires a file path. Option ignored.');
57
+ return i;
58
+ }
59
+ state.caFile = nextArg;
60
+ return i + 1;
61
+ }
62
+ if (arg.startsWith('--tls-ca=')) {
63
+ const val = arg.slice(9);
64
+ if (!val) {
65
+ console.warn('Warning: --tls-ca= requires a file path. Option ignored.');
66
+ }
67
+ else {
68
+ state.caFile = val;
69
+ }
70
+ return i;
71
+ }
72
+ commandArgs.push(arg); // --tls-cert / --tls-key etc. → server flags, pass through
73
+ return i;
74
+ }
75
+ /** Build the GlobalOptions.tls value: --tls-ca / --tls-no-verify imply TLS */
76
+ function buildTlsOption(state) {
77
+ if (state.noVerify || state.caFile !== undefined) {
78
+ return {
79
+ ...(state.noVerify && { rejectUnauthorized: false }),
80
+ ...(state.caFile !== undefined && { caFile: state.caFile }),
81
+ };
82
+ }
83
+ return state.enabled ? true : undefined;
84
+ }
38
85
  /** Parse global options from process.argv */
39
86
  export function parseGlobalOptions() {
40
87
  const allArgs = process.argv.slice(2);
@@ -47,6 +94,7 @@ export function parseGlobalOptions() {
47
94
  let version = false;
48
95
  let hostExplicit = false;
49
96
  let portExplicit = false;
97
+ const tlsState = { enabled: false, noVerify: false };
50
98
  const commandArgs = [];
51
99
  let i = 0;
52
100
  while (i < allArgs.length) {
@@ -76,6 +124,9 @@ export function parseGlobalOptions() {
76
124
  token = allArgs[++i];
77
125
  }
78
126
  }
127
+ else if (arg.startsWith('--tls')) {
128
+ i = applyTlsFlag(arg, allArgs, i, tlsState, commandArgs);
129
+ }
79
130
  else if (arg === '--json') {
80
131
  json = true;
81
132
  }
@@ -137,7 +188,7 @@ export function parseGlobalOptions() {
137
188
  if (!hostExplicit)
138
189
  host = resolveEnvHost(host);
139
190
  return {
140
- options: { host, port, token, json, help, version },
191
+ options: { host, port, token, tls: buildTlsOption(tlsState), json, help, version },
141
192
  commandArgs,
142
193
  };
143
194
  }
@@ -231,6 +282,7 @@ export async function main() {
231
282
  host: options.host,
232
283
  port: options.port,
233
284
  token: options.token,
285
+ tls: options.tls,
234
286
  json: options.json,
235
287
  });
236
288
  }
@@ -60,6 +60,7 @@ export class Queue {
60
60
  this.tcpPool = getSharedPool({
61
61
  host: connOpts.host,
62
62
  port: connOpts.port,
63
+ tls: connOpts.tls,
63
64
  poolSize,
64
65
  pingInterval: connOpts.pingInterval,
65
66
  commandTimeout: connOpts.commandTimeout,
@@ -74,6 +75,7 @@ export class Queue {
74
75
  host: connOpts.host ?? 'localhost',
75
76
  port: connOpts.port ?? 6789,
76
77
  token,
78
+ tls: connOpts.tls,
77
79
  poolSize,
78
80
  pingInterval: connOpts.pingInterval,
79
81
  commandTimeout: connOpts.commandTimeout,
@@ -137,6 +137,7 @@ export class TcpClient extends EventEmitter {
137
137
  const { socket } = await createConnection({
138
138
  host: this.options.host,
139
139
  port: this.options.port,
140
+ tls: this.options.tls,
140
141
  }, this.options.connectTimeout, {
141
142
  onData: (frame) => {
142
143
  this.handleData(frame);
@@ -2,7 +2,7 @@
2
2
  * TCP Connection Handler
3
3
  * Manages low-level socket connection and data handling (msgpack binary protocol)
4
4
  */
5
- import type { SocketWrapper, PendingCommand } from './types';
5
+ import type { SocketWrapper, PendingCommand, ClientTlsOptions } from './types';
6
6
  /** Connection events */
7
7
  export interface ConnectionEvents {
8
8
  onData: (frame: Uint8Array) => void;
@@ -20,7 +20,14 @@ export interface ConnectionTarget {
20
20
  host?: string;
21
21
  /** TCP port */
22
22
  port?: number;
23
+ /** Enable TLS: true (system CAs) or per-connection TLS options */
24
+ tls?: boolean | ClientTlsOptions;
23
25
  }
26
+ /**
27
+ * Map client TLS options to the `tls` value accepted by Bun.connect.
28
+ * Returns undefined when TLS is disabled (plaintext, the default).
29
+ */
30
+ export declare function buildClientTls(tls: boolean | ClientTlsOptions | undefined): true | Record<string, unknown> | undefined;
24
31
  /**
25
32
  * Establish TCP connection to server
26
33
  */
@@ -3,6 +3,20 @@
3
3
  * Manages low-level socket connection and data handling (msgpack binary protocol)
4
4
  */
5
5
  import { FrameParser, FrameSizeError } from '../../infrastructure/server/protocol';
6
+ /**
7
+ * Map client TLS options to the `tls` value accepted by Bun.connect.
8
+ * Returns undefined when TLS is disabled (plaintext, the default).
9
+ */
10
+ export function buildClientTls(tls) {
11
+ if (!tls)
12
+ return undefined;
13
+ if (tls === true)
14
+ return true;
15
+ return {
16
+ ...(tls.rejectUnauthorized !== undefined && { rejectUnauthorized: tls.rejectUnauthorized }),
17
+ ...(tls.caFile !== undefined && { ca: Bun.file(tls.caFile) }),
18
+ };
19
+ }
6
20
  /**
7
21
  * Establish TCP connection to server
8
22
  */
@@ -82,11 +96,23 @@ export async function createConnection(target, connectTimeout, events) {
82
96
  }
83
97
  },
84
98
  };
85
- // Connect via TCP
99
+ // Connect via TCP (optionally wrapped in TLS — protocol is unchanged)
100
+ const tlsValue = buildClientTls(target.tls);
86
101
  void Bun.connect({
87
102
  hostname: target.host ?? 'localhost',
88
103
  port: target.port ?? 6789,
104
+ ...(tlsValue !== undefined && { tls: tlsValue }),
89
105
  socket: socketHandlers,
106
+ }).catch((error) => {
107
+ // Bun.connect rejects (instead of firing connectError) for some failure
108
+ // modes, e.g. a TLS handshake refused synchronously. Route it through the
109
+ // same rejection path so callers never hang until the connect timeout.
110
+ if (!connectionResolved) {
111
+ connectionResolved = true;
112
+ cleanup();
113
+ const message = error instanceof Error ? error.message : String(error);
114
+ reject(new Error(`Failed to connect to ${targetDesc}: ${message}`));
115
+ }
90
116
  });
91
117
  timeoutId = setTimeout(() => {
92
118
  if (!connectionResolved) {
@@ -2,7 +2,7 @@
2
2
  * TCP Client Module
3
3
  * Re-exports all TCP client components
4
4
  */
5
- export type { ConnectionOptions, ConnectionHealth, PendingCommand, SocketWrapper } from './types';
5
+ export type { ConnectionOptions, ConnectionHealth, PendingCommand, SocketWrapper, ClientTlsOptions, } from './types';
6
6
  export { DEFAULT_CONNECTION } from './types';
7
7
  export { HealthTracker, type HealthConfig } from './health';
8
8
  export { ReconnectManager, type ReconnectConfig } from './reconnect';
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Shared TCP Client Instance
3
- * Singleton pattern for shared client management
2
+ * Shared TCP Client Instances
3
+ * One shared client per distinct connection target. Keyed by
4
+ * host/port/token/tls so callers with different configs (notably TLS vs
5
+ * plaintext to the same server) never receive each other's connection.
4
6
  */
5
7
  import type { ConnectionOptions } from './types';
6
8
  import { TcpClient } from './client';
7
- /** Get shared TCP client */
9
+ /** Get shared TCP client for the given connection target */
8
10
  export declare function getSharedTcpClient(options?: Partial<ConnectionOptions>): TcpClient;
9
- /** Close shared client */
11
+ /** Close all shared clients */
10
12
  export declare function closeSharedTcpClient(): void;
@@ -1,19 +1,35 @@
1
1
  /**
2
- * Shared TCP Client Instance
3
- * Singleton pattern for shared client management
2
+ * Shared TCP Client Instances
3
+ * One shared client per distinct connection target. Keyed by
4
+ * host/port/token/tls so callers with different configs (notably TLS vs
5
+ * plaintext to the same server) never receive each other's connection.
4
6
  */
5
7
  import { TcpClient } from './client';
6
- /** Shared client instance */
7
- let sharedClient = null;
8
- /** Get shared TCP client */
8
+ /** Shared clients keyed by connection target */
9
+ const sharedClients = new Map();
10
+ /** Build the sharing key from the connection-identity options */
11
+ function getClientKey(options) {
12
+ const host = options?.host ?? 'localhost';
13
+ const port = options?.port ?? 6789;
14
+ const token = options?.token ?? '';
15
+ const tokenHash = token ? String(Number(Bun.hash(token)) & 0xffff) : '0';
16
+ const tlsKey = options?.tls ? JSON.stringify(options.tls) : '0';
17
+ return `${host}:${port}:${tokenHash}:${tlsKey}`;
18
+ }
19
+ /** Get shared TCP client for the given connection target */
9
20
  export function getSharedTcpClient(options) {
10
- sharedClient ??= new TcpClient(options);
11
- return sharedClient;
21
+ const key = getClientKey(options);
22
+ let client = sharedClients.get(key);
23
+ if (!client) {
24
+ client = new TcpClient(options);
25
+ sharedClients.set(key, client);
26
+ }
27
+ return client;
12
28
  }
13
- /** Close shared client */
29
+ /** Close all shared clients */
14
30
  export function closeSharedTcpClient() {
15
- if (sharedClient) {
16
- sharedClient.close();
17
- sharedClient = null;
31
+ for (const client of sharedClients.values()) {
32
+ client.close();
18
33
  }
34
+ sharedClients.clear();
19
35
  }
@@ -2,6 +2,17 @@
2
2
  * TCP Client Types
3
3
  * Type definitions for TCP connection management
4
4
  */
5
+ /**
6
+ * TLS options for client connections.
7
+ * `true` enables TLS with system CA verification; the object form allows
8
+ * trusting a custom CA (self-signed server cert) or disabling verification.
9
+ */
10
+ export interface ClientTlsOptions {
11
+ /** Verify the server certificate (default: true). Set false for self-signed in dev. */
12
+ rejectUnauthorized?: boolean;
13
+ /** Path to a PEM CA certificate to trust (e.g. the self-signed server cert) */
14
+ caFile?: string;
15
+ }
5
16
  /** Connection options */
6
17
  export interface ConnectionOptions {
7
18
  /** Server host */
@@ -10,6 +21,8 @@ export interface ConnectionOptions {
10
21
  port: number;
11
22
  /** Auth token */
12
23
  token?: string;
24
+ /** Enable TLS: true (system CAs) or per-connection TLS options (default: false) */
25
+ tls?: boolean | ClientTlsOptions;
13
26
  /** Max reconnection attempts (default: Infinity) */
14
27
  maxReconnectAttempts?: number;
15
28
  /** Initial reconnect delay in ms (default: 100) */
@@ -7,6 +7,7 @@ export const DEFAULT_CONNECTION = {
7
7
  host: 'localhost',
8
8
  port: 6789,
9
9
  token: '',
10
+ tls: false,
10
11
  maxReconnectAttempts: Infinity,
11
12
  reconnectDelay: 100,
12
13
  maxReconnectDelay: 30000,
@@ -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,
@@ -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[];
@@ -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';
@@ -202,15 +203,22 @@ export function createHttpServer(queueManager, config) {
202
203
  });
203
204
  },
204
205
  };
205
- // Create server
206
+ // Create server (validate TLS files BEFORE binding, fail fast on bad paths)
207
+ const tlsOptions = config.tls ? loadTlsOptions(config.tls) : undefined;
206
208
  let server;
207
209
  if (config.socketPath) {
208
- 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
+ });
209
216
  }
210
217
  else {
211
218
  server = Bun.serve({
212
219
  hostname: config.hostname ?? '0.0.0.0',
213
220
  port: config.port ?? 6790,
221
+ ...(tlsOptions && { tls: tlsOptions }),
214
222
  fetch,
215
223
  websocket,
216
224
  });
@@ -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 {
@@ -11,6 +11,7 @@ import { getRateLimiter } from './rateLimiter';
11
11
  import { pack, unpack } from 'msgpackr';
12
12
  import { Semaphore, withSemaphore } from '../../shared/semaphore';
13
13
  import { SocketWriteQueue } from './socketWriteQueue';
14
+ import { loadTlsOptions } from './tls';
14
15
  /** Max concurrent commands per connection for pipelining */
15
16
  const MAX_CONCURRENT_PER_CONNECTION = 50;
16
17
  /**
@@ -236,10 +237,13 @@ export function createTcpServer(queueManager, config) {
236
237
  socket.data.writeQueue.flush(socket);
237
238
  },
238
239
  };
239
- // Create TCP server
240
+ // Create TCP server (validate TLS files BEFORE binding the port, so a bad
241
+ // path doesn't leave a half-started listener behind)
242
+ const tlsOptions = config.tls ? loadTlsOptions(config.tls) : undefined;
240
243
  const server = Bun.listen({
241
244
  hostname: config.hostname ?? '0.0.0.0',
242
245
  port: config.port ?? 6789,
246
+ ...(tlsOptions && { tls: tlsOptions }),
243
247
  socket: socketHandlers,
244
248
  });
245
249
  return {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Server TLS options
3
+ * Shared by the TCP (Bun.listen) and HTTP (Bun.serve) servers.
4
+ */
5
+ import type { BunFile } from 'bun';
6
+ /** TLS configuration for a server (paths to PEM files) */
7
+ export interface TlsServerOptions {
8
+ /** Path to the PEM certificate (or full chain) file */
9
+ certFile: string;
10
+ /** Path to the PEM private key file */
11
+ keyFile: string;
12
+ }
13
+ /**
14
+ * Validate cert/key paths and build the `tls` option object for
15
+ * Bun.listen/Bun.serve. Fails fast with a descriptive error so a typo in a
16
+ * path surfaces at startup instead of as an opaque handshake failure.
17
+ */
18
+ export declare function loadTlsOptions(tls: TlsServerOptions): {
19
+ cert: BunFile;
20
+ key: BunFile;
21
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Server TLS options
3
+ * Shared by the TCP (Bun.listen) and HTTP (Bun.serve) servers.
4
+ */
5
+ import { existsSync } from 'node:fs';
6
+ /**
7
+ * Validate cert/key paths and build the `tls` option object for
8
+ * Bun.listen/Bun.serve. Fails fast with a descriptive error so a typo in a
9
+ * path surfaces at startup instead of as an opaque handshake failure.
10
+ */
11
+ export function loadTlsOptions(tls) {
12
+ if (!existsSync(tls.certFile)) {
13
+ throw new Error(`TLS cert file not found: ${tls.certFile}`);
14
+ }
15
+ if (!existsSync(tls.keyFile)) {
16
+ throw new Error(`TLS key file not found: ${tls.keyFile}`);
17
+ }
18
+ return { cert: Bun.file(tls.certFile), key: Bun.file(tls.keyFile) };
19
+ }
package/dist/main.js CHANGED
@@ -48,7 +48,7 @@ import { VERSION } from './shared/version';
48
48
  import { S3BackupManager } from './infrastructure/backup';
49
49
  import { CloudAgent } from './infrastructure/cloud';
50
50
  import { SHARD_COUNT } from './shared/hash';
51
- import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveBackupConfig, } from './config';
51
+ import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from './config';
52
52
  export { defineConfig } from './config';
53
53
  /** Print startup banner */
54
54
  function printBanner(config, cloudUrl) {
@@ -82,6 +82,7 @@ ${dim}────────────────────────
82
82
  ${green}●${reset} HTTP ${httpDisplay}
83
83
  ${yellow}●${reset} Socket ${socketDisplay}
84
84
  ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
85
+ ${yellow}●${reset} TLS ${config.tlsCertFile ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
85
86
  ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
86
87
  ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
87
88
  ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
@@ -108,6 +109,15 @@ async function startServer() {
108
109
  }
109
110
  // Resolve cloud config
110
111
  const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
112
+ // Resolve TLS config — fail fast on partial cert/key before binding anything
113
+ let tlsConfig;
114
+ try {
115
+ tlsConfig = resolveTlsServerOptions(config);
116
+ }
117
+ catch (err) {
118
+ serverLog.error(err instanceof Error ? err.message : String(err));
119
+ process.exit(1);
120
+ }
111
121
  printBanner(config, cloudConfig?.url);
112
122
  // Create queue manager
113
123
  const queueManager = new QueueManager({
@@ -118,6 +128,7 @@ async function startServer() {
118
128
  port: config.tcpPort,
119
129
  hostname: config.hostname,
120
130
  authTokens: config.authTokens,
131
+ ...(tlsConfig && { tls: tlsConfig }),
121
132
  });
122
133
  // Start HTTP server
123
134
  const httpServer = createHttpServer(queueManager, {
@@ -126,6 +137,7 @@ async function startServer() {
126
137
  authTokens: config.authTokens,
127
138
  corsOrigins: config.corsOrigins,
128
139
  requireAuthForMetrics: config.requireAuthForMetrics,
140
+ ...(tlsConfig && { tls: tlsConfig }),
129
141
  });
130
142
  // Initialize S3 backup manager
131
143
  let backupManager = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.7",
3
+ "version": "2.8.8",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",