bunqueue-client 0.1.6 → 0.1.7

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,48 @@ 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.7] - 2026-07-10
9
+
10
+ Audit fixes: typed worker events, error-path hygiene and two more members of
11
+ the "client drops a wire-supported field" class (#111).
12
+
13
+ ### Added
14
+
15
+ - **Typed Worker events.** `worker.on('completed', (job, result) => ...)` now
16
+ gets typed `Job<T>`/`R`/`Error` parameters in strict mode instead of
17
+ `unknown[]` (TS18046). The new `WorkerEventMap<T, R>` covers `ready`,
18
+ `active`, `completed`, `failed`, `progress`, `error`, `drained`, `cancelled`
19
+ and `closed`; unknown event names keep a generic overload, so existing code
20
+ compiles unchanged. (H1)
21
+ - `"prepublishOnly": "bun run build"` so a publish can never ship a stale
22
+ `dist/`. (H3)
23
+
24
+ ### Fixed
25
+
26
+ - **Bunqueue constructor crash vector.** `new Bunqueue(..., { dlq })` fired
27
+ `setDlqConfig` with no rejection handler: an unreachable server at
28
+ construction time killed the process with an unhandled rejection. The
29
+ failure now routes to the worker's `'error'` event (swallowed when no
30
+ listener is attached, matching `pause()`/`resume()`). (H2)
31
+ - **ACK/completed asymmetry.** In the non-batched path the worker emitted
32
+ `'completed'` and incremented `processed` even when the ACK never reached
33
+ the server. Both the ACK and FAIL paths now mirror the batched semantics:
34
+ on a wire failure only `'error'` fires, with no counter increment. Errors
35
+ emitted on `'error'` are now always `Error` instances. (M1)
36
+ - **Not-found swallowing.** `getJobScheduler`, `getJob` and
37
+ `getJobByCustomId` caught every error (including `ConnectionClosedError`
38
+ and `CommandTimeoutError`) and returned `null`. The catch is narrowed to a
39
+ `CommandError` matching `/not found/i`; everything else rethrows. (M2)
40
+ - **Scheduler template priority/deduplication dropped.**
41
+ `upsertJobScheduler` put `priority` inside `jobOptions`, where the server's
42
+ `CronJobOptions` ignores it, and never sent the template's deduplication.
43
+ Both now travel as the top-level `priority`/`uniqueKey`/`dedup` Cron fields
44
+ the handler reads, matching the reference client. (#111 class, F3)
45
+ - **moveJobToFailed lost the stack and the unrecoverable flag.** It sent only
46
+ `error.message`; when given an `Error` it now sends the leading stack lines
47
+ and `unrecoverable: true` for `UnrecoverableError`, mirroring the worker
48
+ FAIL path. (#111 class, F4)
49
+
8
50
  ## [0.1.6] - 2026-07-09
9
51
 
10
52
  Enterprise-grade hardening. All additive and backward-compatible; defaults are
@@ -33,8 +75,8 @@ unchanged (observability is silent, backpressure unbounded, ACK batching off).
33
75
  ### CI
34
76
 
35
77
  - GitHub Actions runs both SDK suites on every `sdk/`/`src/` change (TypeScript
36
- on Bun + Node, Python 3.10/3.12); an npm release workflow publishes with
37
- build provenance.
78
+ on Bun + Node + Deno, Python 3.10/3.12); an npm release workflow publishes
79
+ with build provenance, gated on the e2e suite.
38
80
 
39
81
  ## [0.1.5] - 2026-07-08
40
82
 
package/README.md CHANGED
@@ -8,9 +8,9 @@ The bunqueue server runs on Bun, distributed as a binary or a Docker image. This
8
8
 
9
9
  | Runtime | Status | Notes |
10
10
  |---|---|---|
11
- | Node.js 20 or later | Supported, 81/81 e2e and 8/8 integration tests | ESM. TypeScript files run directly on Node 22 or later via `--experimental-strip-types` |
12
- | Bun | Supported, 81/81 e2e and 8/8 integration tests | No additional configuration required |
13
- | Deno 2 or later | Supported, 81/81 e2e and 8/8 integration tests | Uses `node:` builtins and the npm `msgpackr` package |
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 |
14
14
  | tsx, ts-node, vitest, jest | Supported | These environments execute on Node.js |
15
15
  | 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
16
  | Browser | Not supported | Raw TCP sockets are unavailable. Use the server HTTP API instead |
@@ -76,8 +76,18 @@ export class Bunqueue {
76
76
  });
77
77
  // DLQ & rate limit manager
78
78
  this.dlqrl = new DlqRateLimitManager(this.queue);
79
- if (opts.dlq)
80
- void this.dlqrl.setDlqConfig(opts.dlq);
79
+ // Fire-and-forget config push: without the catch, an unreachable server at
80
+ // construction time becomes an unhandled rejection that kills the process.
81
+ // Route the failure to the worker's 'error' event (the channel every other
82
+ // background command failure uses); with no listener attached, swallow it
83
+ // like pause()/resume() do — an unlistened 'error' emit would itself throw.
84
+ if (opts.dlq) {
85
+ void this.dlqrl.setDlqConfig(opts.dlq).catch((err) => {
86
+ if (this.worker.listenerCount('error') > 0) {
87
+ this.worker.emit('error', err instanceof Error ? err : new Error(String(err)));
88
+ }
89
+ });
90
+ }
81
91
  // Subsystems
