bunqueue 2.8.9 → 2.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,5 +16,14 @@ export interface ClientOptions {
16
16
  /** Output as JSON */
17
17
  json: boolean;
18
18
  }
19
+ /**
20
+ * Client-side timeout for a command: base 30s; ONLY the long-poll commands
21
+ * (PULL/WaitJob, where `timeout` means "how long the server may hold the
22
+ * request") get the server-side timeout plus a 10s network buffer so the
23
+ * client never gives up first. On every other command a `timeout` field has
24
+ * a different meaning (e.g. PUSH: job execution timeout) and must not
25
+ * stretch the client wait.
26
+ */
27
+ export declare function clientTimeoutFor(cmd: Record<string, unknown>): number;
19
28
  /** Execute a CLI command against the server */
20
29
  export declare function executeCommand(command: string, args: string[], options: ClientOptions): Promise<void>;
@@ -7,14 +7,27 @@ import { pack, unpack } from 'msgpackr';
7
7
  import { FrameParser, FrameSizeError } from '../infrastructure/server/protocol';
8
8
  import { CommandError } from './commands/types';
9
9
  import { buildClientTls } from '../client/tcp/connection';
10
+ /**
11
+ * Client-side timeout for a command: base 30s; ONLY the long-poll commands
12
+ * (PULL/WaitJob, where `timeout` means "how long the server may hold the
13
+ * request") get the server-side timeout plus a 10s network buffer so the
14
+ * client never gives up first. On every other command a `timeout` field has
15
+ * a different meaning (e.g. PUSH: job execution timeout) and must not
16
+ * stretch the client wait.
17
+ */
18
+ export function clientTimeoutFor(cmd) {
19
+ const isLongPoll = cmd.cmd === 'PULL' || cmd.cmd === 'WaitJob';
20
+ const cmdTimeout = isLongPoll && typeof cmd.timeout === 'number' ? cmd.timeout : 0;
21
+ return Math.max(30000, cmdTimeout + 10000);
22
+ }
10
23
  /** Send a command and wait for response */
