dflockd-client 1.9.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,8 +103,8 @@ without blocking. If the lock is contended, `enqueue()` returns `"queued"` and
103
103
  | Option | Type | Default | Description |
104
104
  |--------------------|-------------------------------|--------------------------|--------------------------------------------------|
105
105
  | `key` | `string` | *(required)* | Lock name |
106
- | `acquireTimeoutS` | `number` | `10` | Seconds to wait for the lock before giving up |
107
- | `leaseTtlS` | `number` | server default | Server-side lease duration in seconds |
106
+ | `acquireTimeoutS` | `number` | `10` | Seconds to wait for the lock before giving up (integer ≥ 0) |
107
+ | `leaseTtlS` | `number` | server default | Server-side lease duration in seconds (integer ≥ 1) |
108
108
  | `servers` | `Array<[string, number]>` | `[["127.0.0.1", 6388]]` | List of `[host, port]` pairs |
109
109
  | `shardingStrategy` | `ShardingStrategy` | `stableHashShard` | Function mapping `(key, numServers)` to a server index |
110
110
  | `host` | `string` | `127.0.0.1` | Server host *(deprecated — use `servers`)* |
@@ -233,9 +233,9 @@ try {
233
233
  | Option | Type | Default | Description |
234
234
  |--------------------|-------------------------------|--------------------------|--------------------------------------------------|
235
235
  | `key` | `string` | *(required)* | Semaphore name |
236
- | `limit` | `number` | *(required)* | Max concurrent holders |
237
- | `acquireTimeoutS` | `number` | `10` | Seconds to wait before giving up |
238
- | `leaseTtlS` | `number` | server default | Server-side lease duration in seconds |
236
+ | `limit` | `number` | *(required)* | Max concurrent holders (integer ≥ 1) |
237
+ | `acquireTimeoutS` | `number` | `10` | Seconds to wait before giving up (integer ≥ 0) |
238
+ | `leaseTtlS` | `number` | server default | Server-side lease duration in seconds (integer ≥ 1) |
239
239
  | `servers` | `Array<[string, number]>` | `[["127.0.0.1", 6388]]` | List of `[host, port]` pairs |
240
240
  | `shardingStrategy` | `ShardingStrategy` | `stableHashShard` | Function mapping `(key, numServers)` to a server index |
241
241
  | `host` | `string` | `127.0.0.1` | Server host *(deprecated — use `servers`)* |
@@ -356,7 +356,7 @@ import * as net from "net";
356
356
  import {
357
357
  acquire, enqueue, waitForLock, renew, release,
358
358
  semAcquire, semEnqueue, semWaitForLock, semRenew, semRelease,
359
- stats,
359
+ publish, stats,
360
360
  } from "dflockd-client";
361
361
 
362
362
  const sock = net.createConnection({ host: "127.0.0.1", port: 6388 });
@@ -383,9 +383,97 @@ if (semResult.status === "queued") {
383
383
  const semGranted = await semWaitForLock(sock, "sem-key", 10); // { token, lease }
384
384
  }
385
385
 
386
+ // Signal — publish only (no subscription on raw sockets)
387
+ const delivered = await publish(sock, "events.user.login", '{"user":"alice"}');
388
+
386
389
  sock.destroy();
387
390
  ```
388
391
 
392
+ ## Signals
393
+
394
+ Publish/subscribe messaging with NATS-style pattern matching and optional
395
+ queue groups for load-balanced consumption.
396
+
397
+ ### `SignalConnection` (recommended)
398
+
399
+ ```ts
400
+ import { SignalConnection } from "dflockd-client";
401
+
402
+ const conn = await SignalConnection.connect();
403
+
404
+ // Subscribe to signals
405
+ conn.onSignal((sig) => {
406
+ console.log(`${sig.channel}: ${sig.payload}`);
407
+ });
408
+
409
+ await conn.listen("events.>");
410
+
411
+ // ... later
412
+ await conn.unlisten("events.>");
413
+ conn.close();
414
+ ```
415
+
416
+ ### Publish a signal
417
+
418
+ ```ts
419
+ import { SignalConnection } from "dflockd-client";
420
+
421
+ const conn = await SignalConnection.connect();
422
+ const delivered = await conn.emit("events.user.login", '{"user":"alice"}');
423
+ console.log(`delivered to ${delivered} listener(s)`);
424
+ conn.close();
425
+ ```
426
+
427
+ ### Pattern matching
428
+
429
+ Subscription patterns support NATS-style wildcards:
430
+
431
+ - `*` matches exactly one dot-separated token (`events.*.login` matches
432
+ `events.user.login` but not `events.user.admin.login`)
433
+ - `>` matches one or more trailing tokens (`events.>` matches
434
+ `events.user.login` and `events.order.created`)
435
+
436
+ Publishing always uses literal channel names (no wildcards).
437
+
438
+ ### Queue groups
439
+
440
+ Listeners can join a named queue group. Within a group, each signal is
441
+ delivered to exactly one member (round-robin), enabling load-balanced
442
+ processing. Non-grouped listeners always receive every matching signal.
443
+
444
+ ```ts
445
+ // Worker 1
446
+ await conn.listen("tasks.>", "workers");
447
+
448
+ // Worker 2 (same group — only one receives each signal)
449
+ await conn2.listen("tasks.>", "workers");
450
+ ```
451
+
452
+ ### Async iterator
453
+
454
+ `SignalConnection` implements `Symbol.asyncIterator`, so you can consume
455
+ signals with a `for-await-of` loop:
456
+
457
+ ```ts
458
+ const conn = await SignalConnection.connect();
459
+ await conn.listen("events.>");
460
+
461
+ for await (const sig of conn) {
462
+ console.log(sig.channel, sig.payload);
463
+ }
464
+ // Loop ends when conn.close() is called
465
+ ```
466
+
467
+ ### Signal connection options
468
+
469
+ | Option | Type | Default | Description |
470
+ |--------------------|-------------------------|----------------|-------------------------------------|
471
+ | `host` | `string` | `127.0.0.1` | Server host |
472
+ | `port` | `number` | `6388` | Server port |
473
+ | `tls` | `tls.ConnectionOptions` | `undefined` | TLS options; pass `{}` for system CA|
474
+ | `auth` | `string` | `undefined` | Auth token |
475
+ | `connectTimeoutMs` | `number` | `undefined` | TCP connect timeout in milliseconds |
476
+
389
477
  ## License
390
478
 
391
479
  MIT
package/dist/client.cjs CHANGED
@@ -35,8 +35,10 @@ __export(client_exports, {
35
35
  DistributedLock: () => DistributedLock,
36
36
  DistributedSemaphore: () => DistributedSemaphore,
37
37
  LockError: () => LockError,
38
+ SignalConnection: () => SignalConnection,
38
39
  acquire: () => acquire,
39
40
  enqueue: () => enqueue,
41
+ publish: () => publish,
40
42
  release: () => release,
41
43
  renew: () => renew,
42
44
  semAcquire: () => semAcquire,
@@ -261,14 +263,14 @@ function stableHashShard(key, numServers) {
261
263
  }
262
264
  async function protoAcquire(sock, cmd, label, key, acquireTimeoutS, leaseTtlS, limit) {
263
265
  validateKey(key);
264
- if (!Number.isFinite(acquireTimeoutS) || acquireTimeoutS < 0) {
265
- throw new LockError("acquireTimeoutS must be a finite number >= 0");
266
+ if (!Number.isInteger(acquireTimeoutS) || acquireTimeoutS < 0) {
267
+ throw new LockError("acquireTimeoutS must be an integer >= 0");
266
268
  }
267
269
  if (limit != null && (!Number.isInteger(limit) || limit < 1)) {
268
270
  throw new LockError("limit must be an integer >= 1");
269
271
  }
270
- if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
271
- throw new LockError("leaseTtlS must be a finite number > 0");
272
+ if (leaseTtlS != null && (!Number.isInteger(leaseTtlS) || leaseTtlS < 1)) {
273
+ throw new LockError("leaseTtlS must be an integer >= 1");
272
274
  }
273
275
  const parts = [acquireTimeoutS];
274
276
  if (limit != null) parts.push(limit);
@@ -291,8 +293,8 @@ async function protoAcquire(sock, cmd, label, key, acquireTimeoutS, leaseTtlS, l
291
293
  async function protoRenew(sock, cmd, label, key, token, leaseTtlS) {
292
294
  validateKey(key);
293
295
  validateToken(token);
294
- if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
295
- throw new LockError("leaseTtlS must be a finite number > 0");
296
+ if (leaseTtlS != null && (!Number.isInteger(leaseTtlS) || leaseTtlS < 1)) {
297
+ throw new LockError("leaseTtlS must be an integer >= 1");
296
298
  }
297
299
  const arg = leaseTtlS == null ? token : `${token} ${leaseTtlS}`;
298
300
  await writeAll(sock, encodeLines(cmd, key, arg));
@@ -308,8 +310,8 @@ async function protoEnqueue(sock, cmd, label, key, leaseTtlS, limit) {
308
310
  if (limit != null && (!Number.isInteger(limit) || limit < 1)) {
309
311
  throw new LockError("limit must be an integer >= 1");
310
312
  }
311
- if (leaseTtlS != null && (!Number.isFinite(leaseTtlS) || leaseTtlS <= 0)) {
312
- throw new LockError("leaseTtlS must be a finite number > 0");
313
+ if (leaseTtlS != null && (!Number.isInteger(leaseTtlS) || leaseTtlS < 1)) {
314
+ throw new LockError("leaseTtlS must be an integer >= 1");
313
315
  }
314
316
  const parts = [];
315
317
  if (limit != null) parts.push(limit);
@@ -331,8 +333,8 @@ async function protoEnqueue(sock, cmd, label, key, leaseTtlS, limit) {
331
333
  }
332
334
  async function protoWait(sock, cmd, label, key, waitTimeoutS) {
333
335
  validateKey(key);
334
- if (!Number.isFinite(waitTimeoutS) || waitTimeoutS < 0) {
335
- throw new LockError("waitTimeoutS must be a finite number >= 0");
336
+ if (!Number.isInteger(waitTimeoutS) || waitTimeoutS < 0) {
337
+ throw new LockError("waitTimeoutS must be an integer >= 0");
336
338
  }
337
339
  await writeAll(sock, encodeLines(cmd, key, String(waitTimeoutS)));
338
340
  const resp = await readline(sock);
@@ -439,11 +441,11 @@ var DistributedPrimitive = class {
439
441
  this.onLockLost = opts.onLockLost;
440
442
  this.connectTimeoutMs = opts.connectTimeoutMs;
441
443
  this.socketTimeoutMs = opts.socketTimeoutMs;
442
- if (!Number.isFinite(this.acquireTimeoutS) || this.acquireTimeoutS < 0) {
443
- throw new LockError("acquireTimeoutS must be a finite number >= 0");
444
+ if (!Number.isInteger(this.acquireTimeoutS) || this.acquireTimeoutS < 0) {
445
+ throw new LockError("acquireTimeoutS must be an integer >= 0");
444
446
  }
445
- if (this.leaseTtlS != null && (!Number.isFinite(this.leaseTtlS) || this.leaseTtlS <= 0)) {
446
- throw new LockError("leaseTtlS must be a finite number > 0");
447
+ if (this.leaseTtlS != null && (!Number.isInteger(this.leaseTtlS) || this.leaseTtlS < 1)) {
448
+ throw new LockError("leaseTtlS must be an integer >= 1");
447
449
  }
448
450
  if (opts.servers) {
449
451
  if (opts.servers.length === 0) {
@@ -510,8 +512,8 @@ var DistributedPrimitive = class {
510
512
  this.close();
511
513
  }
512
514
  this.closed = false;
513
- this.sock = await this.openConnection();
514
515
  try {
516
+ this.sock = await this.openConnection();
515
517
  this.suspendSocketTimeout(this.sock);
516
518
  const result = await this.doAcquire(this.sock);
517
519
  this.restoreSocketTimeout(this.sock);
@@ -546,7 +548,7 @@ var DistributedPrimitive = class {
546
548
  if (this.renewInFlight) {
547
549
  await Promise.race([
548
550
  this.renewInFlight,
549
- new Promise((r) => setTimeout(r, 5e3))
551
+ new Promise((r) => setTimeout(r, 5e3).unref())
550
552
  ]);
551
553
  this.stopRenew();
552
554
  }
@@ -577,16 +579,16 @@ var DistributedPrimitive = class {
577
579
  this.close();
578
580
  }
579
581
  this.closed = false;
580
- this.sock = await this.openConnection();
581
582
  try {
583
+ this.sock = await this.openConnection();
582
584
  this.suspendSocketTimeout(this.sock);
583
585
  const result = await this.doEnqueue(this.sock);
584
586
  if (result.status === "acquired") {
585
587
  this.token = result.token;
586
588
  this.lease = result.lease ?? 0;
589
+ this.restoreSocketTimeout(this.sock);
587
590
  this.startRenew();
588
591
  }
589
- this.restoreSocketTimeout(this.sock);
590
592
  return result.status;
591
593
  } catch (err) {
592
594
  this.close();
@@ -764,6 +766,308 @@ var DistributedSemaphore = class extends DistributedPrimitive {
764
766
  return semRenew(sock, this.key, token, this.leaseTtlS);
765
767
  }
766
768
  };
769
+ function validatePayload(payload) {
770
+ if (payload === "") {
771
+ throw new LockError("payload must not be empty");
772
+ }
773
+ if (/[\0\n\r]/.test(payload)) {
774
+ throw new LockError(
775
+ "payload must not contain NUL, newline, or carriage return characters"
776
+ );
777
+ }
778
+ }
779
+ async function publish(sock, channel, payload) {
780
+ validateKey(channel);
781
+ validatePayload(payload);
782
+ await writeAll(sock, encodeLines("signal", channel, payload));
783
+ const resp = await readline(sock);
784
+ if (!resp.startsWith("ok ")) {
785
+ throw new LockError(`signal failed: '${resp}'`);
786
+ }
787
+ const n = parseInt(resp.split(" ")[1], 10);
788
+ if (!Number.isFinite(n)) {
789
+ throw new LockError(`signal: bad delivery count: '${resp}'`);
790
+ }
791
+ return n;
792
+ }
793
+ var SignalConnection = class _SignalConnection {
794
+ sock;
795
+ buf = "";
796
+ responseResolve = null;
797
+ responseReject = null;
798
+ cmdQueue = [];
799
+ cmdBusy = false;
800
+ signalListeners = [];
801
+ _closed = false;
802
+ /**
803
+ * Wrap an existing socket as a SignalConnection.
804
+ *
805
+ * The socket should already be connected (and authenticated, if needed).
806
+ * Prefer `SignalConnection.connect()` for convenience.
807
+ */
808
+ constructor(sock) {
809
+ this.sock = sock;
810
+ const leftover = _readlineBuf.get(sock);
811
+ if (leftover) {
812
+ this.buf = leftover;
813
+ _readlineBuf.delete(sock);
814
+ }
815
+ sock.on("data", (chunk) => this.onData(chunk));
816
+ sock.on("error", (err) => this.onSocketError(err));
817
+ sock.on("close", () => this.onSocketEnd());
818
+ sock.on("end", () => this.onSocketEnd());
819
+ this.drainLines();
820
+ }
821
+ /** Connect to a dflockd server and return a SignalConnection. */
822
+ static async connect(opts) {
823
+ const host = opts?.host ?? DEFAULT_HOST;
824
+ const port = opts?.port ?? DEFAULT_PORT;
825
+ const sock = await connect2(
826
+ host,
827
+ port,
828
+ opts?.tls,
829
+ opts?.auth,
830
+ opts?.connectTimeoutMs
831
+ );
832
+ return new _SignalConnection(sock);
833
+ }
834
+ /**
835
+ * Subscribe to signals matching `pattern`.
836
+ *
837
+ * Patterns support NATS-style wildcards:
838
+ * - `*` matches exactly one dot-separated token
839
+ * - `>` matches one or more trailing tokens
840
+ *
841
+ * If `group` is provided, the subscription joins a queue group where
842
+ * signals are load-balanced (round-robin) among group members.
843
+ */
844
+ async listen(pattern, group) {
845
+ validateKey(pattern);
846
+ if (group != null && /[\0\n\r]/.test(group)) {
847
+ throw new LockError(
848
+ "group must not contain NUL, newline, or carriage return characters"
849
+ );
850
+ }
851
+ const resp = await this.sendCmd("listen", pattern, group ?? "");
852
+ if (resp !== "ok") {
853
+ throw new LockError(`listen failed: '${resp}'`);
854
+ }
855
+ }
856
+ /**
857
+ * Unsubscribe from signals matching `pattern`.
858
+ * The `pattern` and `group` must match a previous `listen()` call.
859
+ */
860
+ async unlisten(pattern, group) {
861
+ validateKey(pattern);
862
+ if (group != null && /[\0\n\r]/.test(group)) {
863
+ throw new LockError(
864
+ "group must not contain NUL, newline, or carriage return characters"
865
+ );
866
+ }
867
+ const resp = await this.sendCmd("unlisten", pattern, group ?? "");
868
+ if (resp !== "ok") {
869
+ throw new LockError(`unlisten failed: '${resp}'`);
870
+ }
871
+ }
872
+ /**
873
+ * Publish a signal on a literal channel (no wildcards).
874
+ * Returns the number of listeners that received the signal.
875
+ */
876
+ async emit(channel, payload) {
877
+ validateKey(channel);
878
+ validatePayload(payload);
879
+ const resp = await this.sendCmd("signal", channel, payload);
880
+ if (!resp.startsWith("ok ")) {
881
+ throw new LockError(`signal failed: '${resp}'`);
882
+ }
883
+ const n = parseInt(resp.split(" ")[1], 10);
884
+ if (!Number.isFinite(n)) {
885
+ throw new LockError(`signal: bad delivery count: '${resp}'`);
886
+ }
887
+ return n;
888
+ }
889
+ /** Register a listener for incoming signals. */
890
+ onSignal(listener) {
891
+ this.signalListeners.push(listener);
892
+ }
893
+ /** Remove a previously registered signal listener. */
894
+ offSignal(listener) {
895
+ const idx = this.signalListeners.indexOf(listener);
896
+ if (idx >= 0) this.signalListeners.splice(idx, 1);
897
+ }
898
+ /**
899
+ * Async iterator that yields signals as they arrive.
900
+ * Terminates when the connection closes.
901
+ *
902
+ * ```ts
903
+ * for await (const sig of conn) {
904
+ * console.log(sig.channel, sig.payload);
905
+ * }
906
+ * ```
907
+ */
908
+ async *[Symbol.asyncIterator]() {
909
+ const buffer = [];
910
+ let waiter = null;
911
+ const listener = (sig) => {
912
+ buffer.push(sig);
913
+ if (waiter) {
914
+ const w = waiter;
915
+ waiter = null;
916
+ w();
917
+ }
918
+ };
919
+ const onEnd = () => {
920
+ if (waiter) {
921
+ const w = waiter;
922
+ waiter = null;
923
+ w();
924
+ }
925
+ };
926
+ this.onSignal(listener);
927
+ this.sock.once("close", onEnd);
928
+ try {
929
+ while (true) {
930
+ if (buffer.length > 0) {
931
+ yield buffer.shift();
932
+ continue;
933
+ }
934
+ if (this._closed) break;
935
+ await new Promise((resolve) => {
936
+ waiter = resolve;
937
+ });
938
+ }
939
+ while (buffer.length > 0) {
940
+ yield buffer.shift();
941
+ }
942
+ } finally {
943
+ this.offSignal(listener);
944
+ this.sock.removeListener("close", onEnd);
945
+ }
946
+ }
947
+ /** Close the connection (idempotent). */
948
+ close() {
949
+ if (this._closed) return;
950
+ this._closed = true;
951
+ this.sock.destroy();
952
+ this.rejectPending(new LockError("connection closed"));
953
+ }
954
+ /** Whether the connection is closed. */
955
+ get isClosed() {
956
+ return this._closed;
957
+ }
958
+ // ---- internals ----
959
+ onData(chunk) {
960
+ this.buf += chunk.toString("utf-8");
961
+ if (this.buf.length > MAX_LINE_LENGTH * 2) {
962
+ this.buf = "";
963
+ this._closed = true;
964
+ this.sock.destroy();
965
+ this.rejectPending(
966
+ new LockError("server response exceeded maximum buffer size")
967
+ );
968
+ return;
969
+ }
970
+ this.drainLines();
971
+ }
972
+ drainLines() {
973
+ let idx;
974
+ while ((idx = this.buf.indexOf("\n")) !== -1) {
975
+ const line = this.buf.slice(0, idx).replace(/\r$/, "");
976
+ this.buf = this.buf.slice(idx + 1);
977
+ this.handleLine(line);
978
+ }
979
+ }
980
+ handleLine(line) {
981
+ if (line.startsWith("sig ")) {
982
+ const rest = line.slice(4);
983
+ const spaceIdx = rest.indexOf(" ");
984
+ if (spaceIdx < 0) return;
985
+ const sig = {
986
+ channel: rest.slice(0, spaceIdx),
987
+ payload: rest.slice(spaceIdx + 1)
988
+ };
989
+ for (const listener of [...this.signalListeners]) {
990
+ try {
991
+ listener(sig);
992
+ } catch {
993
+ }
994
+ }
995
+ return;
996
+ }
997
+ if (this.responseResolve) {
998
+ const resolve = this.responseResolve;
999
+ this.responseResolve = null;
1000
+ this.responseReject = null;
1001
+ resolve(line);
1002
+ }
1003
+ }
1004
+ onSocketError(err) {
1005
+ this._closed = true;
1006
+ this.rejectPending(err);
1007
+ }
1008
+ onSocketEnd() {
1009
+ this._closed = true;
1010
+ this.rejectPending(new LockError("server closed connection"));
1011
+ }
1012
+ rejectPending(err) {
1013
+ if (this.responseReject) {
1014
+ const reject = this.responseReject;
1015
+ this.responseResolve = null;
1016
+ this.responseReject = null;
1017
+ reject(err);
1018
+ }
1019
+ }
1020
+ sendCmd(cmd, key, arg) {
1021
+ return new Promise((outerResolve, outerReject) => {
1022
+ const execute = () => {
1023
+ if (this._closed) {
1024
+ this.cmdBusy = false;
1025
+ this.dequeueNext();
1026
+ outerReject(new LockError("connection closed"));
1027
+ return;
1028
+ }
1029
+ let settled = false;
1030
+ this.responseResolve = (line) => {
1031
+ if (settled) return;
1032
+ settled = true;
1033
+ this.cmdBusy = false;
1034
+ this.dequeueNext();
1035
+ outerResolve(line);
1036
+ };
1037
+ this.responseReject = (err) => {
1038
+ if (settled) return;
1039
+ settled = true;
1040
+ this.cmdBusy = false;
1041
+ this.dequeueNext();
1042
+ outerReject(err);
1043
+ };
1044
+ this.sock.write(encodeLines(cmd, key, arg), (err) => {
1045
+ if (err && !settled) {
1046
+ settled = true;
1047
+ this.responseResolve = null;
1048
+ this.responseReject = null;
1049
+ this.cmdBusy = false;
1050
+ this.dequeueNext();
1051
+ outerReject(err);
1052
+ }
1053
+ });
1054
+ };
1055
+ if (this.cmdBusy) {
1056
+ this.cmdQueue.push(execute);
1057
+ } else {
1058
+ this.cmdBusy = true;
1059
+ execute();
1060
+ }
1061
+ });
1062
+ }
1063
+ dequeueNext() {
1064
+ const next = this.cmdQueue.shift();
1065
+ if (next) {
1066
+ this.cmdBusy = true;
1067
+ next();
1068
+ }
1069
+ }
1070
+ };
767
1071
  // Annotate the CommonJS export names for ESM import in node:
768
1072
  0 && (module.exports = {
769
1073
  AcquireTimeoutError,
@@ -771,8 +1075,10 @@ var DistributedSemaphore = class extends DistributedPrimitive {
771
1075
  DistributedLock,
772
1076
  DistributedSemaphore,
773
1077
  LockError,
1078
+ SignalConnection,
774
1079
  acquire,
775
1080
  enqueue,
1081
+ publish,
776
1082
  release,
777
1083
  renew,
778
1084
  semAcquire,