bunqueue 2.8.26 → 2.8.28
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 +57 -8
- package/dist/application/backgroundTasks.js +6 -0
- package/dist/application/dlqManager.d.ts +1 -1
- package/dist/application/operations/queueControl.d.ts +1 -1
- package/dist/application/types.d.ts +1 -1
- package/dist/client/queue/helpers.js +4 -0
- package/dist/client/tcp/connection.d.ts +11 -0
- package/dist/client/tcp/connection.js +78 -6
- package/dist/domain/types/command.d.ts +18 -2
- package/dist/infrastructure/server/protocol.d.ts +7 -0
- package/dist/infrastructure/server/protocol.js +23 -1
- package/dist/infrastructure/server/tcp.d.ts +12 -0
- package/dist/infrastructure/server/tcp.js +48 -18
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -27,11 +27,15 @@
|
|
|
27
27
|
|
|
28
28
|
## Requirements
|
|
29
29
|
|
|
30
|
-
bunqueue is **Bun-only** (`bun >= 1.3.9`)
|
|
31
|
-
rely on Bun's runtime APIs
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
633
|
+
<sub>A web dashboard that fully drives your bunqueue server — 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>
|
|
643
|
+
<sub>
|
|
644
|
+
<a href="https://egeominotti.github.io/bunqueue-dashboard/">Live demo</a> ·
|
|
645
|
+
<a href="https://egeominotti.github.io/bunqueue-dashboard/docs/user-guide">User guide</a> ·
|
|
646
|
+
<a href="https://github.com/egeominotti/bunqueue-dashboard">GitHub</a> ·
|
|
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
|
-
>
|
|
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.
|
|
@@ -105,6 +105,12 @@ function getLockContext(ctx) {
|
|
|
105
105
|
shardLocks: ctx.shardLocks,
|
|
106
106
|
eventsManager: ctx.eventsManager,
|
|
107
107
|
dashboardEmit: ctx.dashboardEmit,
|
|
108
|
+
// #110: without storage, handleMaxStallsExceeded's saveDlqEntry/deleteJob
|
|
109
|
+
// (the #97 fix) silently no-op via optional chaining on the ONLY
|
|
110
|
+
// production path to checkExpiredLocks — the DLQ move never persists and
|
|
111
|
+
// SQLite keeps an orphan `active` row. LockContext.storage is optional,
|
|
112
|
+
// which is why this omission compiled unnoticed since 2.8.17.
|
|
113
|
+
storage: ctx.storage,
|
|
108
114
|
};
|
|
109
115
|
}
|
|
110
116
|
// ============ Job Timeouts ============
|
|
@@ -11,7 +11,7 @@ import type { SqliteStorage } from '../infrastructure/persistence/sqlite';
|
|
|
11
11
|
export interface DlqContext {
|
|
12
12
|
shards: Shard[];
|
|
13
13
|
jobIndex: Map<JobId, JobLocation>;
|
|
14
|
-
storage
|
|
14
|
+
storage: SqliteStorage | null;
|
|
15
15
|
}
|
|
16
16
|
/** Get jobs from DLQ (backward compatible) */
|
|
17
17
|
export declare function getDlqJobs(queue: string, ctx: DlqContext, count?: number): Job[];
|
|
@@ -19,7 +19,7 @@ export interface QueueControlContext {
|
|
|
19
19
|
completedJobsData?: MapLike<JobId, Job>;
|
|
20
20
|
jobResults?: MapLike<JobId, unknown>;
|
|
21
21
|
jobLogs?: MapLike<JobId, unknown>;
|
|
22
|
-
storage
|
|
22
|
+
storage: SqliteStorage | null;
|
|
23
23
|
}
|
|
24
24
|
/** Pause a queue */
|
|
25
25
|
export declare function pauseQueue(queue: string, ctx: QueueControlContext): void;
|
|
@@ -92,7 +92,7 @@ export interface LockContext {
|
|
|
92
92
|
shardLocks: RWLock[];
|
|
93
93
|
eventsManager: EventsManager;
|
|
94
94
|
dashboardEmit?: (event: string, data: Record<string, unknown>) => void;
|
|
95
|
-
storage
|
|
95
|
+
storage: SqliteStorage | null;
|
|
96
96
|
}
|
|
97
97
|
/** Context for background tasks */
|
|
98
98
|
export interface BackgroundContext extends QueueManagerState {
|
|
@@ -19,6 +19,10 @@ export function getDlqContext(manager) {
|
|
|
19
19
|
return {
|
|
20
20
|
shards: getShards(manager),
|
|
21
21
|
jobIndex: manager.getJobIndex(),
|
|
22
|
+
// #110-class: without storage, retryDlqByFilter's deleteDlqEntry/insertJob
|
|
23
|
+
// silently no-op — filtered retries were never persisted in embedded mode
|
|
24
|
+
// (dlq rows resurrected the jobs into the DLQ on restart).
|
|
25
|
+
storage: manager.storage,
|
|
22
26
|
};
|
|
23
27
|
}
|
|
24
28
|
/** Convert client filter to domain filter */
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.8.28",
|
|
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": "
|
|
79
|
+
"@biomejs/biome": "2.5.1",
|
|
80
80
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
81
81
|
"@types/bun": "^1.3.9",
|
|
82
|
-
"bullmq": "^5.
|
|
82
|
+
"bullmq": "^5.79.3",
|
|
83
83
|
"elysia": "^1.4.25",
|
|
84
|
-
"ioredis": "^5.
|
|
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
|
+
}
|