11
- async function sendCommand(socket, command) {
24
+ async function sendCommand(socket, command, timeoutMs = 30000) {
12
25
  return new Promise((resolve, reject) => {
13
26
  const timeoutId = setTimeout(() => {
14
27
  socket.data.resolve = null;
15
28
  socket.data.reject = null;
16
29
  reject(new Error('Command timeout'));
17
- }, 30000);
30
+ }, timeoutMs);
18
31
  socket.data.resolve = (value) => {
19
32
  clearTimeout(timeoutId);
20
33
  resolve(value);
@@ -124,6 +137,13 @@ async function connect(options) {
124
137
  export async function executeCommand(command, args, options) {
125
138
  let connection = null;
126
139
  try {
140
+ // Build the command first: unknown commands and parse errors must be
141
+ // reported without requiring a reachable server.
142
+ const cmd = await buildCommand(command, args);
143
+ if (!cmd) {
144
+ console.error(formatError(`Unknown command: ${command}`, options.json));
145
+ process.exit(1);
146
+ }
127
147
  connection = await connect(options);
128
148
  // Authenticate if token provided
129
149
  if (options.token) {
@@ -138,14 +158,8 @@ export async function executeCommand(command, args, options) {
138
158
  process.exit(1);
139
159
  }
140
160
  }
141
- // Build the command
142
- const cmd = await buildCommand(command, args);
143
- if (!cmd) {
144
- console.error(formatError(`Unknown command: ${command}`, options.json));
145
- process.exit(1);
146
- }
147
161
  // Execute command
148
- const response = await sendCommand(connection.socket, cmd);
162
+ const response = await sendCommand(connection.socket, cmd, clientTimeoutFor(cmd));
149
163
  // Extract subcommand (first positional arg) so the formatter can pick the
150
164
  // right verb for batch-id responses (queue drain → "Drained", dlq retry
151
165
  // → "Retried", etc.). Falls back to undefined when not applicable.
@@ -154,6 +168,18 @@ export async function executeCommand(command, args, options) {
154
168
  console.error(formatOutput(response, command, options.json, subcommand));
155
169
  process.exit(1);
156
170
  }
171
+ // `job wait` that ran out of time replies ok:true + completed:false —
172
+ // for scripts that is a failure, not an "OK": exit 1 with a clear message.
173
+ if (cmd.cmd === 'WaitJob' && response.completed === false) {
174
+ console.error(formatError('Job not completed within timeout', options.json));
175
+ process.exit(1);
176
+ }
177
+ // GetState replies ok:true + state:'unknown' for a missing job — align
178
+ // with `job get` (Job not found, exit 1) instead of a false success.
179
+ if (cmd.cmd === 'GetState' && response.state === 'unknown') {
180
+ console.error(formatError('Job not found', options.json));
181
+ process.exit(1);
182
+ }
157
183
  console.log(formatOutput(response, command, options.json, subcommand));
158
184
  // Worker registrations are tied to the TCP connection — the server
159
185
  // auto-unregisters when the client disconnects (see tcp.ts close handler).
@@ -12,12 +12,12 @@ import { CommandError, requireArg } from './types';
12
12
  export async function executeBackupCommand(args) {
13
13
  const subcommand = args[0];
14
14
  const subArgs = args.slice(1);
15
- // Get database path from env
16
- const dataPath = Bun.env.DATA_PATH ?? Bun.env.SQLITE_PATH;
15
+ // Get database path from env — canonical priority (see config/resolve.ts)
16
+ const dataPath = Bun.env.BUNQUEUE_DATA_PATH ?? Bun.env.BQ_DATA_PATH ?? Bun.env.DATA_PATH ?? Bun.env.SQLITE_PATH;
17
17
  if (!dataPath) {
18
18
  return {
19
19
  success: false,
20
- message: 'DATA_PATH not set. Backup requires persistent storage.',
20
+ message: 'BUNQUEUE_DATA_PATH not set. Backup requires persistent storage.',
21
21
  };
22
22
  }
23
23
  // Create backup manager
@@ -56,14 +56,27 @@ function buildCronAdd(args) {
56
56
  if (values.schedule)
57
57
  cmd.schedule = values.schedule;
58
58
  const every = parseNumberArg(values.every, 'every');
59
- if (every !== undefined)
59
+ if (every !== undefined) {
60
+ // <= 0 would compute a nextRun that is always in the past: the scheduler
61
+ // would fire the cron on every tick, indefinitely.
62
+ if (every <= 0) {
63
+ throw new CommandError(`Invalid every: ${every}. Must be a positive interval in ms`);
64
+ }
60
65
  cmd.repeatEvery = every;
66
+ }
61
67
  const priority = parseNumberArg(values.priority, 'priority');
62
68
  if (priority !== undefined)
63
69
  cmd.priority = priority;
64
70
  const maxLimit = parseNumberArg(values['max-limit'], 'max-limit');
65
- if (maxLimit !== undefined)
66
- cmd.maxLimit = maxLimit;
71
+ if (maxLimit !== undefined) {
72
+ if (maxLimit < 0) {
73
+ throw new CommandError(`Invalid max-limit: ${maxLimit}. Must be >= 0 (0 = unlimited)`);
74
+ }
75
+ // 0 = unlimited: omit the field so the server stores null (no limit).
76
+ // Sending 0 would make isAtLimit treat the cron as already exhausted.
77
+ if (maxLimit > 0)
78
+ cmd.maxLimit = maxLimit;
79
+ }
67
80
  if (values.timezone)
68
81
  cmd.timezone = values.timezone;
69
82
  return cmd;
@@ -151,7 +151,11 @@ function buildWaitJob(args) {
151
151
  id,
152
152
  };
153
153
  const timeout = parseNumberArg(values.timeout, 'timeout');
154
- if (timeout !== undefined)
154
+ if (timeout !== undefined) {
155
+ if (timeout < 0) {
156
+ throw new CommandError(`Invalid timeout: ${timeout}. Must be >= 0 ms`);
157
+ }
155
158
  cmd.timeout = timeout;
159
+ }
156
160
  return cmd;
157
161
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Server Command Handler
3
- * Starts the bunQ server
3
+ * Parses `bunqueue start` flags and boots the SAME full server as the bare
4
+ * `bunqueue` entry point (shared bootstrap — S3 backup, cloud agent, stats,
5
+ * crash handlers and graceful shutdown included).
4
6
  */
5
7
  /** Run the server */
6
8
  export declare function runServer(args: string[], showHelp: boolean): Promise<void>;
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Server Command Handler
3
- * Starts the bunQ server
3
+ * Parses `bunqueue start` flags and boots the SAME full server as the bare
4
+ * `bunqueue` entry point (shared bootstrap — S3 backup, cloud agent, stats,
5
+ * crash handlers and graceful shutdown included).
4
6
  */
5
7
  import { parseArgs } from 'node:util';
6
8
  import { printServerHelp } from '../help';
7
- import { VERSION } from '../../shared/version';
8
- import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveTlsServerOptions, } from '../../config';
9
+ import { loadConfigFile, resolveServerConfig } from '../../config';
10
+ import { bootServer } from '../../infrastructure/server/bootstrap';
9
11
  /** Validate port number */
10
12
  function validatePort(value, name, defaultPort) {
11
13
  const port = parseInt(value, 10);
@@ -103,106 +105,6 @@ export async function runServer(args, showHelp) {
103
105
  const fileConfig = await loadConfigFile(flags.configPath);
104
106
  const mergedConfig = applyCliFlags(fileConfig, flags);
105
107
  const config = resolveServerConfig(mergedConfig);
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
- }
116
- // Import and start the server components
117
- const { QueueManager } = await import('../../application/queueManager');
118
- const { createTcpServer } = await import('../../infrastructure/server/tcp');
119
- const { createHttpServer } = await import('../../infrastructure/server/http');
120
- const { serverLog } = await import('../../shared/logger');
121
- // Initialize
122
- const qm = new QueueManager({
123
- dataPath: config.dataPath,
124
- });
125
- const authTokens = config.authTokens.length > 0 ? config.authTokens : undefined;
126
- // Start TCP and HTTP servers
127
- let tcpServer;
128
- let httpServer;
129
- try {
130
- tcpServer = createTcpServer(qm, {
131
- port: config.tcpPort,
132
- hostname: config.hostname,
133
- authTokens,
134
- ...(tlsConfig && { tls: tlsConfig }),
135
- });
136
- httpServer = createHttpServer(qm, {
137
- port: config.httpPort,
138
- hostname: config.hostname,
139
- authTokens,
140
- ...(tlsConfig && { tls: tlsConfig }),
141
- });
142
- }
143
- catch (err) {
144
- const msg = err instanceof Error ? err.message : 'Unknown error';
145
- console.error(`Failed to start server: ${msg}`);
146
- qm.shutdown();
147
- process.exit(1);
148
- }
149
- serverLog.info('bunqueue server started', {
150
- tcpPort: config.tcpPort,
151
- httpPort: config.httpPort,
152
- host: config.hostname,
153
- dataPath: config.dataPath ?? 'in-memory',
154
- auth: authTokens ? 'enabled' : 'disabled',
155
- });
156
- // Initialize bunqueue Cloud agent (remote dashboard telemetry)
157
- const { CloudAgent } = await import('../../infrastructure/cloud/cloudAgent');
158
- const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(qm, cloudConfig) : null;
159
- if (cloudAgent) {
160
- cloudAgent.setServerHandles({
161
- getConnectionCount: () => tcpServer.getConnectionCount(),
162
- getWsClientCount: () => httpServer.getWsClientCount(),
163
- getSseClientCount: () => httpServer.getSseClientCount(),
164
- });
165
- }
166
- const dim = '\x1b[2m';
167
- const reset = '\x1b[0m';
168
- const bold = '\x1b[1m';
169
- const magenta = '\x1b[35m';
170
- const green = '\x1b[32m';
171
- const yellow = '\x1b[33m';
172
- // Format endpoint display
173
- const tcpDisplay = `${bold}${config.hostname}:${config.tcpPort}${reset}`;
174
- const httpDisplay = `${bold}${config.hostname}:${config.httpPort}${reset}`;
175
- console.log(`
176
- ${magenta} (\\(\\ ${reset}
177
- ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
178
- ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
179
-
180
- ${dim}─────────────────────────────────────────────────${reset}
181
-
182
- ${green}●${reset} TCP ${tcpDisplay}
183
- ${green}●${reset} HTTP ${httpDisplay}
184
- ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
185
- ${yellow}●${reset} TLS ${tlsConfig ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
186
- ${yellow}●${reset} Auth ${authTokens ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
187
- ${yellow}●${reset} Cloud ${cloudConfig ? `${green}enabled${reset} ${dim}→ ${cloudConfig.url}${reset}` : `${dim}disabled${reset}`}
188
-
189
- ${dim}─────────────────────────────────────────────────${reset}
190
-
191
- ${dim}Press ${bold}Ctrl+C${reset}${dim} to stop${reset}
192
- `);
193
- // Handle shutdown
194
- const shutdown = () => {
195
- serverLog.info('Shutting down...');
196
- const doStop = async () => {
197
- if (cloudAgent)
198
- await cloudAgent.stop();
199
- tcpServer.stop();
200
- httpServer.stop();
201
- qm.shutdown();
202
- process.exit(0);
203
- };
204
- void doStop();
205
- };
206
- process.on('SIGINT', shutdown);
207
- process.on('SIGTERM', shutdown);
108
+ // Same full server as the bare `bunqueue` entry (shared bootstrap)
109
+ bootServer(mergedConfig, config);
208
110
  }
@@ -4,15 +4,9 @@
4
4
  */
5
5
  import { parseArgs } from 'node:util';
6
6
  import { CommandError, requireArg } from './types';
7
- /** Valid webhook events */
8
- const VALID_EVENTS = [
9
- 'job.completed',
10
- 'job.failed',
11
- 'job.progress',
12
- 'job.active',
13
- 'job.waiting',
14
- 'job.delayed',
15
- ];
7
+ import { WEBHOOK_EVENTS } from '../../domain/types/webhook';
8
+ /** Valid webhook events — canonical list shared with server/MCP validation */
9
+ const VALID_EVENTS = WEBHOOK_EVENTS;
16
10
  /** Build a webhook subcommand */
17
11
  export function buildWebhookCommand(args) {
18
12
  const subcommand = args[0];
package/dist/cli/help.js CHANGED
@@ -98,6 +98,8 @@ 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
+ Note: after \`pull\` / \`job wait\`, -t is their --timeout;
102
+ use --token there
101
103
  --tls Connect with TLS (verify with system CAs)
102
104
  --tls-ca <file> Trust a custom CA cert (implies --tls)
103
105
  --tls-no-verify TLS without cert verification (self-signed, dev only)
@@ -126,7 +128,7 @@ Options:
126
128
  --tcp-port <port> TCP server port (default: 6789, env: TCP_PORT)
127
129
  --http-port <port> HTTP server port (default: 6790, env: HTTP_PORT)
128
130
  --host <host> Bind address (default: 0.0.0.0, env: HOST)
129
- --data-path <path> SQLite database path (env: DATA_PATH)
131
+ --data-path <path> SQLite database path (env: BUNQUEUE_DATA_PATH, DATA_PATH)
130
132
  --auth-tokens <list> Comma-separated auth tokens (env: AUTH_TOKENS)
131
133
  --tls-cert <file> PEM certificate for native TLS (env: TLS_CERT_FILE)
132
134
  --tls-key <file> PEM private key for native TLS (env: TLS_KEY_FILE)
package/dist/cli/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { runServer } from './commands/server';
7
7
  import { executeCommand } from './client';
8
- import { printHelp, printVersion } from './help';
8
+ import { printHelp, printVersion, printPushHelp, printCronAddHelp } from './help';
9
9
  import { isBackupCommand, executeBackupCommand } from './commands/backup';
10
10
  import { runDoctor } from './commands/doctor';
11
11
  import { VERSION } from '../shared/version';
@@ -82,47 +82,110 @@ function buildTlsOption(state) {
82
82
  }
83
83
  return state.enabled ? true : undefined;
84
84
  }
85
+ /**
86
+ * Exactly two subcommands define their own short `-t` (--timeout):
87
+ * `pull` and `job wait`. ONLY there `-t` is passed through instead of being
88
+ * consumed as the global token flag — every other command keeps `-t TOKEN`
89
+ * working anywhere on the line. The long form `--token` is global everywhere.
90
+ */
91
+ function commandOwnsShortT(commandArgs) {
92
+ return commandArgs[0] === 'pull' || (commandArgs[0] === 'job' && commandArgs[1] === 'wait');
93
+ }
94
+ /**
95
+ * Handle `--token` / global `-t` and return the index to continue scanning
96
+ * from. When the command already parsed owns short `-t` (pull / job wait),
97
+ * the flag is passed through to the subcommand instead of being consumed.
98
+ */
99
+ function applyTokenFlag(arg, allArgs, i, state, commandArgs) {
100
+ if (arg === '-t' && commandOwnsShortT(commandArgs)) {
101
+ commandArgs.push(arg); // pull / job wait own -t (timeout): pass through
102
+ return i;
103
+ }
104
+ const nextArg = allArgs[i + 1];
105
+ // A following flag is not a token: don't swallow it (--token --json ...)
106
+ if (nextArg === undefined || nextArg.startsWith('-')) {
107
+ console.warn('Warning: --token requires a value. Token not set.');
108
+ return i;
109
+ }
110
+ state.token = nextArg;
111
+ return i + 1;
112
+ }
113
+ /** Handle `--host`/`-H <value>`; refuses to swallow a following flag. */
114
+ function applyHostFlag(allArgs, i, state) {
115
+ const nextArg = allArgs[i + 1];
116
+ if (nextArg === undefined || nextArg.startsWith('-')) {
117
+ console.warn('Warning: --host requires a value. Using localhost.');
118
+ return i;
119
+ }
120
+ state.host = nextArg;
121
+ state.hostExplicit = true;
122
+ return i + 1;
123
+ }
124
+ /** Handle `--port`/`-p <value>`; refuses to swallow a following flag. */
125
+ function applyPortFlag(allArgs, i, state) {
126
+ const nextArg = allArgs[i + 1];
127
+ if (nextArg === undefined || nextArg.startsWith('-')) {
128
+ console.warn('Warning: --port requires a value. Using default port 6789.');
129
+ return i;
130
+ }
131
+ const parsed = parseInt(nextArg, 10);
132
+ if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
133
+ console.warn(`Warning: Invalid port "${nextArg}". Using default port 6789.`);
134
+ state.port = 6789;
135
+ }
136
+ else {
137
+ state.port = parsed;
138
+ state.portExplicit = true;
139
+ }
140
+ return i + 1;
141
+ }
142
+ /**
143
+ * Attached-value short flags (-p10, -Hfoo) shadowing a global flag letter are
144
+ * silently mis-parsed downstream (strict:false expands them to garbage
145
+ * booleans — e.g. push -p10 dropped the priority and pushed anyway). Warn so
146
+ * the user switches to the separated/long form. Exception: -t<value> after
147
+ * pull/job wait, where parseArgs handles the attached form correctly.
148
+ */
149
+ function warnAmbiguousAttachedShort(arg, commandArgs) {
150
+ if (!/^-[Hpthv][^-\s]/.test(arg))
151
+ return;
152
+ if (arg.startsWith('-t') && commandOwnsShortT(commandArgs))
153
+ return;
154
+ console.warn(`Warning: "${arg}" looks like a short flag with an attached value; ` +
155
+ `use the separated form ("${arg.slice(0, 2)} ${arg.slice(2)}") or the long form.`);
156
+ }
85
157
  /** Parse global options from process.argv */
86
158
  export function parseGlobalOptions() {
87
159
  const allArgs = process.argv.slice(2);
88
160
  // Extract global options manually to preserve subcommand flags
89
- let host = 'localhost';
90
- let port = 6789;
91
- let token;
161
+ const hp = {
162
+ host: 'localhost',
163
+ port: 6789,
164
+ hostExplicit: false,
165
+ portExplicit: false,
166
+ };
167
+ const tokenState = {};
92
168
  let json = false;
93
169
  let help = false;
94
170
  let version = false;
95
- let hostExplicit = false;
96
- let portExplicit = false;
97
171
  const tlsState = { enabled: false, noVerify: false };
98
172
  const commandArgs = [];
99
173
  let i = 0;
100
174
  while (i < allArgs.length) {
101
175
  const arg = allArgs[i];
176
+ if (arg === '--') {
177
+ // Separator: everything after -- is opaque to the global parser
178
+ commandArgs.push(...allArgs.slice(i + 1));
179
+ break;
180
+ }
102
181
  if (arg === '--host' || arg === '-H') {
103
- host = allArgs[++i] ?? 'localhost';
104
- hostExplicit = true;
182
+ i = applyHostFlag(allArgs, i, hp);
105
183
  }
106
184
  else if (arg === '--port' || arg === '-p') {
107
- const raw = allArgs[++i] ?? '6789';
108
- const parsed = parseInt(raw, 10);
109
- if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
110
- console.warn(`Warning: Invalid port "${raw}". Using default port 6789.`);
111
- port = 6789;
112
- }
113
- else {
114
- port = parsed;
115
- portExplicit = true;
116
- }
185
+ i = applyPortFlag(allArgs, i, hp);
117
186
  }
118
187
  else if (arg === '--token' || arg === '-t') {
119
- const nextArg = allArgs[i + 1];
120
- if (nextArg === undefined) {
121
- console.warn('Warning: --token requires a value. Token not set.');
122
- }
123
- else {
124
- token = allArgs[++i];
125
- }
188
+ i = applyTokenFlag(arg, allArgs, i, tokenState, commandArgs);
126
189
  }
127
190
  else if (arg.startsWith('--tls')) {
128
191
  i = applyTlsFlag(arg, allArgs, i, tlsState, commandArgs);
@@ -130,26 +193,28 @@ export function parseGlobalOptions() {
130
193
  else if (arg === '--json') {
131
194
  json = true;
132
195
  }
133
- else if (arg === '--help' || arg === '-h') {
196
+ else if (arg === '--help' || (arg === '-h' && commandArgs.length === 0)) {
197
+ // Short -h is global help ONLY before the command: after it, a typo of
198
+ // -H (host) must not suppress execution with a false-success exit 0.
134
199
  help = true;
135
200
  }
136
- else if (arg === '--version' || arg === '-v') {
201
+ else if (arg === '--version' || (arg === '-v' && commandArgs.length === 0)) {
137
202
  version = true;
138
203
  }
139
204
  else if (arg.startsWith('--host=')) {
140
- host = arg.slice(7);
141
- hostExplicit = true;
205
+ hp.host = arg.slice(7);
206
+ hp.hostExplicit = true;
142
207
  }
143
208
  else if (arg.startsWith('--port=')) {
144
209
  const raw = arg.slice(7);
145
210
  const parsed = parseInt(raw, 10);
146
211
  if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
147
212
  console.warn(`Warning: Invalid port "${raw}". Using default port 6789.`);
148
- port = 6789;
213
+ hp.port = 6789;
149
214
  }
150
215
  else {
151
- port = parsed;
152
- portExplicit = true;
216
+ hp.port = parsed;
217
+ hp.portExplicit = true;
153
218
  }
154
219
  }
155
220
  else if (arg.startsWith('--token=')) {
@@ -158,10 +223,11 @@ export function parseGlobalOptions() {
158
223
  console.warn('Warning: --token= requires a value. Token not set.');
159
224
  }
160
225
  else {
161
- token = val;
226
+ tokenState.token = val;
162
227
  }
163
228
  }
164
229
  else {
230
+ warnAmbiguousAttachedShort(arg, commandArgs);
165
231
  // Not a global option, pass to command
166
232
  commandArgs.push(arg);
167
233
  }
@@ -173,20 +239,18 @@ export function parseGlobalOptions() {
173
239
  // so they reach parseServerArgs in runServer().
174
240
  // Global -p/--port maps to --tcp-port for the server command.
175
241
  if (isServerMode) {
176
- if (hostExplicit) {
177
- commandArgs.push('--host', host);
242
+ if (hp.hostExplicit) {
243
+ commandArgs.push('--host', hp.host);
178
244
  }
179
- if (portExplicit) {
180
- commandArgs.push('--tcp-port', String(port));
245
+ if (hp.portExplicit) {
246
+ commandArgs.push('--tcp-port', String(hp.port));
181
247
  }
182
248
  }
183
249
  // Fall back to environment variables for token if not set via CLI flag
184
250
  // Priority: --token flag > BQ_TOKEN > BUNQUEUE_TOKEN
185
- token = resolveToken(token);
186
- if (!portExplicit)
187
- port = resolveEnvPort(port);
188
- if (!hostExplicit)
189
- host = resolveEnvHost(host);
251
+ const token = resolveToken(tokenState.token);
252
+ const port = hp.portExplicit ? hp.port : resolveEnvPort(hp.port);
253
+ const host = hp.hostExplicit ? hp.host : resolveEnvHost(hp.host);
190
254
  return {
191
255
  options: { host, port, token, tls: buildTlsOption(tlsState), json, help, version },
192
256
  commandArgs,
@@ -236,8 +300,12 @@ export async function main() {
236
300
  }
237
301
  // Help for specific command
238
302
  if (options.help) {
239
- // Could add command-specific help here
240
- printHelp();
303
+ if (command === 'push')
304
+ printPushHelp();
305
+ else if (command === 'cron')
306
+ printCronAddHelp();
307
+ else
308
+ printHelp();
241
309
  process.exit(0);
242
310
  }
243
311
  // Version command - shows client version + server version if reachable
@@ -143,6 +143,15 @@ function formatStats(stats) {
143
143
  lines.push(` Total Completed: ${str(stats.totalCompleted)}`);
144
144
  lines.push(` Total Failed: ${str(stats.totalFailed)}`);
145
145
  }
146
+ if (stats.uptime !== undefined) {
147
+ lines.push('', ` ${color('Uptime:', colors.cyan)} ${str(stats.uptime)}s`);
148
+ }
149
+ if (stats.pushPerSec !== undefined) {
150
+ lines.push(` Push/sec: ${str(stats.pushPerSec)}`);
151
+ }
152
+ if (stats.pullPerSec !== undefined) {
153
+ lines.push(` Pull/sec: ${str(stats.pullPerSec)}`);
154
+ }
146
155
  return lines.join('\n');
147
156
  }
148
157
  /** Format counts object */
@@ -167,7 +176,17 @@ function formatCronJobs(jobs) {
167
176
  const schedule = job.schedule !== null && job.schedule !== undefined
168
177
  ? str(job.schedule)
169
178
  : `every ${str(job.repeatEvery)}ms`;
170
- return ` ${color(str(job.name), colors.bold)}\n Queue: ${str(job.queue)}\n Schedule: ${schedule}\n Executions: ${str(job.executions)}`;
179
+ let out = ` ${color(str(job.name), colors.bold)}\n Queue: ${str(job.queue)}\n Schedule: ${schedule}\n Executions: ${str(job.executions)}`;
180
+ if (typeof job.nextRun === 'number') {
181
+ out += `\n Next run: ${new Date(job.nextRun).toISOString()}`;
182
+ }
183
+ if (job.maxLimit !== null && job.maxLimit !== undefined) {
184
+ out += `\n Max: ${str(job.maxLimit)}`;
185
+ }
186
+ if (job.timezone !== null && job.timezone !== undefined) {
187
+ out += `\n Timezone: ${str(job.timezone)}`;
188
+ }
189
+ return out;
171
190
  });
172
191
  return lines.join('\n\n');
173
192
  }
@@ -177,7 +196,25 @@ function formatWorkers(workers) {
177
196
  return color('No workers registered', colors.yellow);
178
197
  }
179
198
  return workers
180
- .map((w) => ` ${color(str(w.id), colors.bold)}: ${str(w.name)} (${Array.isArray(w.queues) ? w.queues.join(', ') : 'none'})`)
199
+ .map((w) => {
200
+ const queues = Array.isArray(w.queues) ? w.queues.join(', ') : 'none';
201
+ // status matters operationally: a stale worker must be visible
202
+ const status = w.status === 'stale'
203
+ ? color('[stale]', colors.red)
204
+ : w.status !== undefined
205
+ ? color(`[${str(w.status)}]`, colors.green)
206
+ : '';
207
+ const extra = [];
208
+ if (w.concurrency !== undefined)
209
+ extra.push(`concurrency=${str(w.concurrency)}`);
210
+ if (w.activeJobs !== undefined)
211
+ extra.push(`active=${str(w.activeJobs)}`);
212
+ if (w.processedJobs !== undefined) {
213
+ extra.push(`processed=${str(w.processedJobs)}/failed=${str(w.failedJobs, '0')}`);
214
+ }
215
+ const extraStr = extra.length > 0 ? `\n ${extra.join(' ')}` : '';
216
+ return ` ${color(str(w.id), colors.bold)}: ${str(w.name)} ${status} (${queues})${extraStr}`;
217
+ })
181
218
  .join('\n');
182
219
  }
183
220
  /** Format webhooks list */
@@ -186,7 +223,15 @@ function formatWebhooks(webhooks) {
186
223
  return color('No webhooks registered', colors.yellow);
187
224
  }
188
225
  return webhooks
189
- .map((w) => ` ${color(str(w.id), colors.bold)}: ${str(w.url)}\n Events: ${w.events.join(', ')}`)
226
+ .map((w) => {
227
+ const events = Array.isArray(w.events) ? w.events.join(', ') : 'none';
228
+ const enabled = w.enabled === false ? ` ${color('[disabled]', colors.yellow)}` : '';
229
+ const queue = w.queue !== null && w.queue !== undefined ? `\n Queue: ${str(w.queue)}` : '';
230
+ const counters = w.successCount !== undefined || w.failureCount !== undefined
231
+ ? `\n Delivered: ${str(w.successCount, '0')} ok / ${str(w.failureCount, '0')} failed`
232
+ : '';
233
+ return ` ${color(str(w.id), colors.bold)}: ${str(w.url)}${enabled}\n Events: ${events}${queue}${counters}`;
234
+ })
190
235
  .join('\n\n');
191
236
  }
192
237
  /** Format DLQ jobs */
@@ -300,6 +345,15 @@ function formatSuccess(response, command, subcommand) {
300
345
  // Worker registered
301
346
  if ('workerId' in r)
302
347
  return color(`Worker registered: ${str(r.workerId)}`, colors.green);
348
+ // Webhook added — show the id, it is needed for `webhook remove`
349
+ if ('webhookId' in r)
350
+ return color(`Webhook added: ${str(r.webhookId)}`, colors.green);
351
+ // Cron scheduled — surface nextRun
352
+ if ('cron' in r && r.cron !== null && typeof r.cron === 'object') {
353
+ const c = r.cron;
354
+ const next = typeof c.nextRun === 'number' ? ` (next run: ${new Date(c.nextRun).toISOString()})` : '';
355
+ return color(`Cron scheduled: ${str(c.name)}${next}`, colors.green);
356
+ }
303
357
  // State
304
358
  if ('state' in r)
305
359
  return `State: ${str(r.state)}`;
@@ -16,7 +16,9 @@ export function createCronJob(input, nextRun) {
16
16
  timezone: input.timezone ?? null,
17
17
  nextRun,
18
18
  executions: 0,
19
- maxLimit: input.maxLimit ?? null,
19
+ // 0/negative mean "no limit" on every surface (CLI/HTTP/TCP/MCP): storing
20
+ // 0 would make isAtLimit treat the cron as already exhausted (0 >= 0).
21
+ maxLimit: input.maxLimit !== undefined && input.maxLimit > 0 ? input.maxLimit : null,
20
22
  uniqueKey: input.uniqueKey ?? null,
21
23
  dedup: input.dedup ?? null,
22
24
  skipMissedOnRestart: input.skipMissedOnRestart ?? true,
@@ -3,8 +3,18 @@
3
3
  */
4
4
  /** Webhook ID type */
5
5
  export type WebhookId = string;
6
- /** Webhook event types */
7
- export type WebhookEvent = 'job.pushed' | 'job.started' | 'job.completed' | 'job.failed' | 'job.progress' | 'job.stalled';
6
+ /**
7
+ * Canonical list of webhook events the server actually triggers single
8
+ * source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
9
+ * completed/failed flow through eventsManager.mapEventToWebhook; job.progress
10
+ * is triggered directly by updateProgress (jobManagement).
11
+ */
12
+ export declare const WEBHOOK_EVENTS: readonly ["job.pushed", "job.started", "job.completed", "job.failed", "job.progress"];
13
+ /**
14
+ * Webhook event types. Includes 'job.stalled' for backward compatibility with
15
+ * stored webhooks, but it is never emitted and not accepted on new ones.
16
+ */
17
+ export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number] | 'job.stalled';
8
18
  /** Webhook configuration */
9
19
  export interface Webhook {
10
20
  id: WebhookId;
@@ -2,6 +2,19 @@
2
2
  * Webhook domain types
3
3
  */
4
4
  import { uuid } from '../../shared/hash';
5
+ /**
6
+ * Canonical list of webhook events the server actually triggers — single
7
+ * source of truth for CLI/TCP/HTTP/MCP validation. job.pushed/started/
8
+ * completed/failed flow through eventsManager.mapEventToWebhook; job.progress
9
+ * is triggered directly by updateProgress (jobManagement).
10
+ */
11
+ export const WEBHOOK_EVENTS = [
12
+ 'job.pushed',
13
+ 'job.started',
14
+ 'job.completed',
15
+ 'job.failed',
16
+ 'job.progress',
17
+ ];
5
18
  /** Create a new webhook */
6
19
  export function createWebhook(url, events, queue, secret) {
7
20
  return {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Server bootstrap — the ONE place that boots a full bunqueue server.
3
+ * Used by both entry points (`bunqueue` bare via main.ts and
4
+ * `bunqueue start` via the CLI) so they cannot drift: S3 backup, cloud
5
+ * agent, stats interval, crash handlers and graceful shutdown are always on.
6
+ */
7
+ import { type BunqueueConfig, type ResolvedConfig } from '../../config';
8
+ /** Boot the full server from resolved configuration. Runs until shutdown. */
9
+ export declare function bootServer(fileConfig: BunqueueConfig | null, config: ResolvedConfig): void;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Server bootstrap — the ONE place that boots a full bunqueue server.
3
+ * Used by both entry points (`bunqueue` bare via main.ts and
4
+ * `bunqueue start` via the CLI) so they cannot drift: S3 backup, cloud
5
+ * agent, stats interval, crash handlers and graceful shutdown are always on.
6
+ */
7
+ import { QueueManager } from '../../application/queueManager';
8
+ import { createTcpServer } from './tcp';
9
+ import { createHttpServer } from './http';
10
+ import { Logger, serverLog, statsLog } from '../../shared/logger';
11
+ import { stopRateLimiter } from './rateLimiter';
12
+ import { VERSION } from '../../shared/version';
13
+ import { S3BackupManager } from '../backup';
14
+ import { CloudAgent } from '../cloud';
15
+ import { SHARD_COUNT } from '../../shared/hash';
16
+ import { resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from '../../config';
17
+ /** Print startup banner */
18
+ function printBanner(config, cloudUrl) {
19
+ const dim = '\x1b[2m';
20
+ const reset = '\x1b[0m';
21
+ const bold = '\x1b[1m';
22
+ const magenta = '\x1b[35m';
23
+ const green = '\x1b[32m';
24
+ const yellow = '\x1b[33m';
25
+ // Format TCP endpoint display
26
+ const tcpDisplay = config.tcpSocketPath
27
+ ? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
28
+ : `${bold}${config.hostname}:${config.tcpPort}${reset}`;
29
+ // Format HTTP endpoint display
30
+ const httpDisplay = config.httpSocketPath
31
+ ? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
32
+ : `${bold}${config.hostname}:${config.httpPort}${reset}`;
33
+ // Socket mode display
34
+ const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
35
+ const socketDisplay = hasUnixSockets
36
+ ? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
37
+ : `${dim}disabled${reset}`;
38
+ console.log(`
39
+ ${magenta} (\\(\\ ${reset}
40
+ ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
41
+ ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
42
+
43
+ ${dim}─────────────────────────────────────────────────${reset}
44
+
45
+ ${green}●${reset} TCP ${tcpDisplay}
46
+ ${green}●${reset} HTTP ${httpDisplay}
47
+ ${yellow}●${reset} Socket ${socketDisplay}
48
+ ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
49
+ ${yellow}●${reset} TLS ${config.tlsCertFile ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
50
+ ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
51
+ ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
52
+ ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
53
+ ${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
54
+
55
+ ${dim}─────────────────────────────────────────────────${reset}
56
+
57
+ `);
58
+ }
59
+ /** Boot the full server from resolved configuration. Runs until shutdown. */
60
+ export function bootServer(fileConfig, config) {
61
+ // Apply logging config before anything else
62
+ const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
63
+ const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
64
+ if (logFormat === 'json')
65
+ Logger.enableJsonMode();
66
+ if (logLevel) {
67
+ const validLevels = ['debug', 'info', 'warn', 'error'];
68
+ if (validLevels.includes(logLevel))
69
+ Logger.setLevel(logLevel);
70
+ }
71
+ // Resolve cloud config
72
+ const cloudConfig = resolveCloudConfig(fileConfig, config.dataPath);
73
+ // Resolve TLS config — fail fast on partial cert/key before binding anything
74
+ let tlsConfig;
75
+ try {
76
+ tlsConfig = resolveTlsServerOptions(config);
77
+ }
78
+ catch (err) {
79
+ serverLog.error(err instanceof Error ? err.message : String(err));
80
+ process.exit(1);
81
+ }
82
+ printBanner(config, cloudConfig?.url);
83
+ // Create queue manager
84
+ const queueManager = new QueueManager({
85
+ dataPath: config.dataPath,
86
+ });
87
+ // Start TCP + HTTP servers; a bind failure must not leave a half-started process
88
+ let tcpServer;
89
+ let httpServer;
90
+ try {
91
+ tcpServer = createTcpServer(queueManager, {
92
+ port: config.tcpPort,
93
+ hostname: config.hostname,
94
+ authTokens: config.authTokens,
95
+ ...(tlsConfig && { tls: tlsConfig }),
96
+ });
97
+ httpServer = createHttpServer(queueManager, {
98
+ port: config.httpPort,
99
+ hostname: config.hostname,
100
+ socketPath: config.httpSocketPath,
101
+ authTokens: config.authTokens,
102
+ corsOrigins: config.corsOrigins,
103
+ requireAuthForMetrics: config.requireAuthForMetrics,
104
+ ...(tlsConfig && { tls: tlsConfig }),
105
+ });
106
+ }
107
+ catch (err) {
108
+ const msg = err instanceof Error ? err.message : 'Unknown error';
109
+ console.error(`Failed to start server: ${msg}`);
110
+ queueManager.shutdown();
111
+ process.exit(1);
112
+ }
113
+ // Initialize S3 backup manager
114
+ let backupManager = null;
115
+ if (config.dataPath) {
116
+ const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
117
+ backupManager = new S3BackupManager(backupConfig);
118
+ backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
119
+ backupManager.start();
120
+ }
121
+ // Initialize bunqueue Cloud agent (remote dashboard telemetry)
122
+ const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
123
+ if (cloudAgent) {
124
+ cloudAgent.setServerHandles({
125
+ getConnectionCount: () => tcpServer.getConnectionCount(),
126
+ getWsClientCount: () => httpServer.getWsClientCount(),
127
+ getSseClientCount: () => httpServer.getSseClientCount(),
128
+ getBackupStatus: () => backupManager?.getStatus() ?? null,
129
+ });
130
+ }
131
+ queueManager.emitDashboardEvent('server:started', {
132
+ tcpPort: config.tcpPort,
133
+ httpPort: config.httpPort,
134
+ shards: SHARD_COUNT,
135
+ });
136
+ // Graceful shutdown
137
+ let shuttingDown = false;
138
+ const shutdown = async (signal) => {
139
+ if (shuttingDown)
140
+ return;
141
+ shuttingDown = true;
142
+ serverLog.info(`Received ${signal}, shutting down...`);
143
+ // Stop stats interval immediately
144
+ clearInterval(statsInterval);
145
+ tcpServer.stop();
146
+ httpServer.stop();
147
+ const shutdownTimeout = config.shutdownTimeoutMs;
148
+ const start = Date.now();
149
+ while (Date.now() - start < shutdownTimeout) {
150
+ const stats = queueManager.getStats();
151
+ if (stats.active === 0)
152
+ break;
153
+ serverLog.info(`Waiting for ${stats.active} active jobs...`);
154
+ await Bun.sleep(1000);
155
+ }
156
+ // Stop backup manager
157
+ if (backupManager) {
158
+ backupManager.stop();
159
+ }
160
+ // Stop Cloud agent (sends final shutdown snapshot)
161
+ if (cloudAgent) {
162
+ await cloudAgent.stop();
163
+ }
164
+ queueManager.emitDashboardEvent('server:shutdown', { signal });
165
+ queueManager.shutdown();
166
+ stopRateLimiter();
167
+ serverLog.info('Shutdown complete');
168
+ process.exit(0);
169
+ };
170
+ process.on('SIGINT', () => void shutdown('SIGINT'));
171
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
172
+ process.on('uncaughtException', (err) => {
173
+ serverLog.error('Uncaught exception - initiating shutdown', {
174
+ error: err.message,
175
+ stack: err.stack,
176
+ });
177
+ void shutdown('uncaughtException');
178
+ });
179
+ process.on('unhandledRejection', (reason) => {
180
+ serverLog.error('Unhandled promise rejection - initiating shutdown', {
181
+ reason: reason instanceof Error ? reason.message : String(reason),
182
+ stack: reason instanceof Error ? reason.stack : undefined,
183
+ });
184
+ void shutdown('unhandledRejection');
185
+ });
186
+ // Print stats periodically
187
+ const statsInterval = setInterval(() => {
188
+ const stats = queueManager.getStats();
189
+ const memStats = queueManager.getMemoryStats();
190
+ const workerStats = queueManager.workerManager.getStats();
191
+ const mem = process.memoryUsage();
192
+ const now = new Date();
193
+ const timestamp = now.toLocaleTimeString('en-GB', {
194
+ hour: '2-digit',
195
+ minute: '2-digit',
196
+ second: '2-digit',
197
+ });
198
+ statsLog.info('Queue statistics', {
199
+ time: timestamp,
200
+ waiting: stats.waiting,
201
+ active: stats.active,
202
+ delayed: stats.delayed,
203
+ completed: stats.completed,
204
+ dlq: stats.dlq,
205
+ tcp: tcpServer.getConnectionCount(),
206
+ ws: httpServer.getWsClientCount(),
207
+ sse: httpServer.getSseClientCount(),
208
+ workers: `${workerStats.active}/${workerStats.total}`,
209
+ mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
210
+ rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
211
+ // Internal collection sizes (for memory debugging)
212
+ idx: memStats.jobIndex,
213
+ locks: memStats.jobLocks,
214
+ clients: memStats.clientJobsTotal,
215
+ });
216
+ }, config.statsIntervalMs);
217
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import * as resp from '../../../domain/types/response';
6
6
  import { jobId } from '../../../domain/types/job';
7
+ import { validateNumericField } from '../protocol';
7
8
  /**
8
9
  * Coerce a value to a finite number, or return undefined if it can't be.
9
10
  * Guards config endpoints against non-numeric input (e.g. `"abc"`) that would
@@ -108,6 +109,13 @@ export async function handleDiscard(cmd, ctx, reqId) {
108
109
  /** Handle WaitJob command - wait for job completion (event-driven, no polling) */
109
110
  export async function handleWaitJob(cmd, ctx, reqId) {
110
111
  const jid = jobId(cmd.id);
112
+ // Bounded like PULL (which caps at 60s); waiting for completion is
113
+ // legitimately longer, so the cap here is 10 min. validateNumericField also
114
+ // rejects NaN/Infinity from hostile clients (a hand-rolled <0/>max check
115
+ // would let NaN through and resolve instantly as a false "not completed").
116
+ const timeoutError = validateNumericField(cmd.timeout, 'timeout', { min: 0, max: 600000 });
117
+ if (timeoutError)
118
+ return resp.error(timeoutError, reqId);
111
119
  const timeout = cmd.timeout ?? 30000;
112
120
  // First check if job exists and is already completed
113
121
  const job = await ctx.queueManager.getJob(jid);
@@ -6,6 +6,7 @@ import { jobId } from '../../../domain/types/job';
6
6
  import { VERSION } from '../../../shared/version';
7
7
  import * as resp from '../../../domain/types/response';
8
8
  import { validateWebhookUrl } from '../protocol';
9
+ import { WEBHOOK_EVENTS } from '../../../domain/types/webhook';
9
10
  // ============ Job Logs ============
10
11
  export function handleAddLog(cmd, ctx, reqId) {
11
12
  const jid = jobId(cmd.id);
@@ -140,6 +141,12 @@ export function handleAddWebhook(cmd, ctx, reqId) {
140
141
  const urlError = validateWebhookUrl(cmd.url);
141
142
  if (urlError)
142
143
  return resp.error(urlError, reqId);
144
+ // Reject events that are never triggered — a webhook subscribed to a dead
145
+ // event would be created "ok" and then never fire (silent failure).
146
+ const invalidEvents = cmd.events.filter((e) => !WEBHOOK_EVENTS.includes(e));
147
+ if (invalidEvents.length > 0) {
148
+ return resp.error(`Invalid webhook event(s): ${invalidEvents.join(', ')}. Valid: ${WEBHOOK_EVENTS.join(', ')}`, reqId);
149
+ }
143
150
  const webhook = ctx.queueManager.webhookManager.add(cmd.url, cmd.events, cmd.queue, cmd.secret);
144
151
  ctx.queueManager.emitDashboardEvent('webhook:added', {
145
152
  id: webhook.id,
package/dist/main.js CHANGED
@@ -8,241 +8,29 @@
8
8
  // module; without this guard, the top-level dispatch would re-run the CLI/server
9
9
  // on every import and cause "Failed to listen at 0.0.0.0".
10
10
  if (import.meta.main) {
11
- const clientCommands = [
12
- 'push',
13
- 'pull',
14
- 'ack',
15
- 'fail',
16
- 'job',
17
- 'queue',
18
- 'dlq',
19
- 'cron',
20
- 'worker',
21
- 'webhook',
22
- 'rate-limit',
23
- 'concurrency',
24
- 'stats',
25
- 'metrics',
26
- 'health',
27
- 'backup',
28
- ];
29
11
  const firstArg = process.argv[2];
30
- const isClientCommand = firstArg && clientCommands.includes(firstArg);
31
- const isStartCommand = firstArg === 'start';
32
- const hasHelpOrVersion = process.argv.includes('--help') || process.argv.includes('--version');
33
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- process.argv[2] can be undefined at runtime
34
- const hasFlags = firstArg?.startsWith('-');
35
- if (isClientCommand || hasHelpOrVersion || isStartCommand || hasFlags) {
36
- void import('./cli/index').then(({ main }) => main());
12
+ // The server boots ONLY for a bare `bunqueue` invocation. `start` and
13
+ // flag-led argv go through the CLI, which detects server mode itself and
14
+ // boots the same full server (shared bootstrap). Every other first argument
15
+ // is a CLI command: known ones execute, unknown ones print
16
+ // "Unknown command: X" and exit 1 — a typo must never silently boot a server.
17
+ if (!firstArg) {
18
+ void startServer();
37
19
  }
38
20
  else {
39
- void startServer();
21
+ void import('./cli/index').then(({ main }) => main());
40
22
  }
41
23
  }
42
- import { QueueManager } from './application/queueManager';
43
- import { createTcpServer } from './infrastructure/server/tcp';
44
- import { createHttpServer } from './infrastructure/server/http';
45
- import { Logger, serverLog, statsLog } from './shared/logger';
46
- import { stopRateLimiter } from './infrastructure/server/rateLimiter';
47
- import { VERSION } from './shared/version';
48
- import { S3BackupManager } from './infrastructure/backup';
49
- import { CloudAgent } from './infrastructure/cloud';
50
- import { SHARD_COUNT } from './shared/hash';
51
- import { loadConfigFile, resolveServerConfig, resolveCloudConfig, resolveBackupConfig, resolveTlsServerOptions, } from './config';
24
+ import { Logger } from './shared/logger';
25
+ import { loadConfigFile, resolveServerConfig } from './config';
26
+ import { bootServer } from './infrastructure/server/bootstrap';
52
27
  export { defineConfig } from './config';
53
- /** Print startup banner */
54
- function printBanner(config, cloudUrl) {
55
- const dim = '\x1b[2m';
56
- const reset = '\x1b[0m';
57
- const bold = '\x1b[1m';
58
- const magenta = '\x1b[35m';
59
- const green = '\x1b[32m';
60
- const yellow = '\x1b[33m';
61
- // Format TCP endpoint display
62
- const tcpDisplay = config.tcpSocketPath
63
- ? `${bold}${config.tcpSocketPath}${reset} ${dim}(unix)${reset}`
64
- : `${bold}${config.hostname}:${config.tcpPort}${reset}`;
65
- // Format HTTP endpoint display
66
- const httpDisplay = config.httpSocketPath
67
- ? `${bold}${config.httpSocketPath}${reset} ${dim}(unix)${reset}`
68
- : `${bold}${config.hostname}:${config.httpPort}${reset}`;
69
- // Socket mode display
70
- const hasUnixSockets = config.tcpSocketPath !== undefined || config.httpSocketPath !== undefined;
71
- const socketDisplay = hasUnixSockets
72
- ? `${green}enabled${reset} ${dim}(${config.tcpSocketPath ? 'TCP' : ''}${config.tcpSocketPath && config.httpSocketPath ? '+' : ''}${config.httpSocketPath ? 'HTTP' : ''})${reset}`
73
- : `${dim}disabled${reset}`;
74
- console.log(`
75
- ${magenta} (\\(\\ ${reset}
76
- ${magenta} ( -.-) ${bold}bunqueue${reset} ${dim}v${VERSION}${reset}
77
- ${magenta} o_(")(") ${reset}${dim}High-performance job queue for Bun${reset}
78
-
79
- ${dim}─────────────────────────────────────────────────${reset}
80
-
81
- ${green}●${reset} TCP ${tcpDisplay}
82
- ${green}●${reset} HTTP ${httpDisplay}
83
- ${yellow}●${reset} Socket ${socketDisplay}
84
- ${yellow}●${reset} Data ${config.dataPath ?? 'in-memory'}
85
- ${yellow}●${reset} TLS ${config.tlsCertFile ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
86
- ${yellow}●${reset} Auth ${config.authTokens.length > 0 ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
87
- ${yellow}●${reset} S3 Backup ${config.s3BackupEnabled ? `${green}enabled${reset}` : `${dim}disabled${reset}`}
88
- ${yellow}●${reset} Cloud ${cloudUrl ? `${green}enabled${reset} ${dim}→ ${cloudUrl}${reset}` : `${dim}disabled${reset}`}
89
- ${dim}●${reset} Shards ${bold}${SHARD_COUNT}${reset} ${dim}(${navigator.hardwareConcurrency} CPU cores)${reset}
90
-
91
- ${dim}─────────────────────────────────────────────────${reset}
92
-
93
- `);
94
- }
95
28
  /** Start the server (direct mode) */
96
29
  async function startServer() {
97
30
  // Load config file (bunqueue.config.ts) if present, then merge with env vars
98
31
  const fileConfig = await loadConfigFile();
99
32
  const config = resolveServerConfig(fileConfig);
100
- // Apply logging config before anything else
101
- const logFormat = fileConfig?.logging?.format ?? Bun.env.LOG_FORMAT;
102
- const logLevel = fileConfig?.logging?.level ?? Bun.env.LOG_LEVEL?.toLowerCase();
103
- if (logFormat === 'json')
104
- Logger.enableJsonMode();
105
- if (logLevel) {
106
- const validLevels = ['debug', 'info', 'warn', 'error'];
107
- if (validLevels.includes(logLevel))
108
- Logger.setLevel(logLevel);
109
- }
110
- // Resolve cloud config
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
- }
121
- printBanner(config, cloudConfig?.url);
122
- // Create queue manager
123
- const queueManager = new QueueManager({
124
- dataPath: config.dataPath,
125
- });
126
- // Start TCP server
127
- const tcpServer = createTcpServer(queueManager, {
128
- port: config.tcpPort,
129
- hostname: config.hostname,
130
- authTokens: config.authTokens,
131
- ...(tlsConfig && { tls: tlsConfig }),
132
- });
133
- // Start HTTP server
134
- const httpServer = createHttpServer(queueManager, {
135
- port: config.httpPort,
136
- hostname: config.hostname,
137
- authTokens: config.authTokens,
138
- corsOrigins: config.corsOrigins,
139
- requireAuthForMetrics: config.requireAuthForMetrics,
140
- ...(tlsConfig && { tls: tlsConfig }),
141
- });
142
- // Initialize S3 backup manager
143
- let backupManager = null;
144
- if (config.dataPath) {
145
- const backupConfig = resolveBackupConfig(fileConfig, config.dataPath);
146
- backupManager = new S3BackupManager(backupConfig);
147
- backupManager.setDashboardEmit(queueManager.emitDashboardEvent.bind(queueManager));
148
- backupManager.start();
149
- }
150
- // Initialize bunqueue Cloud agent (remote dashboard telemetry)
151
- const cloudAgent = cloudConfig ? CloudAgent.createFromConfig(queueManager, cloudConfig) : null;
152
- if (cloudAgent) {
153
- cloudAgent.setServerHandles({
154
- getConnectionCount: () => tcpServer.getConnectionCount(),
155
- getWsClientCount: () => httpServer.getWsClientCount(),
156
- getSseClientCount: () => httpServer.getSseClientCount(),
157
- getBackupStatus: () => backupManager?.getStatus() ?? null,
158
- });
159
- }
160
- queueManager.emitDashboardEvent('server:started', {
161
- tcpPort: config.tcpPort,
162
- httpPort: config.httpPort,
163
- shards: SHARD_COUNT,
164
- });
165
- // Graceful shutdown
166
- let shuttingDown = false;
167
- const shutdown = async (signal) => {
168
- if (shuttingDown)
169
- return;
170
- shuttingDown = true;
171
- serverLog.info(`Received ${signal}, shutting down...`);
172
- // Stop stats interval immediately
173
- clearInterval(statsInterval);
174
- tcpServer.stop();
175
- httpServer.stop();
176
- const shutdownTimeout = config.shutdownTimeoutMs;
177
- const start = Date.now();
178
- while (Date.now() - start < shutdownTimeout) {
179
- const stats = queueManager.getStats();
180
- if (stats.active === 0)
181
- break;
182
- serverLog.info(`Waiting for ${stats.active} active jobs...`);
183
- await Bun.sleep(1000);
184
- }
185
- // Stop backup manager
186
- if (backupManager) {
187
- backupManager.stop();
188
- }
189
- // Stop Cloud agent (sends final shutdown snapshot)
190
- if (cloudAgent) {
191
- await cloudAgent.stop();
192
- }
193
- queueManager.emitDashboardEvent('server:shutdown', { signal });
194
- queueManager.shutdown();
195
- stopRateLimiter();
196
- serverLog.info('Shutdown complete');
197
- process.exit(0);
198
- };
199
- process.on('SIGINT', () => void shutdown('SIGINT'));
200
- process.on('SIGTERM', () => void shutdown('SIGTERM'));
201
- process.on('uncaughtException', (err) => {
202
- serverLog.error('Uncaught exception - initiating shutdown', {
203
- error: err.message,
204
- stack: err.stack,
205
- });
206
- void shutdown('uncaughtException');
207
- });
208
- process.on('unhandledRejection', (reason) => {
209
- serverLog.error('Unhandled promise rejection - initiating shutdown', {
210
- reason: reason instanceof Error ? reason.message : String(reason),
211
- stack: reason instanceof Error ? reason.stack : undefined,
212
- });
213
- void shutdown('unhandledRejection');
214
- });
215
- // Print stats periodically
216
- const statsInterval = setInterval(() => {
217
- const stats = queueManager.getStats();
218
- const memStats = queueManager.getMemoryStats();
219
- const workerStats = queueManager.workerManager.getStats();
220
- const mem = process.memoryUsage();
221
- const now = new Date();
222
- const timestamp = now.toLocaleTimeString('en-GB', {
223
- hour: '2-digit',
224
- minute: '2-digit',
225
- second: '2-digit',
226
- });
227
- statsLog.info('Queue statistics', {
228
- time: timestamp,
229
- waiting: stats.waiting,
230
- active: stats.active,
231
- delayed: stats.delayed,
232
- completed: stats.completed,
233
- dlq: stats.dlq,
234
- tcp: tcpServer.getConnectionCount(),
235
- ws: httpServer.getWsClientCount(),
236
- sse: httpServer.getSseClientCount(),
237
- workers: `${workerStats.active}/${workerStats.total}`,
238
- mem: `${Math.round(mem.heapUsed / 1024 / 1024)}MB/${Math.round(mem.heapTotal / 1024 / 1024)}MB`,
239
- rss: `${Math.round(mem.rss / 1024 / 1024)}MB`,
240
- // Internal collection sizes (for memory debugging)
241
- idx: memStats.jobIndex,
242
- locks: memStats.jobLocks,
243
- clients: memStats.clientJobsTotal,
244
- });
245
- }, config.statsIntervalMs);
33
+ bootServer(fileConfig, config);
246
34
  }
247
35
  // Logger env-var bootstrap only applies when this file is the entry point.
248
36
  // Imported consumers (e.g. user config files using `defineConfig`) must not
@@ -4,19 +4,11 @@
4
4
  */
5
5
  import { z } from 'zod';
6
6
  import { withErrorHandler } from './withErrorHandler';
7
+ import { WEBHOOK_EVENTS } from '../../domain/types/webhook';
7
8
  export function registerWebhookTools(server, backend) {
8
9
  server.tool('bunqueue_add_webhook', 'Add a webhook to receive notifications for job events.', {
9
10
  url: z.string().url().describe('Webhook URL to receive POST requests'),
10
- events: z
11
- .array(z.enum([
12
- 'job.completed',
13
- 'job.failed',
14
- 'job.progress',
15
- 'job.active',
16
- 'job.waiting',
17
- 'job.delayed',
18
- ]))
19
- .describe('Events to subscribe to'),
11
+ events: z.array(z.enum(WEBHOOK_EVENTS)).describe('Events to subscribe to'),
20
12
  queue: z.string().optional().describe('Limit to a specific queue (omit for all queues)'),
21
13
  }, withErrorHandler('bunqueue_add_webhook', async ({ url, events, queue }) => {
22
14
  const webhook = await backend.addWebhook(url, events, queue);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.9",
3
+ "version": "2.8.10",
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",