bunqueue 2.8.9 → 2.8.11
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 +8 -2
- package/dist/application/queueManager.d.ts +1 -1
- package/dist/application/queueManager.js +2 -2
- package/dist/cli/client.d.ts +9 -0
- package/dist/cli/client.js +35 -9
- package/dist/cli/commands/backup.js +3 -3
- package/dist/cli/commands/cron.js +16 -3
- package/dist/cli/commands/job.js +5 -1
- package/dist/cli/commands/server.d.ts +3 -1
- package/dist/cli/commands/server.js +7 -105
- package/dist/cli/commands/webhook.js +3 -9
- package/dist/cli/help.js +3 -1
- package/dist/cli/index.js +112 -44
- package/dist/cli/output.js +57 -3
- package/dist/client/jobConversionHelpers.js +6 -3
- package/dist/client/queue/jobProxy.d.ts +4 -0
- package/dist/client/queue/jobProxy.js +14 -6
- package/dist/client/queue/operations/query.js +20 -2
- package/dist/client/worker/processor.js +14 -7
- package/dist/domain/types/command.d.ts +2 -0
- package/dist/domain/types/cron.js +3 -1
- package/dist/domain/types/job.d.ts +8 -0
- package/dist/domain/types/job.js +21 -0
- package/dist/domain/types/webhook.d.ts +12 -2
- package/dist/domain/types/webhook.js +13 -0
- package/dist/infrastructure/persistence/schema.d.ts +2 -2
- package/dist/infrastructure/persistence/schema.js +7 -2
- package/dist/infrastructure/persistence/sqlite.js +2 -2
- package/dist/infrastructure/persistence/sqliteSerializer.js +5 -0
- package/dist/infrastructure/persistence/statements.d.ts +1 -0
- package/dist/infrastructure/server/bootstrap.d.ts +9 -0
- package/dist/infrastructure/server/bootstrap.js +217 -0
- package/dist/infrastructure/server/handlers/advanced.js +8 -0
- package/dist/infrastructure/server/handlers/core.js +7 -1
- package/dist/infrastructure/server/handlers/monitoring.js +7 -0
- package/dist/infrastructure/server/httpRouteJobs.js +2 -0
- package/dist/main.js +12 -224
- package/dist/mcp/tools/webhookTools.js +2 -10
- package/package.json +1 -1
|
@@ -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);
|
|
@@ -189,7 +189,13 @@ 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
|
-
|
|
192
|
+
// #74: the wire is not type-safe — accept stack only as string[], cap at
|
|
193
|
+
// 100 elements before it reaches the domain (authoritative cap happens in
|
|
194
|
+
// failJob at job.stackTraceLimit).
|
|
195
|
+
const stack = Array.isArray(cmd.stack)
|
|
196
|
+
? cmd.stack.filter((line) => typeof line === 'string').slice(0, 100)
|
|
197
|
+
: undefined;
|
|
198
|
+
await ctx.queueManager.fail(jid, cmd.error, cmd.token, cmd.unrecoverable, stack && stack.length > 0 ? stack : undefined);
|
|
193
199
|
// Unregister job from client tracking
|
|
194
200
|
ctx.queueManager.unregisterClientJob(ctx.clientId, jid);
|
|
195
201
|
return resp.ok(undefined, reqId);
|
|
@@ -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,
|
|
@@ -271,6 +271,8 @@ export async function routeJobRoutes(req, path, method, ctx, cors) {
|
|
|
271
271
|
error: body['error'],
|
|
272
272
|
token: body['token'],
|
|
273
273
|
unrecoverable: body['unrecoverable'],
|
|
274
|
+
// Validated (string[] only, capped) in handleFail (#74)
|
|
275
|
+
stack: body['stack'],
|
|
274
276
|
}, ctx);
|
|
275
277
|
return jsonResponse(r, r.ok ? 200 : 400, cors);
|
|
276
278
|
}
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
void
|
|
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
|
|
21
|
+
void import('./cli/index').then(({ main }) => main());
|
|
40
22
|
}
|
|
41
23
|
}
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.8.11",
|
|
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",
|