82
92
  this.cb = opts.circuitBreaker
83
93
  ? new WorkerCircuitBreaker(opts.circuitBreaker, this.worker)
package/dist/index.d.ts CHANGED
@@ -23,5 +23,5 @@ export type { SchedulerOptions } from './queue-admin.js';
23
23
  export type { BatchResponse, CountResponse, DataResponse, JobCountsResponse, JobResponse, JobsResponse, OkResponse, PausedResponse, ProgressResponse, PulledJobResponse, PulledJobsResponse, ResultResponse, StateResponse, WaitJobResponse, } from './responses.js';
24
24
  export type { BackoffOptions, DeduplicationOptions, JobCounts, JobOptions, JobStateName, RepeatOptions, } from './types.js';
25
25
  export { Worker } from './worker.js';
26
- export type { AckBatchOptions, Processor, WorkerOptions } from './worker-types.js';
27
- export declare const __version__ = "0.1.6";
26
+ export type { AckBatchOptions, Processor, WorkerEventMap, WorkerOptions, } from './worker-types.js';
27
+ export declare const __version__ = "0.1.7";
package/dist/index.js CHANGED
@@ -13,4 +13,4 @@ export { Job } from './job.js';
13
13
  export { consoleLogger, noopLogger } from './observability.js';
14
14
  export { Queue } from './queue.js';
15
15
  export { Worker } from './worker.js';
16
- export const __version__ = '0.1.6';
16
+ export const __version__ = '0.1.7';
@@ -2,6 +2,7 @@
2
2
  * Queue admin surface: DLQ, stall/DLQ configs, rate limits, schedulers,
3
3
  * monitoring and webhooks. Merged onto Queue.prototype by queue.ts.
4
4
  */
5
+ import { CommandError } from './errors.js';
5
6
  import { compact } from './frame.js';
6
7
  import { jobPayload, wireJobOptions } from './types.js';
