dflockd-client 1.9.2 → 1.10.1

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
@@ -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,
@@ -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);
@@ -577,8 +579,8 @@ 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") {
@@ -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,