bunqueue-client 0.1.5 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +46 -3
  3. package/dist/ack-batcher.d.ts +28 -0
  4. package/dist/ack-batcher.js +67 -0
  5. package/dist/backpressure.d.ts +22 -0
  6. package/dist/backpressure.js +39 -0
  7. package/dist/bunqueue/bunqueue.js +12 -2
  8. package/dist/connection-pool.d.ts +30 -0
  9. package/dist/connection-pool.js +60 -0
  10. package/dist/connection-types.d.ts +21 -1
  11. package/dist/connection.d.ts +12 -3
  12. package/dist/connection.js +39 -3
  13. package/dist/flow-types.d.ts +2 -1
  14. package/dist/flow.js +12 -3
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +3 -1
  17. package/dist/job.d.ts +2 -2
  18. package/dist/observability.d.ts +95 -0
  19. package/dist/observability.js +110 -0
  20. package/dist/queue-admin.js +16 -2
  21. package/dist/queue-control.d.ts +5 -0
  22. package/dist/queue-control.js +27 -9
  23. package/dist/queue-query.js +13 -21
  24. package/dist/queue.d.ts +13 -4
  25. package/dist/queue.js +14 -7
  26. package/dist/responses.d.ts +79 -0
  27. package/dist/responses.js +10 -0
  28. package/dist/worker-base.d.ts +15 -3
  29. package/dist/worker-base.js +15 -7
  30. package/dist/worker-types.d.ts +43 -1
  31. package/dist/worker.d.ts +5 -1
  32. package/dist/worker.js +59 -14
  33. package/package.json +3 -1
  34. package/src/ack-batcher.ts +76 -0
  35. package/src/backpressure.ts +40 -0
  36. package/src/bunqueue/bunqueue.ts +12 -1
  37. package/src/connection-pool.ts +71 -0
  38. package/src/connection-types.ts +23 -1
  39. package/src/connection.ts +42 -6
  40. package/src/flow-types.ts +2 -1
  41. package/src/flow.ts +24 -16
  42. package/src/index.ts +33 -2
  43. package/src/job.ts +3 -3
  44. package/src/observability.ts +158 -0
  45. package/src/queue-admin.ts +15 -2
  46. package/src/queue-control.ts +34 -11
  47. package/src/queue-query.ts +39 -29
  48. package/src/queue.ts +30 -11
  49. package/src/responses.ts +96 -0
  50. package/src/worker-base.ts +49 -11
  51. package/src/worker-types.ts +42 -1
  52. package/src/worker.ts +63 -16
