bunqueue 2.8.26 → 2.8.27

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/README.md CHANGED
@@ -27,11 +27,15 @@
27
27
 
28
28
  ## Requirements
29
29
 
30
- bunqueue is **Bun-only** (`bun >= 1.3.9`). The client, TCP transport and persistence
31
- rely on Bun's runtime APIs (`Bun.connect`, `Bun.file`, `Bun.hash`, …) and ship as
32
- ESM with extensionless specifiers, so they do **not** run under Node.js. Importing
33
- the package from Node fails fast with a clear error pointing here rather than a
34
- cryptic resolver crash. Install Bun from [bun.sh](https://bun.sh) and run with `bun`.
30
+ The bunqueue **server** (and the embedded mode) is **Bun-only** (`bun >= 1.3.9`):
31
+ persistence and transport rely on Bun's runtime APIs. Install Bun from
32
+ [bun.sh](https://bun.sh) and run with `bun`. Importing the `bunqueue` package from
33
+ Node fails fast with a clear error rather than a cryptic resolver crash.
34
+
35
+ **Your producers and workers don't have to run on Bun.** Official client SDKs
36
+ connect to the server from **Node.js, Deno, Cloudflare Workers**
37
+ ([`bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) on npm) and
38
+ **Python** (`bunqueue-client`, PyPI coming soon) — see [One Queue, Any Language](#one-queue-any-language-sdks).
35
39
 
36
40
  ## Quickstart
37
41
 
@@ -626,13 +630,22 @@ await engine.signal(run.id, 'manager-approval', { approved: true });
626
630
 
627
631
  <p align="center">
628
632
  <strong>bunqueue Dashboard</strong><br/>
629
- <sub>A visual interface for managing queues, jobs, workers and monitoring in real time. Currently in beta.</sub>
633
+ <sub>A web dashboard that fully drives your bunqueue server &mdash; queues, jobs, DLQ, cron, webhooks, workers, live activity, SQLite inspector, and an AI Copilot. Open source, currently in beta. Requires Bun.</sub>
630
634
  </p>
631
635
 
632
636
  https://github.com/user-attachments/assets/e8a8d38e-b4a6-4dc8-8360-876c0f24d116
633
637
 
638
+ ```bash
639
+ bunx bunqueue-dashboard
640
+ ```
641
+
634
642
  <p align="center">
635
- <sub>Want early access? Reach out at <b>egeominotti@gmail.com</b></sub>
643
+ <sub>
644
+ <a href="https://egeominotti.github.io/bunqueue-dashboard/">Live demo</a> &middot;
645
+ <a href="https://egeominotti.github.io/bunqueue-dashboard/docs/user-guide">User guide</a> &middot;
646
+ <a href="https://github.com/egeominotti/bunqueue-dashboard">GitHub</a> &middot;
647
+ <a href="https://www.npmjs.com/package/bunqueue-dashboard">npm: bunqueue-dashboard</a>
648
+ </sub>
636
649
  </p>
637
650
 
638
651
  ---
@@ -740,7 +753,9 @@ bunqueue is for when you **don't want to run Redis**. SQLite with WAL mode handl
740
753
  bun add bunqueue
741
754
  ```
742
755
 
743
- > Requires [Bun](https://bun.sh) runtime. Node.js is not supported.
756
+ > The server and embedded mode require the [Bun](https://bun.sh) runtime. To
757
+ > connect **clients** from Node.js, Deno, Cloudflare Workers or Python, use the
758
+ > [SDKs](#one-queue-any-language-sdks) instead.
744
759
 
745
760
  ## Two Modes
746
761
 
@@ -804,6 +819,40 @@ const worker = new Worker(
804
819
  await queue.add('process', { data: 'hello' });
805
820
  ```
806
821
 
822
+ ## One Queue, Any Language (SDKs)
823
+
824
+ The server does all the heavy lifting — retries, priorities, scheduling, DLQ.
825
+ Official client SDKs speak the native TCP protocol (msgpack, pipelined) with
826
+ full feature parity, so producers and workers can live anywhere in your stack:
827
+
828
+ | Where your code runs | Install | Status |
829
+ | -------------------- | ------- | ------ |
830
+ | **Node.js ≥ 20, Deno ≥ 2, Bun, Cloudflare Workers** | [`npm install bunqueue-client`](https://www.npmjs.com/package/bunqueue-client) | 58/58 e2e per runtime + 11/11 inside workerd |
831
+ | **Python ≥ 3.9** | PyPI coming soon — today: `pip install "git+https://github.com/egeominotti/bunqueue.git#subdirectory=sdk/python"` | 44/44 e2e |
832
+
833
+ ```typescript
834
+ // Node.js / Deno / Cloudflare Workers
835
+ import { Queue, Worker } from 'bunqueue-client';
836
+
837
+ const queue = new Queue('emails', { host: 'localhost', port: 6789 });
838
+ await queue.add('welcome', { to: 'user@example.com' });
839
+
840
+ new Worker('emails', async (job) => ({ sent: true }), { concurrency: 10 });
841
+ ```
842
+
843
+ ```python
844
+ # Python
845
+ from bunqueue import Queue, Worker
846
+
847
+ queue = Queue("emails", host="localhost", port=6789)
848
+ queue.add("welcome", {"to": "user@example.com"})
849
+
850
+ Worker("emails", lambda job: {"sent": True}, concurrency=10).run()
851
+ ```
852
+
853
+ Mix and match: a Next.js API adds jobs, a Python service processes them.
854
+ Docs: [bunqueue.dev/guide/sdks](https://bunqueue.dev/guide/sdks/) · Sources: [`sdk/`](./sdk/)
855
+
807
856
  ### Simple Mode
808
857
 
809
858
  One object. Queue + Worker + Routes + Middleware + Cron. Zero boilerplate.
@@ -26,8 +26,19 @@ export interface ConnectionTarget {
26
26
  /**
27
27
  * Map client TLS options to the `tls` value accepted by Bun.connect.
28
28
  * Returns undefined when TLS is disabled (plaintext, the default).
29
+ *
30
+ * The CA is read into bytes (not passed as a `Bun.file` handle): Bun computes
31
+ * the peer's `authorizationError` from this `ca`, and we enforce it in the
32
+ * `handshake` handler (see `tlsRequiresVerification` / #109) — Bun.connect
33
+ * itself never rejects an unauthorized peer on the client side.
29
34
  */
30
35
  export declare function buildClientTls(tls: boolean | ClientTlsOptions | undefined): true | Record<string, unknown> | undefined;
36
+ /**
37
+ * Whether the caller asked us to authenticate the server certificate.
38
+ * Verification is the default for any TLS connection; only an explicit
39
+ * `rejectUnauthorized: false` opts out (encryption-only). #109
40
+ */
41
+ export declare function tlsRequiresVerification(tls: boolean | ClientTlsOptions | undefined): boolean;
31
42
  /**
32
43
  * Establish TCP connection to server
33
44
  */
@@ -2,10 +2,16 @@
2
2
  * TCP Connection Handler
3
3
  * Manages low-level socket connection and data handling (msgpack binary protocol)
4
4
  */
5
+ import { readFileSync } from 'node:fs';
5
6
  import { FrameParser, FrameSizeError } from '../../infrastructure/server/protocol';
6
7
  /**
7
8
  * Map client TLS options to the `tls` value accepted by Bun.connect.
8
9
  * Returns undefined when TLS is disabled (plaintext, the default).
10
+ *
11
+ * The CA is read into bytes (not passed as a `Bun.file` handle): Bun computes
12
+ * the peer's `authorizationError` from this `ca`, and we enforce it in the
13
+ * `handshake` handler (see `tlsRequiresVerification` / #109) — Bun.connect
14
+ * itself never rejects an unauthorized peer on the client side.
9
15
  */
10
16
  export function buildClientTls(tls) {
11
17
  if (!tls)
@@ -14,9 +20,21 @@ export function buildClientTls(tls) {
14
20
  return true;
15
21
  return {
16
22
  ...(tls.rejectUnauthorized !== undefined && { rejectUnauthorized: tls.rejectUnauthorized }),
17
- ...(tls.caFile !== undefined && { ca: Bun.file(tls.caFile) }),
23
+ ...(tls.caFile !== undefined && { ca: readFileSync(tls.caFile) }),
18
24
  };
19
25
  }
26
+ /**
27
+ * Whether the caller asked us to authenticate the server certificate.
28
+ * Verification is the default for any TLS connection; only an explicit
29
+ * `rejectUnauthorized: false` opts out (encryption-only). #109
30
+ */
31
+ export function tlsRequiresVerification(tls) {
32
+ if (!tls)
33
+ return false;
34
+ if (tls === true)
35
+ return true;
36
+ return tls.rejectUnauthorized !== false;
37
+ }
20
38
  /**
21
39
  * Establish TCP connection to server
22
40
  */
@@ -29,6 +47,35 @@ export async function createConnection(target, connectTimeout, events) {
29
47
  };
30
48
  let connectionResolved = false;
31
49
  let timeoutId = null;
50
+ // TLS server-certificate verification. Bun.connect never rejects an
51
+ // unauthorized peer on the client side, but it DOES compute the peer's
52
+ // authorizationError and hand it to the `handshake` callback — so we gate
53
+ // the connection on it ourselves. #109
54
+ //
55
+ // Crucially, once a `handshake` handler is registered, Bun fires `open`
56
+ // BEFORE the TLS handshake completes (without it, `open` fires only after).
57
+ // So for EVERY TLS connection we must resolve on `handshake`, not `open` —
58
+ // otherwise the pool writes its first command onto a socket whose handshake
59
+ // is still in flight and the bytes are lost. Plaintext has no handshake
60
+ // event, so it still resolves in `open`.
61
+ const tlsValue = buildClientTls(target.tls);
62
+ const isTls = tlsValue !== undefined;
63
+ const verifyTls = tlsRequiresVerification(target.tls);
64
+ let opened = false;
65
+ let handshakeOk = !isTls; // plaintext: nothing to wait for; TLS: wait for handshake
66
+ const maybeResolveOpen = () => {
67
+ if (opened && handshakeOk && !connectionResolved) {
68
+ connectionResolved = true;
69
+ // Clear the connect timeout only now that we are actually resolving.
70
+ // For TLS, `open` fires BEFORE `handshake`, so clearing the timeout in
71
+ // `open` would leave the handshake phase unbounded — a stalled/malicious
72
+ // peer that completes TCP but never drives the TLS handshake (no error,
73
+ // no close) would hang this promise forever. Keeping the timeout armed
74
+ // until resolve preserves connectTimeout as a bound over the handshake.
75
+ cleanup();
76
+ resolve({ socket: socketData, cleanup });
77
+ }
78
+ };
32
79
  const cleanup = () => {
33
80
  if (timeoutId) {
34
81
  clearTimeout(timeoutId);
@@ -58,7 +105,9 @@ export async function createConnection(target, connectTimeout, events) {
58
105
  }
59
106
  },
60
107
  open(sock) {
61
- cleanup();
108
+ // NB: do NOT clear the connect timeout here. For TLS, `open` precedes
109
+ // `handshake`; the timeout must stay armed until `maybeResolveOpen`
110
+ // actually resolves (or a stalled handshake could hang forever). #109
62
111
  // Enable TCP keepalive so the OS probes idle connections and surfaces a
63
112
  // dead peer (suspended host, NAT/LB drop) via an error/close event,
64
113
  // instead of a half-open socket lingering until tcp_retries2 (~15 min).
@@ -72,8 +121,31 @@ export async function createConnection(target, connectTimeout, events) {
72
121
  }
73
122
  socketData.write = (d) => sock.write(d);
74
123
  socketData.end = () => sock.end();
75
- connectionResolved = true;
76
- resolve({ socket: socketData, cleanup });
124
+ opened = true;
125
+ maybeResolveOpen();
126
+ },
127
+ handshake(sock, success, authorizationError) {
128
+ // Fires only for TLS. When verification is required, a failed handshake
129
+ // or any authorizationError (wrong/absent CA, self-signed, host
130
+ // mismatch) must abort — otherwise TLS is encryption-only and an active
131
+ // MITM can impersonate the broker and harvest the auth token. #109
132
+ if (verifyTls && (!success || authorizationError)) {
133
+ if (!connectionResolved) {
134
+ connectionResolved = true;
135
+ cleanup();
136
+ const why = authorizationError?.message ?? 'handshake failed';
137
+ reject(new Error(`TLS verification failed for ${targetDesc}: ${why}`));
138
+ }
139
+ try {
140
+ sock.end();
141
+ }
142
+ catch {
143
+ /* already closing */
144
+ }
145
+ return;
146
+ }
147
+ handshakeOk = true;
148
+ maybeResolveOpen();
77
149
  },
78
150
  close() {
79
151
  if (!connectionResolved) {
@@ -99,8 +171,8 @@ export async function createConnection(target, connectTimeout, events) {
99
171
  }
100
172
  },
101
173
  };
102
- // Connect via TCP (optionally wrapped in TLS — protocol is unchanged)
103
- const tlsValue = buildClientTls(target.tls);
174
+ // Connect via TCP (optionally wrapped in TLS — protocol is unchanged).
175
+ // `tlsValue`/`isTls` were computed above to drive handshake-gated resolve.
104
176
  void Bun.connect({
105
177
  hostname: target.host ?? 'localhost',
106
178
  port: target.port ?? 6789,
@@ -16,7 +16,10 @@ export interface PushCommand extends BaseCommand {
16
16
  readonly priority?: number;
17
17
  readonly delay?: number;
18
18
  readonly maxAttempts?: number;
19
- readonly backoff?: number;
19
+ readonly backoff?: number | {
20
+ type: 'fixed' | 'exponential';
21
+ delay: number;
22
+ };
20
23
  readonly ttl?: number;
21
24
  readonly timeout?: number;
22
25
  readonly uniqueKey?: string;
@@ -31,11 +34,24 @@ export interface PushCommand extends BaseCommand {
31
34
  readonly removeOnFail?: boolean;
32
35
  /** Force immediate persistence to disk (bypass write buffer) */
33
36
  readonly durable?: boolean;
34
- /** Repeat configuration for recurring jobs */
37
+ /**
38
+ * Repeat configuration for recurring jobs. The wire object is preserved
39
+ * verbatim by `unpack` and consumed by the domain `parseRepeatConfig`, so
40
+ * the type mirrors `JobInput['repeat']` — under-declaring it (e.g. only
41
+ * `every`/`limit`/`count`) silently hid `pattern`/`tz`/dates from callers.
42
+ */
35
43
  readonly repeat?: {
36
44
  every?: number;
37
45
  limit?: number;
46
+ pattern?: string;
38
47
  count?: number;
48
+ startDate?: number;
49
+ endDate?: number;
50
+ tz?: string;
51
+ immediately?: boolean;
52
+ prevMillis?: number;
53
+ offset?: number;
54
+ jobId?: string;
39
55
  };
40
56
  /** BullMQ v5 flow failure propagation options */
41
57
  readonly failParentOnFailure?: boolean;
@@ -20,6 +20,13 @@ export declare function validateNumericField(value: unknown, name: string, optio
20
20
  max?: number;
21
21
  required?: boolean;
22
22
  }): string | null;
23
+ /**
24
+ * Validate the backoff field, which the public JobOptions type allows in two
25
+ * forms: a plain number (delay ms) or `{ type: 'fixed' | 'exponential', delay
26
+ * }`. Embedded mode already accepts both (domain parseBackoff); the TCP path
27
+ * must too, or the object form throws over the wire while working embedded.
28
+ */
29
+ export declare function validateBackoffField(value: unknown): string | null;
23
30
  /** Validate job options numeric fields */
24
31
  export declare function validateJobOptions(options: Record<string, unknown>): string | null;
25
32
  /** Re-export from shared module for backward compatibility */
@@ -77,6 +77,28 @@ export function validateNumericField(value, name, options = {}) {
77
77
  }
78
78
  return null;
79
79
  }
80
+ /**
81
+ * Validate the backoff field, which the public JobOptions type allows in two
82
+ * forms: a plain number (delay ms) or `{ type: 'fixed' | 'exponential', delay
83
+ * }`. Embedded mode already accepts both (domain parseBackoff); the TCP path
84
+ * must too, or the object form throws over the wire while working embedded.
85
+ */
86
+ export function validateBackoffField(value) {
87
+ if (value === undefined || value === null)
88
+ return null;
89
+ if (typeof value === 'object') {
90
+ const obj = value;
91
+ if (obj['type'] !== 'fixed' && obj['type'] !== 'exponential') {
92
+ return "backoff.type must be 'fixed' or 'exponential'";
93
+ }
94
+ return validateNumericField(obj['delay'], 'backoff.delay', {
95
+ min: 0,
96
+ max: 24 * 60 * 60 * 1000, // max 1 day
97
+ required: true,
98
+ });
99
+ }
100
+ return validateNumericField(value, 'backoff', { min: 0, max: 24 * 60 * 60 * 1000 }); // max 1 day
101
+ }
80
102
  /** Validate job options numeric fields */
81
103
  export function validateJobOptions(options) {
82
104
  const validations = [
@@ -84,7 +106,7 @@ export function validateJobOptions(options) {
84
106
  validateNumericField(options['delay'], 'delay', { min: 0, max: 365 * 24 * 60 * 60 * 1000 }), // max 1 year
85
107
  validateNumericField(options['timeout'], 'timeout', { min: 0, max: 24 * 60 * 60 * 1000 }), // max 1 day
86
108
  validateNumericField(options['maxAttempts'], 'maxAttempts', { min: 1, max: 1000 }),
87
- validateNumericField(options['backoff'], 'backoff', { min: 0, max: 24 * 60 * 60 * 1000 }), // max 1 day
109
+ validateBackoffField(options['backoff']),
88
110
  validateNumericField(options['ttl'], 'ttl', { min: 0, max: 365 * 24 * 60 * 60 * 1000 }), // max 1 year
89
111
  validateNumericField(options['stallTimeout'], 'stallTimeout', {
90
112
  min: 0,
@@ -54,6 +54,18 @@ interface ConnectionData {
54
54
  export declare function createTcpServer(queueManager: QueueManager, config: TcpServerConfig): {
55
55
  server: TCPSocketListener<ConnectionData>;
56
56
  connections: Map<string, Socket<ConnectionData>>;
57
+ /**
58
+ * Raw Bun socket handlers. Exposed for tests that need to drive the socket
59
+ * lifecycle directly (e.g. a `data` event arriving before `open` under TLS,
60
+ * #108) — production code goes through Bun.listen above.
61
+ */
62
+ _socketHandlers: {
63
+ open(socket: Socket<ConnectionData>): void;
64
+ data(socket: Socket<ConnectionData>, data: Buffer): Promise<void>;
65
+ close(socket: Socket<ConnectionData>): void;
66
+ error(_socket: Socket<ConnectionData>, error: Error): void;
67
+ drain(socket: Socket<ConnectionData>): void;
68
+ };
57
69
  /** Get connection count */
58
70
  getConnectionCount(): number;
59
71
  /** Broadcast to all connections */
@@ -103,28 +103,45 @@ export function createTcpServer(queueManager, config) {
103
103
  socket.terminate();
104
104
  }, idleTimeoutMs);
105
105
  }
106
+ /**
107
+ * Initialise the per-socket state. Idempotent: safe to call from both `open`
108
+ * and lazily from `data`. Under native TLS, Bun can deliver a `data` event
109
+ * BEFORE `open` has run (observed near-deterministically when a Worker boots
110
+ * its connections concurrently) — the handler would then destructure a null
111
+ * `socket.data` and the resulting TypeError escalates to the process-level
112
+ * unhandledRejection handler, taking the whole server down (a pre-auth remote
113
+ * DoS on an exposed TLS port). Initialising lazily preserves that first frame
114
+ * instead of dropping it; the guard makes a second `open` a no-op. See #108.
115
+ */
116
+ function initConnection(socket) {
117
+ if (socket.data)
118
+ return;
119
+ const clientId = uuid();
120
+ const state = createConnectionState(clientId);
121
+ const ctx = {
122
+ queueManager,
123
+ authTokens,
124
+ authenticated: authTokens.size === 0, // Auto-auth if no tokens
125
+ clientId, // For job ownership tracking
126
+ };
127
+ socket.data = {
128
+ state,
129
+ frameParser: new FrameParser(),
130
+ ctx,
131
+ semaphore: new Semaphore(MAX_CONCURRENT_PER_CONNECTION),
132
+ writeQueue: new SocketWriteQueue(maxWriteQueueBytes),
133
+ stallTimer: null,
134
+ };
135
+ connections.set(clientId, socket);
136
+ queueManager.emitDashboardEvent('client:connected', { clientId, transport: 'tcp' });
137
+ }
106
138
  const socketHandlers = {
107
139
  open(socket) {
108
- const clientId = uuid();
109
- const state = createConnectionState(clientId);
110
- const ctx = {
111
- queueManager,
112
- authTokens,
113
- authenticated: authTokens.size === 0, // Auto-auth if no tokens
114
- clientId, // For job ownership tracking
115
- };
116
- socket.data = {
117
- state,
118
- frameParser: new FrameParser(),
119
- ctx,
120
- semaphore: new Semaphore(MAX_CONCURRENT_PER_CONNECTION),
121
- writeQueue: new SocketWriteQueue(maxWriteQueueBytes),
122
- stallTimer: null,
123
- };
124
- connections.set(clientId, socket);
125
- queueManager.emitDashboardEvent('client:connected', { clientId, transport: 'tcp' });
140
+ initConnection(socket);
126
141
  },
127
142
  async data(socket, data) {
143
+ // TLS may deliver `data` before `open` — ensure per-socket state exists. #108
144
+ initConnection(socket);
128
145
  const { frameParser, ctx, state, semaphore, writeQueue } = socket.data;
129
146
  const rateLimiter = getRateLimiter();
130
147
  // Check rate limit
@@ -203,6 +220,10 @@ export function createTcpServer(queueManager, config) {
203
220
  await Promise.all(frames.map(processFrame));
204
221
  },
205
222
  close(socket) {
223
+ // A socket can close before `open`/`data` ever initialised its state
224
+ // (e.g. TLS handshake aborted) — nothing to release. #108
225
+ if (!socket.data)
226
+ return;
206
227
  const clientId = socket.data.state.clientId;
207
228
  // Cancel the slowloris stall timer and drop any buffered-but-unwritten
208
229
  // bytes; the socket is gone.
@@ -235,6 +256,9 @@ export function createTcpServer(queueManager, config) {
235
256
  tcpLog.error('Connection error', { error: error.message });
236
257
  },
237
258
  drain(socket) {
259
+ // Can fire before per-socket state is initialised under TLS. #108
260
+ if (!socket.data)
261
+ return;
238
262
  // Socket is ready for more writes after backpressure: flush any unwritten
239
263
  // tail bytes buffered by short writes, preserving frame order.
240
264
  socket.data.writeQueue.flush(socket);
@@ -252,6 +276,12 @@ export function createTcpServer(queueManager, config) {
252
276
  return {
253
277
  server,
254
278
  connections,
279
+ /**
280
+ * Raw Bun socket handlers. Exposed for tests that need to drive the socket
281
+ * lifecycle directly (e.g. a `data` event arriving before `open` under TLS,
282
+ * #108) — production code goes through Bun.listen above.
283
+ */
284
+ _socketHandlers: socketHandlers,
255
285
  /** Get connection count */
256
286
  getConnectionCount() {
257
287
  return connections.size;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.26",
3
+ "version": "2.8.27",
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",
@@ -76,12 +76,12 @@
76
76
  "msgpackr": "^1.11.8"
77
77
  },
78
78
  "devDependencies": {
79
- "@biomejs/biome": "^2.5.0",
79
+ "@biomejs/biome": "2.5.1",
80
80
  "@modelcontextprotocol/sdk": "^1.26.0",
81
81
  "@types/bun": "^1.3.9",
82
- "bullmq": "^5.70.1",
82
+ "bullmq": "^5.79.3",
83
83
  "elysia": "^1.4.25",
84
- "ioredis": "^5.9.3",
84
+ "ioredis": "^5.11.1",
85
85
  "typescript": "^5.9.3",
86
86
  "zod": "^4.3.6"
87
87
  },
@@ -146,4 +146,4 @@
146
146
  "engines": {
147
147
  "bun": ">=1.3.9"
148
148
  }
149
- }
149
+ }