bunqueue-client 0.1.7 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to `bunqueue-client` (TypeScript SDK) are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.9] - 2026-07-14
9
+
10
+ Conformance-suite driven: the SDK is now certified by the cross-language
11
+ conformance kit (`sdk/conformance`, 17/17) against the formal wire spec
12
+ (`docs/protocol.md`).
13
+
14
+ ### Fixed
15
+
16
+ - **`drain()` now returns the number of removed jobs** (was `void`,
17
+ silently discarding the wire `count` — the "discarded return value"
18
+ class the conformance suite checks for).
19
+
20
+ ## [0.1.8] - 2026-07-14
21
+
22
+ Spec-alignment audit against the core protocol. Every fix ships with a repro
23
+ test in `tests/e2e-spec-align.ts`.
24
+
25
+ ### Fixed
26
+
27
+ - **`heartbeatIntervalS: 0` now disables heartbeats.** Previously it armed
28
+ `setInterval(fn, 0)`, flooding the server with hundreds of `Heartbeat`
29
+ commands per second. `0` (or negative) now matches the official client's
30
+ "0 = disabled" semantics.
31
+ - **`batchSize` is clamped to the server maximum (1000).** The server rejects
32
+ `PULLB` with `count > 1000`; an unclamped `batchSize` combined with
33
+ `concurrency > 1000` wedged the pull loop in a permanent error cycle.
34
+ - **Simple Mode `cron()`/`every()` forward the execution `limit`.** The option
35
+ was silently dropped (the "client drops a wire-supported field" class,
36
+ #111); it now reaches the scheduler as wire `maxLimit`, matching the
37
+ official client's signature.
38
+ - **`waitForJob()` clamps `ttlMs` to the server cap (600000).** Larger values
39
+ were rejected by the server with "timeout must be at most 600000" instead
40
+ of waiting.
41
+ - **`PROTOCOL_VERSION` bumped to 2**, matching the version the server
42
+ advertises in `Hello`.
43
+
8
44
  ## [0.1.7] - 2026-07-10
9
45
 
10
46
  Audit fixes: typed worker events, error-path hygiene and two more members of
package/README.md CHANGED
@@ -1,16 +1,43 @@
1
+ <div align="center">
2
+
3
+ <a href="https://bunqueue.dev">
4
+ <img src="https://raw.githubusercontent.com/egeominotti/bunqueue/main/.github/logo.png" alt="bunqueue logo" width="110" />
5
+ </a>
6
+
1
7
  # bunqueue-client
2
8
 
3
- Official TypeScript client for [bunqueue](https://github.com/egeominotti/bunqueue), the high performance job queue server. The client implements the native TCP protocol (msgpack, pipelined) and provides full feature parity with the built in Bun client, while running on every modern JavaScript runtime.
9
+ **The official TypeScript client for [bunqueue](https://bunqueue.dev), the high performance job queue server.**
10
+
11
+ Native TCP protocol (msgpack, pipelined), full parity with the built in Bun client, one runtime dependency.
12
+ Runs everywhere: Node.js, Bun, Deno and Cloudflare Workers.
13
+
14
+ [![npm version](https://img.shields.io/npm/v/bunqueue-client?color=d3156d&label=npm)](https://www.npmjs.com/package/bunqueue-client)
15
+ [![npm downloads](https://img.shields.io/npm/dm/bunqueue-client?color=ff4f9f)](https://www.npmjs.com/package/bunqueue-client)
16
+ [![license](https://img.shields.io/npm/l/bunqueue-client?color=1a1a2e)](https://github.com/egeominotti/bunqueue/blob/main/sdk/typescript/LICENSE)
17
+ [![runtimes](https://img.shields.io/badge/runtimes-node%20%7C%20bun%20%7C%20deno%20%7C%20workers-2ea44f)](#compatibility)
18
+
19
+ [Documentation](https://bunqueue.dev/guide/sdks/) · [Server](https://github.com/egeominotti/bunqueue) · [Changelog](https://github.com/egeominotti/bunqueue/blob/main/sdk/typescript/CHANGELOG.md) · [Python SDK](https://github.com/egeominotti/bunqueue/tree/main/sdk/python)
20
+
21
+ </div>
22
+
23
+ ---
4
24
 
5
25
  The bunqueue server runs on Bun, distributed as a binary or a Docker image. This client allows any Node.js, Bun, Deno, or Cloudflare Workers service to produce and consume jobs against it: one queue, any language, any runtime.
6
26
 
27
+ ## Why bunqueue-client
28
+
29
+ - **Full API surface.** Queues, workers, flows (parent/children trees), schedulers, DLQ, rate limits, webhooks, Simple Mode: 110+ public methods, each covered by an e2e test against a real server.
30
+ - **Cross runtime by design.** Only `node:*` builtins, zero `Bun.*` globals, a single dependency (`msgpackr`). The same package runs on Node 20+, Bun, Deno 2 and Cloudflare Workers (`nodejs_compat`).
31
+ - **Production semantics.** Lock leasing with heartbeat renewal, at least once delivery with retries and backoff, unrecoverable failures straight to the DLQ, reconnection with half open detection, opt in ACK batching and connection pooling.
32
+ - **Typed end to end.** Generic `Queue<T>` / `Worker<T, R>`, typed worker events, structured telemetry hooks for your metrics stack.
33
+
7
34
  ## Compatibility
8
35
 
9
36
  | Runtime | Status | Notes |
10
37
  |---|---|---|
11
- | Node.js 20 or later | Supported, 100/100 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
12
- | Bun | Supported, 100/100 e2e and 8/8 integration tests | No additional configuration required |
13
- | Deno 2 or later | Supported, 100/100 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
38
+ | Node.js 20 or later | Supported, 110/110 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
39
+ | Bun | Supported, 110/110 e2e and 8/8 integration tests | No additional configuration required |
40
+ | Deno 2 or later | Supported, 110/110 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
14
41
  | tsx, ts-node, vitest, jest | Supported | These environments execute on Node.js |
15
42
  | Cloudflare Workers | Supported, 16/16 e2e tests inside workerd, including Simple Mode and the full API surface | Requires the `nodejs_compat` compatibility flag. The runtime is request scoped, so long lived worker loops are not available: consume in batches from Cron Triggers or Durable Object alarms, a pattern covered by the test suite. TLS connections require a publicly trusted certificate |
16
43
  | Browser | Not supported | Raw TCP sockets are unavailable. Use the server HTTP API instead |
@@ -13,9 +13,11 @@ type Ctx = Bunqueue<any, any>;
13
13
  export declare const bunqueueApi: {
14
14
  cron(this: Ctx, id: string, pattern: string, data?: unknown, opts?: {
15
15
  timezone?: string;
16
+ limit?: number;
16
17
  jobOpts?: JobOptions;
17
18
  }): Promise<Raw | null>;
18
19
  every(this: Ctx, id: string, intervalMs: number, data?: unknown, opts?: {
20
+ limit?: number;
19
21
  jobOpts?: JobOptions;
20
22
  }): Promise<Raw | null>;
21
23
  removeCron(this: Ctx, id: string): Promise<void>;
@@ -50,9 +52,11 @@ export declare const bunqueueApi: {
50
52
  export interface BunqueueApi<T = unknown, R = unknown> {
51
53
  cron(id: string, pattern: string, data?: T, opts?: {
52
54
  timezone?: string;
55
+ limit?: number;
53
56
  jobOpts?: JobOptions;
54
57
  }): Promise<Raw | null>;
55
58
  every(id: string, intervalMs: number, data?: T, opts?: {
59
+ limit?: number;
56
60
  jobOpts?: JobOptions;
57
61
  }): Promise<Raw | null>;
58
62
  removeCron(id: string): Promise<void>;
@@ -6,11 +6,11 @@
6
6
  export const bunqueueApi = {
7
7
  // --------------------------------------------------------------------- cron
8
8
  async cron(id, pattern, data, opts) {
9
- await this.queue.upsertJobScheduler(id, { pattern, tz: opts?.timezone }, { name: id, data, opts: opts?.jobOpts });
9
+ await this.queue.upsertJobScheduler(id, { pattern, tz: opts?.timezone, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
10
10
  return this.queue.getJobScheduler(id);
11
11
  },
12
12
  async every(id, intervalMs, data, opts) {
13
- await this.queue.upsertJobScheduler(id, { every: intervalMs }, { name: id, data, opts: opts?.jobOpts });
13
+ await this.queue.upsertJobScheduler(id, { every: intervalMs, limit: opts?.limit }, { name: id, data, opts: opts?.jobOpts });
14
14
  return this.queue.getJobScheduler(id);
15
15
  },
16
16
  removeCron(id) {
package/dist/frame.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Wire framing for the bunqueue TCP protocol: 4-byte big-endian length
3
3
  * prefix + msgpack payload. Runtime-neutral (plain Buffer operations).
4
4
  */
5
- export declare const PROTOCOL_VERSION = 1;
5
+ export declare const PROTOCOL_VERSION = 2;
6
6
  export declare const MAX_FRAME_SIZE: number;
7
7
  /** Drop undefined-valued keys so the msgpack frame stays minimal. */
8
8
  export declare function compact<T extends Record<string, unknown>>(obj: T): T;
package/dist/frame.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * prefix + msgpack payload. Runtime-neutral (plain Buffer operations).
4
4
  */
5
5
  import { ConnectionClosedError } from './errors.js';
6
- export const PROTOCOL_VERSION = 1;
6
+ export const PROTOCOL_VERSION = 2;
7
7
  export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // mirror server-side limit
8
8
  /** Drop undefined-valued keys so the msgpack frame stays minimal. */
9
9
  export function compact(obj) {
@@ -9,7 +9,8 @@ export declare const controlMethods: {
9
9
  pause(this: Ctx): Promise<void>;
10
10
  resume(this: Ctx): Promise<void>;
11
11
  isPaused(this: Ctx): Promise<boolean>;
12
- drain(this: Ctx): Promise<void>;
12
+ /** Remove all waiting/delayed jobs; returns how many were dropped. */
13
+ drain(this: Ctx): Promise<number>;
13
14
  obliterate(this: Ctx): Promise<void>;
14
15
  /** Remove old jobs; returns how many were removed. */
15
16
  clean(this: Ctx, graceMs: number, limit?: number, state?: JobStateName): Promise<number>;
@@ -15,8 +15,10 @@ export const controlMethods = {
15
15
  async isPaused() {
16
16
  return (await this.call({ cmd: 'IsPaused', queue: this.name })).paused === true;
17
17
  },
18
+ /** Remove all waiting/delayed jobs; returns how many were dropped. */
18
19
  async drain() {
19
- await this.call({ cmd: 'Drain', queue: this.name });
20
+ const response = await this.call({ cmd: 'Drain', queue: this.name });
21
+ return response.count ?? 0;
20
22
  },
21
23
  async obliterate() {
22
24
  await this.call({ cmd: 'Obliterate', queue: this.name });
@@ -106,6 +106,8 @@ export const queryMethods = {
106
106
  * will not complete), everything else throws CommandTimeoutError.
107
107
  */
108
108
  async waitForJob(id, ttlMs = 30_000) {
109
+ // The server validates 0 <= timeout <= 600000: clamp instead of erroring.
110
+ ttlMs = Math.min(Math.max(ttlMs, 0), 600_000);
109
111
  const response = await this.call({ cmd: 'WaitJob', id, timeout: ttlMs }, ttlMs + 5000);
110
112
  if (response.completed !== true) {
111
113
  let state;
@@ -38,7 +38,11 @@ export class WorkerBase extends EventEmitter {
38
38
  throw new Error('concurrency must be >= 1');
39
39
  this.queue = queue;
40
40
  this.concurrency = opts.concurrency ?? 4;
41
- this.batchSize = Math.max(1, opts.batchSize ?? 10);
41
+ // The server rejects PULLB count > 1000 (handlers/core.ts) — an unclamped
42
+ // batchSize would wedge the pull loop in a permanent error cycle. The
43
+ // finite-guard also catches NaN, which would otherwise pass both bounds.
44
+ const rawBatch = opts.batchSize ?? 10;
45
+ this.batchSize = Number.isFinite(rawBatch) ? Math.min(Math.max(1, rawBatch), 1000) : 10;
42
46
  this.pollTimeoutMs = Math.min(opts.pollTimeoutMs ?? 5000, MAX_POLL_TIMEOUT_MS);
43
47
  this.lockTtlMs = opts.lockTtlMs ?? 30_000;
44
48
  this.heartbeatIntervalS = opts.heartbeatIntervalS ?? 10;
@@ -51,13 +51,13 @@ export interface WorkerOptions extends Observability {
51
51
  ackBatch?: AckBatchOptions;
52
52
  /** Max jobs processed in parallel (default 4). */
53
53
  concurrency?: number;
54
- /** Max jobs fetched per PULLB (default 10, capped by free slots). */
54
+ /** Max jobs fetched per PULLB (default 10, capped by free slots and the server max 1000). */
55
55
  batchSize?: number;
56
56
  /** Server-side long-poll timeout in ms (default 5000, max 30000). */
57
57
  pollTimeoutMs?: number;
58
58
  /** Job lock TTL in ms (default 30000). */
59
59
  lockTtlMs?: number;
60
- /** Worker + job heartbeat interval in seconds (default 10). */
60
+ /** Worker + job heartbeat interval in seconds (default 10, 0 = disabled). */
61
61
  heartbeatIntervalS?: number;
62
62
  /** Start the loop at construction (default true, mirrors the TS client). */
63
63
  autorun?: boolean;
package/dist/worker.js CHANGED
@@ -172,6 +172,11 @@ export class Worker extends WorkerBase {
172
172
  }
173
173
  // --------------------------------------------------------------- heartbeat
174
174
  startHeartbeat() {
175
+ // 0, negative or non-finite (NaN coerces to interval 0) disables
176
+ // heartbeats — setInterval(fn, 0) would fire every macrotask and flood
177
+ // the server with Heartbeat commands.
178
+ if (!(Number.isFinite(this.heartbeatIntervalS) && this.heartbeatIntervalS > 0))
179
+ return;
175
180
  this.heartbeatTimer = setInterval(() => {
176
181
  void (async () => {
177
182
  await this.safeCall({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue-client",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Cross-runtime TypeScript client for the bunqueue job queue server — Node.js, Bun, Deno and Cloudflare Workers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,11 +22,11 @@ export const bunqueueApi = {
22
22
  id: string,
23
23
  pattern: string,
24
24
  data?: unknown,
25
- opts?: { timezone?: string; jobOpts?: JobOptions }
25
+ opts?: { timezone?: string; limit?: number; jobOpts?: JobOptions }
26
26
  ): Promise<Raw | null> {
27
27
  await this.queue.upsertJobScheduler(
28
28
  id,
29
- { pattern, tz: opts?.timezone },
29
+ { pattern, tz: opts?.timezone, limit: opts?.limit },
30
30
  { name: id, data, opts: opts?.jobOpts }
31
31
  );
32
32
  return this.queue.getJobScheduler(id);
@@ -37,11 +37,11 @@ export const bunqueueApi = {
37
37
  id: string,
38
38
  intervalMs: number,
39
39
  data?: unknown,
40
- opts?: { jobOpts?: JobOptions }
40
+ opts?: { limit?: number; jobOpts?: JobOptions }
41
41
  ): Promise<Raw | null> {
42
42
  await this.queue.upsertJobScheduler(
43
43
  id,
44
- { every: intervalMs },
44
+ { every: intervalMs, limit: opts?.limit },
45
45
  { name: id, data, opts: opts?.jobOpts }
46
46
  );
47
47
  return this.queue.getJobScheduler(id);
@@ -173,13 +173,13 @@ export interface BunqueueApi<T = unknown, R = unknown> {
173
173
  id: string,
174
174
  pattern: string,
175
175
  data?: T,
176
- opts?: { timezone?: string; jobOpts?: JobOptions }
176
+ opts?: { timezone?: string; limit?: number; jobOpts?: JobOptions }
177
177
  ): Promise<Raw | null>;
178
178
  every(
179
179
  id: string,
180
180
  intervalMs: number,
181
181
  data?: T,
182
- opts?: { jobOpts?: JobOptions }
182
+ opts?: { limit?: number; jobOpts?: JobOptions }
183
183
  ): Promise<Raw | null>;
184
184
  removeCron(id: string): Promise<void>;
185
185
  listCrons(): Promise<Raw[]>;
package/src/frame.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { ConnectionClosedError } from './errors.js';
7
7
 
8
- export const PROTOCOL_VERSION = 1;
8
+ export const PROTOCOL_VERSION = 2;
9
9
  export const MAX_FRAME_SIZE = 64 * 1024 * 1024; // mirror server-side limit
10
10
 
11
11
  /** Drop undefined-valued keys so the msgpack frame stays minimal. */
@@ -25,8 +25,10 @@ export const controlMethods = {
25
25
  return (await this.call<PausedResponse>({ cmd: 'IsPaused', queue: this.name })).paused === true;
26
26
  },
27
27
 
28
- async drain(this: Ctx): Promise<void> {
29
- await this.call({ cmd: 'Drain', queue: this.name });
28
+ /** Remove all waiting/delayed jobs; returns how many were dropped. */
29
+ async drain(this: Ctx): Promise<number> {
30
+ const response = await this.call<{ count?: number }>({ cmd: 'Drain', queue: this.name });
31
+ return response.count ?? 0;
30
32
  },
31
33
 
32
34
  async obliterate(this: Ctx): Promise<void> {
@@ -148,6 +148,8 @@ export const queryMethods = {
148
148
  * will not complete), everything else throws CommandTimeoutError.
149
149
  */
150
150
  async waitForJob<R = unknown>(this: Ctx, id: string, ttlMs = 30_000): Promise<R> {
151
+ // The server validates 0 <= timeout <= 600000: clamp instead of erroring.
152
+ ttlMs = Math.min(Math.max(ttlMs, 0), 600_000);
151
153
  const response = await this.call<WaitJobResponse<R>>(
152
154
  { cmd: 'WaitJob', id, timeout: ttlMs },
153
155
  ttlMs + 5000
@@ -46,7 +46,11 @@ export class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
46
46
  if ((opts.concurrency ?? 4) < 1) throw new Error('concurrency must be >= 1');
47
47
  this.queue = queue;
48
48
  this.concurrency = opts.concurrency ?? 4;
49
- this.batchSize = Math.max(1, opts.batchSize ?? 10);
49
+ // The server rejects PULLB count > 1000 (handlers/core.ts) — an unclamped
50
+ // batchSize would wedge the pull loop in a permanent error cycle. The
51
+ // finite-guard also catches NaN, which would otherwise pass both bounds.
52
+ const rawBatch = opts.batchSize ?? 10;
53
+ this.batchSize = Number.isFinite(rawBatch) ? Math.min(Math.max(1, rawBatch), 1000) : 10;
50
54
  this.pollTimeoutMs = Math.min(opts.pollTimeoutMs ?? 5000, MAX_POLL_TIMEOUT_MS);
51
55
  this.lockTtlMs = opts.lockTtlMs ?? 30_000;
52
56
  this.heartbeatIntervalS = opts.heartbeatIntervalS ?? 10;
@@ -53,13 +53,13 @@ export interface WorkerOptions extends Observability {
53
53
  ackBatch?: AckBatchOptions;
54
54
  /** Max jobs processed in parallel (default 4). */
55
55
  concurrency?: number;
56
- /** Max jobs fetched per PULLB (default 10, capped by free slots). */
56
+ /** Max jobs fetched per PULLB (default 10, capped by free slots and the server max 1000). */
57
57
  batchSize?: number;
58
58
  /** Server-side long-poll timeout in ms (default 5000, max 30000). */
59
59
  pollTimeoutMs?: number;
60
60
  /** Job lock TTL in ms (default 30000). */
61
61
  lockTtlMs?: number;
62
- /** Worker + job heartbeat interval in seconds (default 10). */
62
+ /** Worker + job heartbeat interval in seconds (default 10, 0 = disabled). */
63
63
  heartbeatIntervalS?: number;
64
64
  /** Start the loop at construction (default true, mirrors the TS client). */
65
65
  autorun?: boolean;
package/src/worker.ts CHANGED
@@ -197,6 +197,10 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
197
197
  // --------------------------------------------------------------- heartbeat
198
198
 
199
199
  private startHeartbeat(): void {
200
+ // 0, negative or non-finite (NaN coerces to interval 0) disables
201
+ // heartbeats — setInterval(fn, 0) would fire every macrotask and flood
202
+ // the server with Heartbeat commands.
203
+ if (!(Number.isFinite(this.heartbeatIntervalS) && this.heartbeatIntervalS > 0)) return;
200
204
  this.heartbeatTimer = setInterval(() => {
201
205
  void (async () => {
202
206
  await this.safeCall({