package/CHANGELOG.md ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ All notable changes to `bunqueue-client` (TypeScript SDK) are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
50
+ ## [0.1.6] - 2026-07-09
51
+
52
+ Enterprise-grade hardening. All additive and backward-compatible; defaults are
53
+ unchanged (observability is silent, backpressure unbounded, ACK batching off).
54
+
55
+ ### Added
56
+
57
+ - **Observability.** Every `Connection`/`Queue`/`Worker`/`FlowProducer` accepts
58
+ an injectable `logger` and an `onTelemetry` sink (zero hard deps — bridge it to
59
+ OpenTelemetry/Prometheus yourself). `TelemetryEvent` is a typed union covering
60
+ per-command latency, connect/disconnect/reconnect, auth and backpressure.
61
+ `Connection` is now an `EventEmitter` emitting `connect` / `disconnect` /
62
+ `reconnect_scheduled`. Ships `noopLogger` (default) and `consoleLogger`.
63
+ - **Backpressure.** `maxInFlight` bounds concurrent in-flight commands; callers
64
+ park until a slot frees instead of growing memory unbounded under load.
65
+ - **ACK batching.** Opt-in `Worker({ ackBatch: { enabled: true } })` coalesces
66
+ completed-job ACKs into `ACKB` round-trips for higher throughput; a job stays
67
+ active (lock renewed) until its batch is confirmed.
68
+ - **Connection pool.** `Queue({ poolSize: N })` fans producer commands across N
69
+ round-robin connections (`ConnectionPool`, producer-side; workers stay single-
70
+ connection by design).
71
+ - **Typed responses.** `call<R>()` is generic over the exported response shapes
72
+ (`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, …); internal
73
+ `as Record<string, unknown>` casts removed across the query/control/flow paths.
74
+
75
+ ### CI
76
+
77
+ - GitHub Actions runs both SDK suites on every `sdk/`/`src/` change (TypeScript
78
+ on Bun + Node + Deno, Python 3.10/3.12); an npm release workflow publishes
79
+ with build provenance, gated on the e2e suite.
80
+
81
+ ## [0.1.5] - 2026-07-08
82
+
83
+ Protocol-coherence audit against the bunqueue server. Every fix ships with a
84
+ RED→GREEN repro in `tests/e2e-audit-fixes.ts`.
85
+
86
+ ### Fixed
87
+
88
+ - **addBulk dropped the custom job id.** PUSHB entries are `JobInput`
89
+ (`customId`), not the single-PUSH `jobId` the server renames — the batch
90
+ path now renames `jobId`→`customId`, so `getJobByCustomId` and idempotent
91
+ bulk ingest work. (H1)
92
+ - **Half-open link wedge.** Enable TCP keepalive (~15s idle) and tear down the
93
+ socket after 3 consecutive command timeouts so the next call reconnects,
94
+ instead of wedging until the OS abandons the writes. The teardown is
95
+ generation-guarded so a stale-connection timeout can't abort a fresh
96
+ reconnect. (H2)
97
+ - **getFlow crashed on a missing job.** A missing root/child now yields `null`
98
+ and is skipped (partial tree) instead of throwing; the catch is narrowed to
99
+ `'not found'` so real server errors still surface, and a `visited` set guards
100
+ against cycles now that depth defaults to unlimited. (H4)
101
+ - **waitForJob returned `undefined` on timeout.** It now rejects on
102
+ non-completion: a `failed` job throws `CommandError`, otherwise
103
+ `CommandTimeoutError` — the `completed` flag is no longer ignored. (M1)
104
+ - **getWaitingCount / getWaiting counted prioritized jobs.** Now waiting-only,
105
+ matching BullMQ and the Python SDK. (M2)
106
+
107
+ ### Changed
108
+
109
+ - `addJobLog(id, message, level?)` accepts an optional level;
110
+ `getJobLogs` formats entries as `[level] message` (no longer drops the level).
111
+ - `retryJobs`: the dead `count` field is no longer sent on the wire (the server
112
+ has no partial RetryDlq; `count` is accepted only for API parity).
113
+ - Worker `FAIL` keeps the leading stack lines (`slice(0, N)`) so the error
114
+ message is preserved on long stacks.
115
+
116
+ ## [0.1.4] - initial published release
117
+
118
+ - Cross-runtime (Node/Bun/Deno) TCP client: `Queue`, `Worker`, `FlowProducer`,
119
+ `Bunqueue` Simple Mode, msgpack wire protocol, TLS, auth, pipelining.
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 |
@@ -272,6 +272,49 @@ const queue = new Queue('emails', {
272
272
 
273
273
  Authentication uses server side tokens (`AUTH_TOKENS`). Transport security uses native TLS, with support for system certificate authorities, a custom CA bundle, or disabled verification for development environments.
274
274
 
275
+ ## Observability
276
+
277
+ Inject a logger and a telemetry sink to bridge the client into your stack. There are no hard dependencies — you wire OpenTelemetry, Prometheus or your own logger. Defaults are silent.
278
+
279
+ ```typescript
280
+ import { Queue, consoleLogger, type TelemetryEvent } from 'bunqueue-client';
281
+
282
+ const queue = new Queue('emails', {
283
+ logger: consoleLogger('info'), // or your own { debug, info, warn, error }
284
+ onTelemetry: (e: TelemetryEvent) => {
285
+ // per-command latency, connect/disconnect/reconnect, auth, backpressure
286
+ if (e.type === 'command') metrics.observe(e.cmd, e.durationMs, e.ok);
287
+ },
288
+ });
289
+
290
+ // Connection is an EventEmitter for lifecycle hooks:
291
+ queue.connection.on('reconnect_scheduled', (i) => log.warn('reconnecting', i));
292
+ queue.connection.on('disconnect', () => log.warn('link down'));
293
+ ```
294
+
295
+ ## Throughput and resilience
296
+
297
+ ```typescript
298
+ // Bound in-flight commands (backpressure) — parks callers instead of growing memory:
299
+ const queue = new Queue('emails', { maxInFlight: 10_000 });
300
+
301
+ // Fan producer commands across N connections (round-robin, producer-side):
302
+ const pooled = new Queue('emails', { poolSize: 4 });
303
+
304
+ // Batch worker ACKs into ACKB round-trips (opt-in) for high-volume consumers:
305
+ const worker = new Worker('emails', process, {
306
+ ackBatch: { enabled: true, maxSize: 50, maxDelayMs: 5 },
307
+ });
308
+ ```
309
+
310
+ Always attach a `worker.on('error', …)` listener: per Node `EventEmitter` semantics an unhandled `error` event throws. The worker frees each job's concurrency slot before emitting, so even a throwing listener cannot degrade throughput — but the error itself is yours to observe.
311
+
312
+ Half-open links are detected via TCP keepalive and a consecutive-timeout teardown, so a silently dropped connection (cloud LB/NAT idle drop) recovers in seconds rather than minutes.
313
+
314
+ ## Typed responses
315
+
316
+ `connection.call<R>()` and `queue.call<R>()` are generic over the exported response shapes (`JobResponse`, `PulledJobsResponse`, `JobCountsResponse`, `WaitJobResponse`, …), so raw command access is fully typed without casting.
317
+
275
318
  ## API surface
276
319
 
277
320
  | Area | Capabilities |
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Batches completed-job ACKs into ACKB round-trips for higher throughput.
3
+ *
4
+ * Opt-in (see WorkerOptions.ackBatch). Items flush when the buffer reaches
5
+ * `maxSize` or after `maxDelayMs`, whichever comes first; `flush()` drains the
6
+ * rest on shutdown. Each item's `onSettled` fires after the batch is acked
7
+ * (or errored), so the worker keeps a job "active" — and its lock renewed —
8
+ * until the server has confirmed the ack.
9
+ */
10
+ import type { Connection } from './connection.js';
11
+ export interface AckItem {
12
+ id: string;
13
+ token: string;
14
+ result: unknown;
15
+ /** Called once the batch settles: `err` set on failure, undefined on ack. */
16
+ onSettled: (err?: unknown) => void;
17
+ }
18
+ export declare class AckBatcher {
19
+ private readonly connection;
20
+ private readonly maxSize;
21
+ private readonly maxDelayMs;
22
+ private buffer;
23
+ private timer;
24
+ constructor(connection: Connection, maxSize: number, maxDelayMs: number);
25
+ add(item: AckItem): void;
26
+ /** Send the buffered ACKs as one ACKB (no-op when empty). */
27
+ flush(): Promise<void>;
28
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Batches completed-job ACKs into ACKB round-trips for higher throughput.
3
+ *
4
+ * Opt-in (see WorkerOptions.ackBatch). Items flush when the buffer reaches
5
+ * `maxSize` or after `maxDelayMs`, whichever comes first; `flush()` drains the
6
+ * rest on shutdown. Each item's `onSettled` fires after the batch is acked
7
+ * (or errored), so the worker keeps a job "active" — and its lock renewed —
8
+ * until the server has confirmed the ack.
9
+ */
10
+ import { compact } from './frame.js';
11
+ export class AckBatcher {
12
+ connection;
13
+ maxSize;
14
+ maxDelayMs;
15
+ buffer = [];
16
+ timer = null;
17
+ constructor(connection, maxSize, maxDelayMs) {
18
+ this.connection = connection;
19
+ this.maxSize = maxSize;
20
+ this.maxDelayMs = maxDelayMs;
21
+ }
22
+ add(item) {
23
+ this.buffer.push(item);
24
+ if (this.buffer.length >= this.maxSize) {
25
+ void this.flush();
26
+ }
27
+ else if (!this.timer) {
28
+ this.timer = setTimeout(() => void this.flush(), this.maxDelayMs);
29
+ this.timer.unref?.(); // don't keep the process alive for a pending flush
30
+ }
31
+ }
32
+ /** Send the buffered ACKs as one ACKB (no-op when empty). */
33
+ async flush() {
34
+ if (this.timer) {
35
+ clearTimeout(this.timer);
36
+ this.timer = null;
37
+ }
38
+ if (this.buffer.length === 0)
39
+ return;
40
+ const batch = this.buffer;
41
+ this.buffer = [];
42
+ // Capture the wire outcome first; settle callbacks run OUTSIDE the try so
43
+ // a throwing callback can neither be re-invoked with an error (double
44
+ // settle) nor starve the remaining items of their callback.
45
+ let error;
46
+ try {
47
+ await this.connection.call(compact({
48
+ cmd: 'ACKB',
49
+ ids: batch.map((b) => b.id),
50
+ tokens: batch.map((b) => b.token),
51
+ results: batch.map((b) => b.result),
52
+ }));
53
+ }
54
+ catch (err) {
55
+ error = err ?? new Error('ACKB failed');
56
+ }
57
+ for (const item of batch) {
58
+ try {
59
+ item.onSettled(error);
60
+ }
61
+ catch {
62
+ // A callback error (e.g. an unhandled 'error' emit in the worker)
63
+ // must never prevent the other items from settling.
64
+ }
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Backpressure gate: bounds the number of in-flight commands on a connection.
3
+ *
4
+ * When the in-flight count reaches `max`, `acquire()` parks the caller until a
5
+ * slot frees (a pending command settles). This turns unbounded memory growth
6
+ * under a fast producer / slow server into cooperative flow control. `max <= 0`
7
+ * disables the gate (unbounded, the default).
8
+ */
9
+ export declare class Backpressure {
10
+ private readonly max;
11
+ private readonly onWait?;
12
+ private readonly waiters;
13
+ constructor(max: number, onWait?: (() => void) | undefined);
14
+ /** Resolve immediately if under the limit, else park until a slot frees. */
15
+ acquire(inFlight: number): Promise<void> | void;
16
+ /** Release one parked caller (call when a pending command settles). */
17
+ release(): void;
18
+ /** Release every parked caller (call on teardown so none hang forever). */
19
+ clear(): void;
20
+ /** Number of callers currently parked. */
21
+ get waiting(): number;
22
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Backpressure gate: bounds the number of in-flight commands on a connection.
3
+ *
4
+ * When the in-flight count reaches `max`, `acquire()` parks the caller until a
5
+ * slot frees (a pending command settles). This turns unbounded memory growth
6
+ * under a fast producer / slow server into cooperative flow control. `max <= 0`
7
+ * disables the gate (unbounded, the default).
8
+ */
9
+ export class Backpressure {
10
+ max;
11
+ onWait;
12
+ waiters = [];
13
+ constructor(max, onWait) {
14
+ this.max = max;
15
+ this.onWait = onWait;
16
+ }
17
+ /** Resolve immediately if under the limit, else park until a slot frees. */
18
+ acquire(inFlight) {
19
+ if (this.max <= 0 || inFlight < this.max)
20
+ return;
21
+ this.onWait?.();
22
+ return new Promise((resolve) => {
23
+ this.waiters.push(resolve);
24
+ });
25
+ }
26
+ /** Release one parked caller (call when a pending command settles). */
27
+ release() {
28
+ this.waiters.shift()?.();
29
+ }
30
+ /** Release every parked caller (call on teardown so none hang forever). */
31
+ clear() {
32
+ while (this.waiters.length > 0)
33
+ this.waiters.shift()?.();
34
+ }
35
+ /** Number of callers currently parked. */
36
+ get waiting() {
37
+ return this.waiters.length;
38
+ }
39
+ }
@@ -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)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A round-robin pool of TCP connections for producer-side throughput.
3
+ *
4
+ * Each `call()` is dispatched to the next connection, so independent commands
5
+ * (PUSH, queries, control) fan out across N sockets instead of head-of-lining
6
+ * on one. Use it for {@link Queue} / {@link FlowProducer}; NOT for a Worker,
7
+ * whose pull→ack flow is bound to a single connection's lock ownership and
8
+ * reconnect/registration lifecycle.
9
+ *
10
+ * Satisfies {@link ConnectionLike}, so it is a drop-in for a single Connection.
11
+ * Lifecycle events from every member connection are re-emitted here.
12
+ */
13
+ import { EventEmitter } from 'node:events';
14
+ import type { Command, ConnectionLike, ConnectionOptions, Response } from './connection-types.js';
15
+ export declare class ConnectionPool extends EventEmitter implements ConnectionLike {
16
+ private readonly connections;
17
+ private cursor;
18
+ constructor(size: number, options?: ConnectionOptions);
19
+ /** How many connections back this pool. */
20
+ get size(): number;
21
+ private next;
22
+ call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
23
+ ping(): Promise<boolean>;
24
+ connect(): Promise<void>;
25
+ close(): void;
26
+ /** True while at least one member connection is up. */
27
+ get isConnected(): boolean;
28
+ /** Representative generation (first connection); pools are producer-side. */
29
+ get generation(): number;
30
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A round-robin pool of TCP connections for producer-side throughput.
3
+ *
4
+ * Each `call()` is dispatched to the next connection, so independent commands
5
+ * (PUSH, queries, control) fan out across N sockets instead of head-of-lining
6
+ * on one. Use it for {@link Queue} / {@link FlowProducer}; NOT for a Worker,
7
+ * whose pull→ack flow is bound to a single connection's lock ownership and
8
+ * reconnect/registration lifecycle.
9
+ *
10
+ * Satisfies {@link ConnectionLike}, so it is a drop-in for a single Connection.
11
+ * Lifecycle events from every member connection are re-emitted here.
12
+ */
13
+ import { EventEmitter } from 'node:events';
14
+ import { Connection } from './connection.js';
15
+ import { LIFECYCLE_EVENTS } from './observability.js';
16
+ export class ConnectionPool extends EventEmitter {
17
+ connections;
18
+ cursor = 0;
19
+ constructor(size, options = {}) {
20
+ super();
21
+ const n = Math.max(1, Math.floor(size));
22
+ this.connections = Array.from({ length: n }, () => {
23
+ const conn = new Connection(options);
24
+ for (const event of LIFECYCLE_EVENTS) {
25
+ conn.on(event, (payload) => this.emit(event, payload));
26
+ }
27
+ return conn;
28
+ });
29
+ }
30
+ /** How many connections back this pool. */
31
+ get size() {
32
+ return this.connections.length;
33
+ }
34
+ next() {
35
+ const conn = this.connections[this.cursor];
36
+ this.cursor = (this.cursor + 1) % this.connections.length;
37
+ return conn;
38
+ }
39
+ call(command, timeoutMs) {
40
+ return this.next().call(command, timeoutMs);
41
+ }
42
+ ping() {
43
+ return this.connections[0].ping();
44
+ }
45
+ async connect() {
46
+ await Promise.all(this.connections.map((conn) => conn.connect()));
47
+ }
48
+ close() {
49
+ for (const conn of this.connections)
50
+ conn.close();
51
+ }
52
+ /** True while at least one member connection is up. */
53
+ get isConnected() {
54
+ return this.connections.some((conn) => conn.isConnected);
55
+ }
56
+ /** Representative generation (first connection); pools are producer-side. */
57
+ get generation() {
58
+ return this.connections[0].generation;
59
+ }
60
+ }
@@ -1,15 +1,21 @@
1
1
  /** Connection option and message types. */
2
+ import type { Observability } from './observability.js';
2
3
  export type TlsOption = boolean | {
3
4
  caFile?: string;
4
5
  rejectUnauthorized?: boolean;
5
6
  } | undefined;
6
- export interface ConnectionOptions {
7
+ export interface ConnectionOptions extends Observability {
7
8
  host?: string;
8
9
  port?: number;
9
10
  token?: string;
10
11
  tls?: TlsOption;
11
12
  connectTimeoutMs?: number;
12
13
  commandTimeoutMs?: number;
14
+ /**
15
+ * Max in-flight commands before {@link Connection.call} applies backpressure
16
+ * (awaits a free slot). 0 / undefined = unbounded. Bounds memory under load.
17
+ */
18
+ maxInFlight?: number;
13
19
  }
14
20
  export type Command = Record<string, unknown> & {
15
21
  cmd: string;
@@ -17,6 +23,20 @@ export type Command = Record<string, unknown> & {
17
23
  export type Response = Record<string, unknown> & {
18
24
  ok: boolean;
19
25
  };
26
+ /**
27
+ * The connection surface consumers (Queue, FlowProducer, Job) depend on.
28
+ * Both {@link Connection} and a round-robin ConnectionPool satisfy it, so a
29
+ * pool can be dropped in transparently for producer-side throughput.
30
+ */
31
+ export interface ConnectionLike {
32
+ call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
33
+ ping(): Promise<boolean>;
34
+ connect(): Promise<void>;
35
+ close(): void;
36
+ readonly isConnected: boolean;
37
+ readonly generation: number;
38
+ on(event: string, listener: (...args: unknown[]) => void): this;
39
+ }
20
40
  export interface Pending {
21
41
  resolve: (response: Response) => void;
22
42
  reject: (error: Error) => void;
@@ -5,10 +5,17 @@
5
5
  * pipelining (many in-flight commands per socket). Uses only `node:`
6
6
  * builtins (net/tls), which Node, Bun and Deno all support.
7
7
  */
8
+ import { EventEmitter } from 'node:events';
8
9
  import type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
9
10
  export type { Command, ConnectionOptions, Response, TlsOption } from './connection-types.js';
10
- /** A single pipelined TCP connection to a bunqueue server. */
11
- export declare class Connection {
11
+ /**
12
+ * A single pipelined TCP connection to a bunqueue server.
13
+ *
14
+ * Emits typed lifecycle events for observability: `connect`, `disconnect` and
15
+ * `reconnect_scheduled` (payloads mirror the telemetry events). Attach a
16
+ * telemetry sink via the `onTelemetry` option for per-command latency metrics.
17
+ */
18
+ export declare class Connection extends EventEmitter {
12
19
  readonly host: string;
13
20
  readonly port: number;
14
21
  readonly token: string | undefined;
@@ -27,6 +34,8 @@ export declare class Connection {
27
34
  private nextAttemptAt;
28
35
  private readonly maxCommandTimeouts;
29
36
  private consecutiveTimeouts;
37
+ private readonly telemetry;
38
+ private readonly backpressure;
30
39
  constructor(options?: ConnectionOptions);
31
40
  get isConnected(): boolean;
32
41
  /**
@@ -42,7 +51,7 @@ export declare class Connection {
42
51
  * Send a command and await its response. Rejects with CommandError when
43
52
  * the server answers ok=false. Reconnects lazily if the link was lost.
44
53
  */
45
- call(command: Command, timeoutMs?: number): Promise<Response>;
54
+ call<R = Response>(command: Command, timeoutMs?: number): Promise<R>;
46
55
  /** Ping the server; returns true when it answers pong. */
47
56
  ping(): Promise<boolean>;
48
57
  /** Protocol negotiation; returns server name/version/protocolVersion. */