7
8
  export const adminMethods = {
@@ -50,6 +51,10 @@ export const adminMethods = {
50
51
  // ---------------------------------------------------------------- scheduler
51
52
  /** Create/update a recurring job scheduler (cron pattern or fixed interval). */
52
53
  async upsertJobScheduler(schedulerId, repeat, template = {}) {
54
+ // Priority and deduplication of spawned jobs travel as TOP-LEVEL Cron
55
+ // fields (the handler reads cmd.priority/uniqueKey/dedup); inside
56
+ // jobOptions the server's CronJobOptions silently ignores them.
57
+ const dedup = template.opts?.deduplication;
53
58
  await this.call(compact({
54
59
  cmd: 'Cron',
55
60
  name: schedulerId,
@@ -57,9 +62,14 @@ export const adminMethods = {
57
62
  data: jobPayload(template.name ?? schedulerId, template.data ?? {}),
58
63
  schedule: repeat.pattern,
59
64
  repeatEvery: repeat.every,
65
+ priority: template.opts?.priority,
60
66
  timezone: repeat.tz,
61
67
  immediately: repeat.immediately,
62
68
  maxLimit: repeat.limit,
69
+ uniqueKey: dedup?.id,
70
+ dedup: dedup
71
+ ? compact({ ttl: dedup.ttl, extend: dedup.extend, replace: dedup.replace })
72
+ : undefined,
63
73
  skipMissedOnRestart: repeat.skipMissedOnRestart,
64
74
  skipIfNoWorker: repeat.skipIfNoWorker,
65
75
  preventOverlap: repeat.preventOverlap,
@@ -74,8 +84,12 @@ export const adminMethods = {
74
84
  const response = await this.call({ cmd: 'CronGet', name: schedulerId });
75
85
  return (response.cron ?? response.data ?? null);
76
86
  }
77
- catch {
78
- return null; // not-found surfaces as a CommandError
87
+ catch (err) {
88
+ // Only 'Cron job not found' maps to null; connection loss, timeouts and
89
+ // real server errors must surface, not masquerade as a missing scheduler.
90
+ if (err instanceof CommandError && /not found/i.test(err.message))
91
+ return null;
92
+ throw err;
79
93
  }
80
94
  },
81
95
  async getJobSchedulers() {
@@ -38,6 +38,11 @@ export declare const controlMethods: {
38
38
  moveJobToDelayed(this: Ctx, id: string, delayMs: number): Promise<void>;
39
39
  extendJobLock(this: Ctx, id: string, token: string, durationMs: number): Promise<void>;
40
40
  moveJobToCompleted(this: Ctx, id: string, returnValue: unknown, token?: string): Promise<void>;
41
+ /**
42
+ * Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
43
+ * and the UnrecoverableError "do not retry" intent are persisted (#111
44
+ * silent-loss class), not just the message.
45
+ */
41
46
  moveJobToFailed(this: Ctx, id: string, error: Error | string, token?: string): Promise<void>;
42
47
  };
43
48
  export type QueueControlApi = typeof controlMethods;
@@ -2,7 +2,9 @@
2
2
  * Queue control surface: pause/drain/clean, promotion, retry and per-job
3
3
  * mutations. Methods are merged onto Queue.prototype by queue.ts.
4
4
  */
5
+ import { UnrecoverableError } from './errors.js';
5
6
  import { compact } from './frame.js';
7
+ import { MAX_STACK_LINES } from './worker-types.js';
6
8
  export const controlMethods = {
7
9
  async pause() {
8
10
  await this.call({ cmd: 'Pause', queue: this.name });
@@ -47,9 +49,9 @@ export const controlMethods = {
47
49
  await this.call({ cmd: 'RetryCompleted', queue: this.name });
48
50
  return;
49
51
  }
50
- // `count` is accepted for API parity but not sent: the server has no
51
- // partial RetryDlq — it retries the whole DLQ (BullMQ semantics).
52
- await this.call({ cmd: 'RetryDlq', queue: this.name });
52
+ // `count` caps how many DLQ entries are retried (server >= 2.8.29). Older
53
+ // servers ignore the field and retry the whole DLQ — forward-compatible.
54
+ await this.call(compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }));
53
55
  },
54
56
  async retryCompleted(id) {
55
57
  await this.call(compact({ cmd: 'RetryCompleted', queue: this.name, id }));
@@ -79,8 +81,25 @@ export const controlMethods = {
79
81
  async moveJobToCompleted(id, returnValue, token) {
80
82
  await this.call(compact({ cmd: 'ACK', id, result: returnValue, token }));
81
83
  },
84
+ /**
85
+ * Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
86
+ * and the UnrecoverableError "do not retry" intent are persisted (#111
87
+ * silent-loss class), not just the message.
88
+ */
82
89
  async moveJobToFailed(id, error, token) {
83
- const message = typeof error === 'string' ? error : error.message;
84
- await this.call(compact({ cmd: 'FAIL', id, error: message, token }));
90
+ const err = typeof error === 'string' ? undefined : error;
91
+ const message = err ? err.message || err.name : error;
92
+ // Keep the FIRST lines (message + throw site), like the worker path.
93
+ const stack = err
94
+ ? (err.stack ?? err.message).split('\n').slice(0, MAX_STACK_LINES)
95
+ : undefined;
96
+ await this.call(compact({
97
+ cmd: 'FAIL',
98
+ id,
99
+ error: message,
100
+ stack,
101
+ unrecoverable: err instanceof UnrecoverableError ? true : undefined,
102
+ token,
103
+ }));
85
104
  },
86
105
  };
@@ -16,8 +16,10 @@ export const queryMethods = {
16
16
  return response.job ? new Job(response.job, this.connection) : null;
17
17
  }
18
18
  catch (err) {
19
- if (err instanceof CommandError)
20
- return null; // server: 'Job not found'
19
+ // Only the server's 'Job not found' maps to null; connection loss,
20
+ // timeouts and other server errors must surface.
21
+ if (err instanceof CommandError && /not found/i.test(err.message))
22
+ return null;
21
23
  throw err;
22
24
  }
23
25
  },
@@ -27,7 +29,7 @@ export const queryMethods = {
27
29
  return response.job ? new Job(response.job, this.connection) : null;
28
30
  }
29
31
  catch (err) {
30
- if (err instanceof CommandError)
32
+ if (err instanceof CommandError && /not found/i.test(err.message))
31
33
  return null;
32
34
  throw err;
33
35
  }
@@ -4,8 +4,8 @@
4
4
  */
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { Connection } from './connection.js';
7
- import { type WorkerOptions } from './worker-types.js';
8
- export declare class WorkerBase extends EventEmitter {
7
+ import { type WorkerEventMap, type WorkerOptions } from './worker-types.js';
8
+ export declare class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
9
9
  readonly queue: string;
10
10
  readonly concurrency: number;
11
11
  readonly batchSize: number;
@@ -41,9 +41,16 @@ export declare class WorkerBase extends EventEmitter {
41
41
  * 'ready' is replayed to listeners attached after it fired: with autorun the
42
42
  * loop starts inside the constructor, so a plain once-only event could be
43
43
  * missed by `new Worker(...).on('ready', ...)` patterns.
44
+ *
45
+ * The overloads give the known worker events typed parameters (see
46
+ * WorkerEventMap); unknown event names keep the generic signature.
44
47
  */
48
+ on<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
45
49
  on(event: string | symbol, listener: (...args: unknown[]) => void): this;
50
+ once<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
46
51
  once(event: string | symbol, listener: (...args: unknown[]) => void): this;
52
+ off<E extends keyof WorkerEventMap<T, R>>(event: E, listener: WorkerEventMap<T, R>[E]): this;
53
+ off(event: string | symbol, listener: (...args: unknown[]) => void): this;
47
54
  /**
48
55
  * Cooperative cancel of a locally active job (mirrors the official client):
49
56
  * marks the job and emits 'cancelled'; the processor is expected to check
@@ -56,9 +63,12 @@ export declare class WorkerBase extends EventEmitter {
56
63
  * With `force` the wait for in-flight jobs is skipped (parity with the
57
64
  * official client's `close(force)`). */
58
65
  close(force?: boolean): Promise<void>;
66
+ /** Dispatch a command, routing failures to 'error'. Returns whether the
67
+ * command reached the server — callers gate success-only side effects
68
+ * ('completed'/'failed' emits, counters) on it. */
59
69
  protected safeCall(command: Record<string, unknown> & {
60
70
  cmd: string;
61
- }): Promise<void>;
71
+ }): Promise<boolean>;
62
72
  /** Hook run during close() before draining in-flight jobs (see Worker). */
63
73
  protected beforeClose(): Promise<void>;
64
74
  }
@@ -6,7 +6,7 @@ import { randomBytes } from 'node:crypto';
6
6
  import { EventEmitter } from 'node:events';
7
7
  import { hostname } from 'node:os';
8
8
  import { Connection } from './connection.js';
9
- import { MAX_POLL_TIMEOUT_MS, sleep } from './worker-types.js';
9
+ import { MAX_POLL_TIMEOUT_MS, sleep, } from './worker-types.js';
10
10
  export class WorkerBase extends EventEmitter {
11
11
  queue;
12
12
  concurrency;
@@ -74,11 +74,6 @@ export class WorkerBase extends EventEmitter {
74
74
  async waitUntilReady() {
75
75
  await this.readyPromise;
76
76
  }
77
- /**
78
- * 'ready' is replayed to listeners attached after it fired: with autorun the
79
- * loop starts inside the constructor, so a plain once-only event could be
80
- * missed by `new Worker(...).on('ready', ...)` patterns.
81
- */
82
77
  on(event, listener) {
83
78
  if (event === 'ready' && this.readyFired)
84
79
  listener();
@@ -91,6 +86,9 @@ export class WorkerBase extends EventEmitter {
91
86
  }
92
87
  return super.once(event, listener);
93
88
  }
89
+ off(event, listener) {
90
+ return super.off(event, listener);
91
+ }
94
92
  /**
95
93
  * Cooperative cancel of a locally active job (mirrors the official client):
96
94
  * marks the job and emits 'cancelled'; the processor is expected to check
@@ -139,12 +137,17 @@ export class WorkerBase extends EventEmitter {
139
137
  this.running = false;
140
138
  this.emit('closed');
141
139
  }
140
+ /** Dispatch a command, routing failures to 'error'. Returns whether the
141
+ * command reached the server — callers gate success-only side effects
142
+ * ('completed'/'failed' emits, counters) on it. */
142
143
  async safeCall(command) {
143
144
  try {
144
145
  await this.connection.call(command);
146
+ return true;
145
147
  }
146
148
  catch (err) {
147
- this.emit('error', err);
149
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
150
+ return false;
148
151
  }
149
152
  }
150
153
  /** Hook run during close() before draining in-flight jobs (see Worker). */
@@ -3,6 +3,34 @@ import type { TlsOption } from './connection.js';
3
3
  import type { Job } from './job.js';
4
4
  import type { Observability } from './observability.js';
5
5
  export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
6
+ /**
7
+ * Typed Worker event map: listeners registered via `on`/`once`/`off` for these
8
+ * names get typed job/result/error parameters in strict mode. Unknown event
9
+ * names fall back to a generic `(...args: unknown[])` overload.
10
+ */
11
+ export interface WorkerEventMap<T = unknown, R = unknown> {
12
+ /** Worker registered and pull loop started (replayed to late listeners). */
13
+ ready: () => void;
14
+ /** A job was pulled and handed to the processor. */
15
+ active: (job: Job<T>) => void;
16
+ /** Processor resolved AND the ACK reached the server. */
17
+ completed: (job: Job<T>, result: R) => void;
18
+ /** Processor threw AND the FAIL reached the server. */
19
+ failed: (job: Job<T>, error: Error) => void;
20
+ /** job.updateProgress() was called from the processor. */
21
+ progress: (job: Job<T>, progress: number) => void;
22
+ /** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
23
+ error: (error: Error) => void;
24
+ /** The queue went from busy to empty (no active jobs, nothing pulled). */
25
+ drained: () => void;
26
+ /** Cooperative cancel was requested for a locally active job. */
27
+ cancelled: (info: {
28
+ jobId: string;
29
+ reason: string;
30
+ }) => void;
31
+ /** close() finished. */
32
+ closed: () => void;
33
+ }
6
34
  export interface AckBatchOptions {
7
35
  /** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
8
36
  enabled?: boolean;
package/dist/worker.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { WorkerBase } from './worker-base.js';
9
9
  import { type Processor, type WorkerOptions } from './worker-types.js';
10
- export declare class Worker<T = unknown, R = unknown> extends WorkerBase {
10
+ export declare class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
11
11
  private readonly processor;
12
12
  private readonly ackBatcher;
13
13
  constructor(queue: string, processor: Processor<T, R>, opts?: WorkerOptions);
package/dist/worker.js CHANGED
@@ -36,7 +36,7 @@ export class Worker extends WorkerBase {
36
36
  return;
37
37
  this.running = true;
38
38
  this.loopPromise = this.loop().catch((err) => {
39
- this.emit('error', err);
39
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
40
40
  });
41
41
  }
42
42
  // -------------------------------------------------------------------- loop
@@ -57,7 +57,7 @@ export class Worker extends WorkerBase {
57
57
  backoffIdx = 0;
58
58
  }
59
59
  catch (err) {
60
- this.emit('error', err);
60
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
61
61
  if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
62
62
  const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
63
63
  backoffIdx += 1;
@@ -123,7 +123,7 @@ export class Worker extends WorkerBase {
123
123
  onSettled: (err) => {
124
124
  this.finishJob(job.id);
125
125
  if (err) {
126
- this.emit('error', err);
126
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
127
127
  }
128
128
  else {
129
129
  this.processed += 1;
@@ -133,20 +133,23 @@ export class Worker extends WorkerBase {
133
133
  });
134
134
  return;
135
135
  }
136
- this.processed += 1;
137
- await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
136
+ const acked = await this.safeCall(compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }));
138
137
  // Free the slot BEFORE emitting: a throwing 'completed' listener must
139
138
  // not leak the active slot (same rationale as the batched path).
140
139
  this.finishJob(job.id);
141
- this.emit('completed', job, result);
140
+ // Mirror the batched path: a failed ACK already emitted 'error' — do not
141
+ // also claim completion (no 'completed', no processed++).
142
+ if (acked) {
143
+ this.processed += 1;
144
+ this.emit('completed', job, result);
145
+ }
142
146
  }
143
147
  catch (err) {
144
- this.failedCount += 1;
145
148
  const error = err instanceof Error ? err : new Error(String(err));
146
149
  // Keep the FIRST lines: in a JS stack the message + throw site lead, so
147
150
  // slice(0,N) preserves them (slice(-N) would drop them on long stacks).
148
151
  const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
149
- await this.safeCall(compact({
152
+ const failed = await this.safeCall(compact({
150
153
  cmd: 'FAIL',
151
154
  id: job.id,
152
155
  token,
@@ -155,7 +158,12 @@ export class Worker extends WorkerBase {
155
158
  unrecoverable: err instanceof UnrecoverableError ? true : undefined,
156
159
  }));
157
160
  this.finishJob(job.id);
158
- this.emit('failed', job, error);
161
+ // Same asymmetry guard as the ACK path: if the FAIL never reached the
162
+ // server, only 'error' fires (the lock expiry will retry the job).
163
+ if (failed) {
164
+ this.failedCount += 1;
165
+ this.emit('failed', job, error);
166
+ }
159
167
  }
160
168
  }
161
169
  finishJob(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue-client",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "scripts": {
27
27
  "build": "tsc -p tsconfig.json",
28
+ "prepublishOnly": "bun run build",
28
29
  "test": "bun tests/e2e.ts",
29
30
  "test:integration": "bun tests/integration.ts",
30
31
  "lint": "biome lint src tests",
@@ -90,7 +90,18 @@ export class Bunqueue<T = unknown, R = unknown> {
90
90
 
91
91
  // DLQ & rate limit manager
92
92
  this.dlqrl = new DlqRateLimitManager<T>(this.queue);
93
- if (opts.dlq) void this.dlqrl.setDlqConfig(opts.dlq);
93
+ // Fire-and-forget config push: without the catch, an unreachable server at
94
+ // construction time becomes an unhandled rejection that kills the process.
95
+ // Route the failure to the worker's 'error' event (the channel every other
96
+ // background command failure uses); with no listener attached, swallow it
97
+ // like pause()/resume() do — an unlistened 'error' emit would itself throw.
98
+ if (opts.dlq) {
99
+ void this.dlqrl.setDlqConfig(opts.dlq).catch((err: unknown) => {
100
+ if (this.worker.listenerCount('error') > 0) {
101
+ this.worker.emit('error', err instanceof Error ? err : new Error(String(err)));
102
+ }
103
+ });
104
+ }
94
105
 
95
106
  // Subsystems
96
107
  this.cb = opts.circuitBreaker
package/src/index.ts CHANGED
@@ -83,6 +83,11 @@ export type {
83
83
  RepeatOptions,
84
84
  } from './types.js';
85
85
  export { Worker } from './worker.js';
86
- export type { AckBatchOptions, Processor, WorkerOptions } from './worker-types.js';
86
+ export type {
87
+ AckBatchOptions,
88
+ Processor,
89
+ WorkerEventMap,
90
+ WorkerOptions,
91
+ } from './worker-types.js';
87
92
 
88
- export const __version__ = '0.1.6';
93
+ export const __version__ = '0.1.7';
@@ -3,6 +3,7 @@
3
3
  * monitoring and webhooks. Merged onto Queue.prototype by queue.ts.
4
4
  */
5
5
 
6
+ import { CommandError } from './errors.js';
6
7
  import { compact } from './frame.js';
7
8
  import type { Queue } from './queue.js';
8
9
  import { type JobOptions, jobPayload, wireJobOptions } from './types.js';
@@ -95,6 +96,10 @@ export const adminMethods = {
95
96
  repeat: SchedulerOptions,
96
97
  template: { name?: string; data?: unknown; opts?: JobOptions } = {}
97
98
  ): Promise<void> {
99
+ // Priority and deduplication of spawned jobs travel as TOP-LEVEL Cron
100
+ // fields (the handler reads cmd.priority/uniqueKey/dedup); inside
101
+ // jobOptions the server's CronJobOptions silently ignores them.
102
+ const dedup = template.opts?.deduplication;
98
103
  await this.call(
99
104
  compact({
100
105
  cmd: 'Cron',
@@ -103,9 +108,14 @@ export const adminMethods = {
103
108
  data: jobPayload(template.name ?? schedulerId, template.data ?? {}),
104
109
  schedule: repeat.pattern,
105
110
  repeatEvery: repeat.every,
111
+ priority: template.opts?.priority,
106
112
  timezone: repeat.tz,
107
113
  immediately: repeat.immediately,
108
114
  maxLimit: repeat.limit,
115
+ uniqueKey: dedup?.id,
116
+ dedup: dedup
117
+ ? compact({ ttl: dedup.ttl, extend: dedup.extend, replace: dedup.replace })
118
+ : undefined,
109
119
  skipMissedOnRestart: repeat.skipMissedOnRestart,
110
120
  skipIfNoWorker: repeat.skipIfNoWorker,
111
121
  preventOverlap: repeat.preventOverlap,
@@ -122,8 +132,11 @@ export const adminMethods = {
122
132
  try {
123
133
  const response = await this.call({ cmd: 'CronGet', name: schedulerId });
124
134
  return (response.cron ?? response.data ?? null) as Raw | null;
125
- } catch {
126
- return null; // not-found surfaces as a CommandError
135
+ } catch (err) {
136
+ // Only 'Cron job not found' maps to null; connection loss, timeouts and
137
+ // real server errors must surface, not masquerade as a missing scheduler.
138
+ if (err instanceof CommandError && /not found/i.test(err.message)) return null;
139
+ throw err;
127
140
  }
128
141
  },
129
142
 
@@ -3,10 +3,12 @@
3
3
  * mutations. Methods are merged onto Queue.prototype by queue.ts.
4
4
  */
5
5
 
6
+ import { UnrecoverableError } from './errors.js';
6
7
  import { compact } from './frame.js';
7
8
  import type { Queue } from './queue.js';
8
9
  import type { CountResponse, PausedResponse } from './responses.js';
9
10
  import type { JobStateName } from './types.js';
11
+ import { MAX_STACK_LINES } from './worker-types.js';
10
12
 
11
13
  type Ctx = Queue<unknown>;
12
14
 
@@ -72,9 +74,11 @@ export const controlMethods = {
72
74
  await this.call({ cmd: 'RetryCompleted', queue: this.name });
73
75
  return;
74
76
  }
75
- // `count` is accepted for API parity but not sent: the server has no
76
- // partial RetryDlq — it retries the whole DLQ (BullMQ semantics).
77
- await this.call({ cmd: 'RetryDlq', queue: this.name });
77
+ // `count` caps how many DLQ entries are retried (server >= 2.8.29). Older
78
+ // servers ignore the field and retry the whole DLQ — forward-compatible.
79
+ await this.call(
80
+ compact({ cmd: 'RetryDlq', queue: this.name, count: opts.count }) as { cmd: string }
81
+ );
78
82
  },
79
83
 
80
84
  async retryCompleted(this: Ctx, id?: string): Promise<void> {
@@ -129,14 +133,33 @@ export const controlMethods = {
129
133
  await this.call(compact({ cmd: 'ACK', id, result: returnValue, token }) as { cmd: string });
130
134
  },
131
135
 
136
+ /**
137
+ * Explicit failure path: mirrors the worker's FAIL wire so the stacktrace
138
+ * and the UnrecoverableError "do not retry" intent are persisted (#111
139
+ * silent-loss class), not just the message.
140
+ */
132
141
  async moveJobToFailed(
133
142
  this: Ctx,
134
143
  id: string,
135
144
  error: Error | string,
136
145
  token?: string
137
146
  ): Promise<void> {
138
- const message = typeof error === 'string' ? error : error.message;
139
- await this.call(compact({ cmd: 'FAIL', id, error: message, token }) as { cmd: string });
147
+ const err = typeof error === 'string' ? undefined : error;
148
+ const message = err ? err.message || err.name : (error as string);
149
+ // Keep the FIRST lines (message + throw site), like the worker path.
150
+ const stack = err
151
+ ? (err.stack ?? err.message).split('\n').slice(0, MAX_STACK_LINES)
152
+ : undefined;
153
+ await this.call(
154
+ compact({
155
+ cmd: 'FAIL',
156
+ id,
157
+ error: message,
158
+ stack,
159
+ unrecoverable: err instanceof UnrecoverableError ? true : undefined,
160
+ token,
161
+ }) as { cmd: string }
162
+ );
140
163
  },
141
164
  };
142
165
 
@@ -34,7 +34,9 @@ export const queryMethods = {
34
34
  const response = await this.call<JobResponse>({ cmd: 'GetJob', id });
35
35
  return response.job ? new Job<T>(response.job, this.connection) : null;
36
36
  } catch (err) {
37
- if (err instanceof CommandError) return null; // server: 'Job not found'
37
+ // Only the server's 'Job not found' maps to null; connection loss,
38
+ // timeouts and other server errors must surface.
39
+ if (err instanceof CommandError && /not found/i.test(err.message)) return null;
38
40
  throw err;
39
41
  }
40
42
  },
@@ -44,7 +46,7 @@ export const queryMethods = {
44
46
  const response = await this.call<JobResponse>({ cmd: 'GetJobByCustomId', customId });
45
47
  return response.job ? new Job<T>(response.job, this.connection) : null;
46
48
  } catch (err) {
47
- if (err instanceof CommandError) return null;
49
+ if (err instanceof CommandError && /not found/i.test(err.message)) return null;
48
50
  throw err;
49
51
  }
50
52
  },
@@ -7,9 +7,14 @@ import { randomBytes } from 'node:crypto';
7
7
  import { EventEmitter } from 'node:events';
8
8
  import { hostname } from 'node:os';
9
9
  import { Connection } from './connection.js';
10
- import { MAX_POLL_TIMEOUT_MS, sleep, type WorkerOptions } from './worker-types.js';
11
-
12
- export class WorkerBase extends EventEmitter {
10
+ import {
11
+ MAX_POLL_TIMEOUT_MS,
12
+ sleep,
13
+ type WorkerEventMap,
14
+ type WorkerOptions,
15
+ } from './worker-types.js';
16
+
17
+ export class WorkerBase<T = unknown, R = unknown> extends EventEmitter {
13
18
  readonly queue: string;
14
19
  readonly concurrency: number;
15
20
  readonly batchSize: number;
@@ -88,18 +93,40 @@ export class WorkerBase extends EventEmitter {
88
93
  * 'ready' is replayed to listeners attached after it fired: with autorun the
89
94
  * loop starts inside the constructor, so a plain once-only event could be
90
95
  * missed by `new Worker(...).on('ready', ...)` patterns.
96
+ *
97
+ * The overloads give the known worker events typed parameters (see
98
+ * WorkerEventMap); unknown event names keep the generic signature.
91
99
  */
92
- override on(event: string | symbol, listener: (...args: unknown[]) => void): this {
93
- if (event === 'ready' && this.readyFired) listener();
94
- return super.on(event, listener);
100
+ override on<E extends keyof WorkerEventMap<T, R>>(
101
+ event: E,
102
+ listener: WorkerEventMap<T, R>[E]
103
+ ): this;
104
+ override on(event: string | symbol, listener: (...args: unknown[]) => void): this;
105
+ override on(event: string | symbol, listener: (...args: never[]) => void): this {
106
+ if (event === 'ready' && this.readyFired) (listener as () => void)();
107
+ return super.on(event, listener as (...args: unknown[]) => void);
95
108
  }
96
109
 
97
- override once(event: string | symbol, listener: (...args: unknown[]) => void): this {
110
+ override once<E extends keyof WorkerEventMap<T, R>>(
111
+ event: E,
112
+ listener: WorkerEventMap<T, R>[E]
113
+ ): this;
114
+ override once(event: string | symbol, listener: (...args: unknown[]) => void): this;
115
+ override once(event: string | symbol, listener: (...args: never[]) => void): this {
98
116
  if (event === 'ready' && this.readyFired) {
99
- listener();
117
+ (listener as () => void)();
100
118
  return this;
101
119
  }
102
- return super.once(event, listener);
120
+ return super.once(event, listener as (...args: unknown[]) => void);
121
+ }
122
+
123
+ override off<E extends keyof WorkerEventMap<T, R>>(
124
+ event: E,
125
+ listener: WorkerEventMap<T, R>[E]
126
+ ): this;
127
+ override off(event: string | symbol, listener: (...args: unknown[]) => void): this;
128
+ override off(event: string | symbol, listener: (...args: never[]) => void): this {
129
+ return super.off(event, listener as (...args: unknown[]) => void);
103
130
  }
104
131
 
105
132
  /**
@@ -151,11 +178,16 @@ export class WorkerBase extends EventEmitter {
151
178
  this.emit('closed');
152
179
  }
153
180
 
154
- protected async safeCall(command: Record<string, unknown> & { cmd: string }): Promise<void> {
181
+ /** Dispatch a command, routing failures to 'error'. Returns whether the
182
+ * command reached the server — callers gate success-only side effects
183
+ * ('completed'/'failed' emits, counters) on it. */
184
+ protected async safeCall(command: Record<string, unknown> & { cmd: string }): Promise<boolean> {
155
185
  try {
156
186
  await this.connection.call(command);
187
+ return true;
157
188
  } catch (err) {
158
- this.emit('error', err);
189
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
190
+ return false;
159
191
  }
160
192
  }
161
193
 
@@ -6,6 +6,32 @@ import type { Observability } from './observability.js';
6
6
 
7
7
  export type Processor<T = unknown, R = unknown> = (job: Job<T>) => R | Promise<R>;
8
8
 
9
+ /**
10
+ * Typed Worker event map: listeners registered via `on`/`once`/`off` for these
11
+ * names get typed job/result/error parameters in strict mode. Unknown event
12
+ * names fall back to a generic `(...args: unknown[])` overload.
13
+ */
14
+ export interface WorkerEventMap<T = unknown, R = unknown> {
15
+ /** Worker registered and pull loop started (replayed to late listeners). */
16
+ ready: () => void;
17
+ /** A job was pulled and handed to the processor. */
18
+ active: (job: Job<T>) => void;
19
+ /** Processor resolved AND the ACK reached the server. */
20
+ completed: (job: Job<T>, result: R) => void;
21
+ /** Processor threw AND the FAIL reached the server. */
22
+ failed: (job: Job<T>, error: Error) => void;
23
+ /** job.updateProgress() was called from the processor. */
24
+ progress: (job: Job<T>, progress: number) => void;
25
+ /** Connection/command error (pull loop, ACK/FAIL, heartbeat, ...). */
26
+ error: (error: Error) => void;
27
+ /** The queue went from busy to empty (no active jobs, nothing pulled). */
28
+ drained: () => void;
29
+ /** Cooperative cancel was requested for a locally active job. */
30
+ cancelled: (info: { jobId: string; reason: string }) => void;
31
+ /** close() finished. */
32
+ closed: () => void;
33
+ }
34
+
9
35
  export interface AckBatchOptions {
10
36
  /** Batch ACKs into ACKB round-trips (default false; opt-in for throughput). */
11
37
  enabled?: boolean;
package/src/worker.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  type WorkerOptions,
22
22
  } from './worker-types.js';
23
23
 
24
- export class Worker<T = unknown, R = unknown> extends WorkerBase {
24
+ export class Worker<T = unknown, R = unknown> extends WorkerBase<T, R> {
25
25
  private readonly processor: Processor<T, R>;
26
26
  private readonly ackBatcher: AckBatcher | null;
27
27
 
@@ -44,8 +44,8 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
44
44
  run(): void {
45
45
  if (this.running || this.closedFlag) return;
46
46
  this.running = true;
47
- this.loopPromise = this.loop().catch((err) => {
48
- this.emit('error', err);
47
+ this.loopPromise = this.loop().catch((err: unknown) => {
48
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
49
49
  });
50
50
  }
51
51
 
@@ -68,7 +68,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
68
68
  await this.pollOnce();
69
69
  backoffIdx = 0;
70
70
  } catch (err) {
71
- this.emit('error', err);
71
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
72
72
  if (err instanceof ConnectionClosedError || err instanceof CommandTimeoutError) {
73
73
  const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
74
74
  backoffIdx += 1;
@@ -143,7 +143,7 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
143
143
  onSettled: (err) => {
144
144
  this.finishJob(job.id);
145
145
  if (err) {
146
- this.emit('error', err);
146
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
147
147
  } else {
148
148
  this.processed += 1;
149
149
  this.emit('completed', job, result);
@@ -152,21 +152,24 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
152
152
  });
153
153
  return;
154
154
  }
155
- this.processed += 1;
156
- await this.safeCall(
155
+ const acked = await this.safeCall(
157
156
  compact({ cmd: 'ACK', id: job.id, token, result: result ?? undefined }) as { cmd: string }
158
157
  );
159
158
  // Free the slot BEFORE emitting: a throwing 'completed' listener must
160
159
  // not leak the active slot (same rationale as the batched path).
161
160
  this.finishJob(job.id);
162
- this.emit('completed', job, result);
161
+ // Mirror the batched path: a failed ACK already emitted 'error' — do not
162
+ // also claim completion (no 'completed', no processed++).
163
+ if (acked) {
164
+ this.processed += 1;
165
+ this.emit('completed', job, result);
166
+ }
163
167
  } catch (err) {
164
- this.failedCount += 1;
165
168
  const error = err instanceof Error ? err : new Error(String(err));
166
169
  // Keep the FIRST lines: in a JS stack the message + throw site lead, so
167
170
  // slice(0,N) preserves them (slice(-N) would drop them on long stacks).
168
171
  const stack = (error.stack ?? error.message).split('\n').slice(0, MAX_STACK_LINES);
169
- await this.safeCall(
172
+ const failed = await this.safeCall(
170
173
  compact({
171
174
  cmd: 'FAIL',
172
175
  id: job.id,
@@ -177,7 +180,12 @@ export class Worker<T = unknown, R = unknown> extends WorkerBase {
177
180
  }) as { cmd: string }
178
181
  );
179
182
  this.finishJob(job.id);
180
- this.emit('failed', job, error);
183
+ // Same asymmetry guard as the ACK path: if the FAIL never reached the
184
+ // server, only 'error' fires (the lock expiry will retry the job).
185
+ if (failed) {
186
+ this.failedCount += 1;
187
+ this.emit('failed', job, error);
188
+ }
181
189
  }
182
190
  }
183
191