dflockd-client 1.10.1 → 1.10.3

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
@@ -2,26 +2,13 @@
2
2
 
3
3
  TypeScript client for the [dflockd](https://github.com/mtingers/dflockd) distributed lock daemon.
4
4
 
5
- **[Documentation](https://mtingers.github.io/dflockd-client-ts/)**
6
-
7
- ## Installation
5
+ ## Install
8
6
 
9
7
  ```bash
10
8
  npm install dflockd-client
11
9
  ```
12
10
 
13
- ## Usage
14
-
15
- Start the [dflockd](https://github.com/mtingers/dflockd) server:
16
-
17
- ```bash
18
- dflockd
19
- ```
20
-
21
- ### `withLock` (recommended)
22
-
23
- The simplest way to use a lock. Acquires, runs your callback, and releases
24
- automatically — even if the callback throws.
11
+ ## Quick example
25
12
 
26
13
  ```ts
27
14
  import { DistributedLock } from "dflockd-client";
@@ -34,445 +21,18 @@ await lock.withLock(async () => {
34
21
  // lock is released
35
22
  ```
36
23
 
37
- ### Manual `acquire` / `release`
38
-
39
- ```ts
40
- import { DistributedLock } from "dflockd-client";
41
-
42
- const lock = new DistributedLock({
43
- key: "my-resource",
44
- acquireTimeoutS: 10, // wait up to 10 s (default)
45
- leaseTtlS: 20, // server-side lease duration
46
- });
47
-
48
- const ok = await lock.acquire(); // true on success, false on timeout
49
- if (!ok) {
50
- console.error("could not acquire lock");
51
- process.exit(1);
52
- }
53
-
54
- try {
55
- // critical section — lock is held and auto-renewed
56
- } finally {
57
- await lock.release();
58
- }
59
- ```
60
-
61
- ### Two-phase lock (enqueue / wait)
62
-
63
- Split acquisition into two steps so you can notify an external system
64
- (webhook, database, queue) between joining the FIFO queue and blocking.
65
-
66
- ```ts
67
- import { DistributedLock } from "dflockd-client";
68
-
69
- const lock = new DistributedLock({
70
- key: "my-resource",
71
- acquireTimeoutS: 10,
72
- leaseTtlS: 20,
73
- });
74
-
75
- // Step 1: join the queue (non-blocking, returns immediately)
76
- const status = await lock.enqueue(); // "acquired" or "queued"
77
-
78
- // Step 2: do something between enqueue and blocking
79
- console.log(`enqueue status: ${status}`);
80
- notifyExternalSystem(status);
81
-
82
- // Step 3: block until the lock is granted (or timeout)
83
- const granted = await lock.wait(10); // true on success, false on timeout
84
- if (!granted) {
85
- console.error("timed out waiting for lock");
86
- process.exit(1);
87
- }
88
-
89
- try {
90
- // critical section — lock is held and auto-renewed
91
- } finally {
92
- await lock.release();
93
- }
94
- ```
95
-
96
- If the lock is free when `enqueue()` is called, it returns `"acquired"` and
97
- the lock is already held (fast path). `wait()` then returns `true` immediately
98
- without blocking. If the lock is contended, `enqueue()` returns `"queued"` and
99
- `wait()` blocks until the lock is granted or the timeout expires.
100
-
101
- ### Options
102
-
103
- | Option | Type | Default | Description |
104
- |--------------------|-------------------------------|--------------------------|--------------------------------------------------|
105
- | `key` | `string` | *(required)* | Lock name |
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
- | `servers` | `Array<[string, number]>` | `[["127.0.0.1", 6388]]` | List of `[host, port]` pairs |
109
- | `shardingStrategy` | `ShardingStrategy` | `stableHashShard` | Function mapping `(key, numServers)` to a server index |
110
- | `host` | `string` | `127.0.0.1` | Server host *(deprecated — use `servers`)* |
111
- | `port` | `number` | `6388` | Server port *(deprecated — use `servers`)* |
112
- | `renewRatio` | `number` | `0.5` | Renew at `lease * ratio` seconds (e.g. 50% of TTL)|
113
- | `tls` | `tls.ConnectionOptions` | `undefined` | TLS options; pass `{}` for default system CA |
114
- | `auth` | `string` | `undefined` | Auth token for servers started with `--auth-token` |
115
- | `onLockLost` | `(key: string, token: string) => void` | `undefined` | Called when background lease renewal fails and the lock is lost |
116
- | `connectTimeoutMs` | `number` | `undefined` | TCP connect timeout in milliseconds |
117
- | `socketTimeoutMs` | `number` | `undefined` | Socket idle timeout in milliseconds |
24
+ ## Features
118
25
 
119
- ### Multi-server sharding
120
-
121
- Distribute locks across multiple dflockd instances. Each key is consistently
122
- routed to the same server using CRC32-based hashing (matching Python's
123
- `zlib.crc32`).
124
-
125
- ```ts
126
- import { DistributedLock } from "dflockd-client";
127
-
128
- const lock = new DistributedLock({
129
- key: "my-resource",
130
- servers: [
131
- ["10.0.0.1", 6388],
132
- ["10.0.0.2", 6388],
133
- ["10.0.0.3", 6388],
134
- ],
135
- });
136
-
137
- await lock.withLock(async () => {
138
- // critical section — routed to a consistent server based on key
139
- });
140
- ```
141
-
142
- You can supply a custom sharding strategy:
143
-
144
- ```ts
145
- import { DistributedLock, ShardingStrategy } from "dflockd-client";
146
-
147
- const roundRobin: ShardingStrategy = (_key, numServers) => {
148
- return Math.floor(Math.random() * numServers);
149
- };
150
-
151
- const lock = new DistributedLock({
152
- key: "my-resource",
153
- servers: [
154
- ["10.0.0.1", 6388],
155
- ["10.0.0.2", 6388],
156
- ],
157
- shardingStrategy: roundRobin,
158
- });
159
- ```
160
-
161
- ## Semaphore
162
-
163
- A semaphore allows up to N concurrent holders per key (instead of exactly 1
164
- for a lock). The `DistributedSemaphore` API mirrors `DistributedLock`.
165
-
166
- ### `withLock` (recommended)
167
-
168
- ```ts
169
- import { DistributedSemaphore } from "dflockd-client";
170
-
171
- const sem = new DistributedSemaphore({ key: "my-resource", limit: 5 });
172
-
173
- await sem.withLock(async () => {
174
- // critical section — up to 5 concurrent holders
175
- });
176
- // slot is released
177
- ```
178
-
179
- ### Manual `acquire` / `release`
180
-
181
- ```ts
182
- import { DistributedSemaphore } from "dflockd-client";
183
-
184
- const sem = new DistributedSemaphore({
185
- key: "my-resource",
186
- limit: 5,
187
- acquireTimeoutS: 10,
188
- leaseTtlS: 20,
189
- });
190
-
191
- const ok = await sem.acquire(); // true on success, false on timeout
192
- if (!ok) {
193
- console.error("could not acquire semaphore slot");
194
- process.exit(1);
195
- }
196
-
197
- try {
198
- // critical section — slot is held and auto-renewed
199
- } finally {
200
- await sem.release();
201
- }
202
- ```
203
-
204
- ### Two-phase semaphore (enqueue / wait)
205
-
206
- ```ts
207
- import { DistributedSemaphore } from "dflockd-client";
208
-
209
- const sem = new DistributedSemaphore({
210
- key: "my-resource",
211
- limit: 5,
212
- acquireTimeoutS: 10,
213
- });
214
-
215
- const status = await sem.enqueue(); // "acquired" or "queued"
216
- console.log(`enqueue status: ${status}`);
217
-
218
- const granted = await sem.wait(10); // true on success, false on timeout
219
- if (!granted) {
220
- console.error("timed out waiting for semaphore slot");
221
- process.exit(1);
222
- }
223
-
224
- try {
225
- // critical section — slot is held and auto-renewed
226
- } finally {
227
- await sem.release();
228
- }
229
- ```
230
-
231
- ### Semaphore options
232
-
233
- | Option | Type | Default | Description |
234
- |--------------------|-------------------------------|--------------------------|--------------------------------------------------|
235
- | `key` | `string` | *(required)* | Semaphore name |
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
- | `servers` | `Array<[string, number]>` | `[["127.0.0.1", 6388]]` | List of `[host, port]` pairs |
240
- | `shardingStrategy` | `ShardingStrategy` | `stableHashShard` | Function mapping `(key, numServers)` to a server index |
241
- | `host` | `string` | `127.0.0.1` | Server host *(deprecated — use `servers`)* |
242
- | `port` | `number` | `6388` | Server port *(deprecated — use `servers`)* |
243
- | `renewRatio` | `number` | `0.5` | Renew at `lease * ratio` seconds (e.g. 50% of TTL)|
244
- | `tls` | `tls.ConnectionOptions` | `undefined` | TLS options; pass `{}` for default system CA |
245
- | `auth` | `string` | `undefined` | Auth token for servers started with `--auth-token` |
246
- | `onLockLost` | `(key: string, token: string) => void` | `undefined` | Called when background lease renewal fails and the slot is lost |
247
- | `connectTimeoutMs` | `number` | `undefined` | TCP connect timeout in milliseconds |
248
- | `socketTimeoutMs` | `number` | `undefined` | Socket idle timeout in milliseconds |
249
-
250
- ## Stats
251
-
252
- Query server runtime statistics (active connections, held locks, semaphores,
253
- idle entries).
254
-
255
- ```ts
256
- import { stats } from "dflockd-client";
257
-
258
- const s = await stats();
259
- console.log(`connections: ${s.connections}`);
260
- console.log(`locks held: ${s.locks.length}`);
261
- console.log(`semaphores active: ${s.semaphores.length}`);
262
-
263
- for (const lock of s.locks) {
264
- console.log(` ${lock.key} owner=${lock.owner_conn_id} expires_in=${lock.lease_expires_in_s}s waiters=${lock.waiters}`);
265
- }
266
-
267
- for (const sem of s.semaphores) {
268
- console.log(` ${sem.key} holders=${sem.holders}/${sem.limit} waiters=${sem.waiters}`);
269
- }
270
- ```
271
-
272
- Pass `{ host, port }` to query a specific server:
273
-
274
- ```ts
275
- const s = await stats({ host: "10.0.0.1", port: 6388 });
276
- ```
277
-
278
- ## TLS
279
-
280
- When the dflockd server is started with `--tls-cert` and `--tls-key`, all
281
- connections must use TLS. Pass a `tls` option (accepting Node's
282
- `tls.ConnectionOptions`) to enable TLS on the client:
283
-
284
- ```ts
285
- import { DistributedLock, DistributedSemaphore, stats } from "dflockd-client";
286
-
287
- // TLS with default system CA validation
288
- const lock = new DistributedLock({ key: "my-resource", tls: {} });
289
-
290
- // Self-signed CA
291
- import * as fs from "fs";
292
- const lock2 = new DistributedLock({
293
- key: "my-resource",
294
- tls: { ca: fs.readFileSync("ca.pem") },
295
- });
296
-
297
- // Semaphore over TLS
298
- const sem = new DistributedSemaphore({ key: "my-resource", limit: 5, tls: {} });
299
-
300
- // Stats over TLS
301
- const s = await stats({ host: "10.0.0.1", port: 6388, tls: {} });
302
- ```
303
-
304
- ## Authentication
305
-
306
- When the dflockd server is started with `--auth-token`, every connection must
307
- authenticate before sending any other command. Pass the `auth` option to enable
308
- token-based authentication:
309
-
310
- ```ts
311
- import { DistributedLock, DistributedSemaphore, stats } from "dflockd-client";
312
-
313
- // Lock with auth
314
- const lock = new DistributedLock({ key: "my-resource", auth: "my-secret-token" });
315
-
316
- // Semaphore with auth
317
- const sem = new DistributedSemaphore({ key: "my-resource", limit: 5, auth: "my-secret-token" });
318
-
319
- // Stats with auth
320
- const s = await stats({ host: "10.0.0.1", port: 6388, auth: "my-secret-token" });
321
-
322
- // Combined with TLS
323
- const secureLock = new DistributedLock({
324
- key: "my-resource",
325
- tls: {},
326
- auth: "my-secret-token",
327
- });
328
- ```
329
-
330
- If authentication fails, an `AuthError` (a subclass of `LockError`) is thrown.
331
-
332
- ### Error handling
333
-
334
- ```ts
335
- import { DistributedLock, AcquireTimeoutError, AuthError, LockError } from "dflockd-client";
336
-
337
- try {
338
- await lock.withLock(async () => { /* ... */ });
339
- } catch (err) {
340
- if (err instanceof AcquireTimeoutError) {
341
- // lock could not be acquired within acquireTimeoutS
342
- } else if (err instanceof AuthError) {
343
- // authentication failed (bad or missing token)
344
- } else if (err instanceof LockError) {
345
- // protocol-level error (bad token, server disconnect, etc.)
346
- }
347
- }
348
- ```
349
-
350
- ### Low-level functions
351
-
352
- For cases where you manage the socket yourself:
353
-
354
- ```ts
355
- import * as net from "net";
356
- import {
357
- acquire, enqueue, waitForLock, renew, release,
358
- semAcquire, semEnqueue, semWaitForLock, semRenew, semRelease,
359
- publish, stats,
360
- } from "dflockd-client";
361
-
362
- const sock = net.createConnection({ host: "127.0.0.1", port: 6388 });
363
-
364
- // Lock — single-phase
365
- const { token, lease } = await acquire(sock, "my-key", 10);
366
- const remaining = await renew(sock, "my-key", token, 60);
367
- await release(sock, "my-key", token);
368
-
369
- // Lock — two-phase
370
- const result = await enqueue(sock, "another-key"); // { status, token, lease }
371
- if (result.status === "queued") {
372
- const granted = await waitForLock(sock, "another-key", 10); // { token, lease }
373
- }
374
-
375
- // Semaphore — single-phase (limit = 5)
376
- const sem = await semAcquire(sock, "sem-key", 10, 5); // { token, lease }
377
- const semRemaining = await semRenew(sock, "sem-key", sem.token, 60);
378
- await semRelease(sock, "sem-key", sem.token);
379
-
380
- // Semaphore — two-phase
381
- const semResult = await semEnqueue(sock, "sem-key", 5); // { status, token, lease }
382
- if (semResult.status === "queued") {
383
- const semGranted = await semWaitForLock(sock, "sem-key", 10); // { token, lease }
384
- }
385
-
386
- // Signal — publish only (no subscription on raw sockets)
387
- const delivered = await publish(sock, "events.user.login", '{"user":"alice"}');
388
-
389
- sock.destroy();
390
- ```
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
- ```
26
+ - **Locks and semaphores** with automatic lease renewal
27
+ - **Two-phase locking** — enqueue then wait, with hooks between steps
28
+ - **Signal pub/sub** NATS-style pattern matching and queue groups
29
+ - **Multi-server sharding** CRC32-based consistent hashing
30
+ - **TLS and authentication** support
31
+ - **Low-level API** for direct socket control
466
32
 
467
- ### Signal connection options
33
+ ## Documentation
468
34
 
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 |
35
+ Full docs at **[mtingers.github.io/dflockd-client-ts](https://mtingers.github.io/dflockd-client-ts/)**
476
36
 
477
37
  ## License
478
38
 
package/dist/client.cjs CHANGED
@@ -52,6 +52,7 @@ __export(client_exports, {
52
52
  });
53
53
  module.exports = __toCommonJS(client_exports);
54
54
  var net = __toESM(require("net"), 1);
55
+ var import_string_decoder = require("string_decoder");
55
56
  var tls = __toESM(require("tls"), 1);
56
57
  var DEFAULT_HOST = "127.0.0.1";
57
58
  var DEFAULT_PORT = 6388;
@@ -117,6 +118,7 @@ function parseLease(value, fallback = 30) {
117
118
  return Number.isFinite(n) && n >= 0 ? n : fallback;
118
119
  }
119
120
  var _readlineBuf = /* @__PURE__ */ new WeakMap();
121
+ var _readlineDecoder = /* @__PURE__ */ new WeakMap();
120
122
  var MAX_LINE_LENGTH = 1024 * 1024;
121
123
  function readline(sock) {
122
124
  return new Promise((resolve, reject) => {
@@ -128,8 +130,14 @@ function readline(sock) {
128
130
  resolve(line);
129
131
  return;
130
132
  }
133
+ let decoder = _readlineDecoder.get(sock);
134
+ if (!decoder) {
135
+ decoder = new import_string_decoder.StringDecoder("utf8");
136
+ _readlineDecoder.set(sock, decoder);
137
+ }
138
+ const dec = decoder;
131
139
  const onData = (chunk) => {
132
- buf += chunk.toString("utf-8");
140
+ buf += dec.write(chunk);
133
141
  const idx = buf.indexOf("\n");
134
142
  if (idx !== -1) {
135
143
  cleanup();
@@ -139,22 +147,26 @@ function readline(sock) {
139
147
  } else if (buf.length > MAX_LINE_LENGTH) {
140
148
  cleanup();
141
149
  _readlineBuf.delete(sock);
150
+ _readlineDecoder.delete(sock);
142
151
  reject(new LockError("server response exceeded maximum line length"));
143
152
  }
144
153
  };
145
154
  const onError = (err) => {
146
155
  cleanup();
147
156
  _readlineBuf.delete(sock);
157
+ _readlineDecoder.delete(sock);
148
158
  reject(err);
149
159
  };
150
160
  const onClose = () => {
151
161
  cleanup();
152
162
  _readlineBuf.delete(sock);
163
+ _readlineDecoder.delete(sock);
153
164
  reject(new LockError("server closed connection"));
154
165
  };
155
166
  const onEnd = () => {
156
167
  cleanup();
157
168
  _readlineBuf.delete(sock);
169
+ _readlineDecoder.delete(sock);
158
170
  reject(new LockError("server closed connection"));
159
171
  };
160
172
  const cleanup = () => {
@@ -165,6 +177,7 @@ function readline(sock) {
165
177
  };
166
178
  if (sock.readableEnded || sock.destroyed) {
167
179
  _readlineBuf.delete(sock);
180
+ _readlineDecoder.delete(sock);
168
181
  reject(new LockError("server closed connection"));
169
182
  return;
170
183
  }
@@ -700,11 +713,11 @@ var DistributedPrimitive = class {
700
713
  this.renewInFlight = null;
701
714
  if (this.closed || this.token !== savedToken) return;
702
715
  const elapsed = Date.now() - start;
703
- const interval2 = Math.max(1, this.lease * this.renewRatio) * 1e3;
716
+ const interval2 = Math.max(0.1, this.lease * this.renewRatio) * 1e3;
704
717
  this.renewTimer = setTimeout(loop, Math.max(0, interval2 - elapsed));
705
718
  this.renewTimer.unref();
706
719
  };
707
- const interval = Math.max(1, this.lease * this.renewRatio) * 1e3;
720
+ const interval = Math.max(0.1, this.lease * this.renewRatio) * 1e3;
708
721
  this.renewTimer = setTimeout(loop, interval);
709
722
  this.renewTimer.unref();
710
723
  }
@@ -793,6 +806,7 @@ async function publish(sock, channel, payload) {
793
806
  var SignalConnection = class _SignalConnection {
794
807
  sock;
795
808
  buf = "";
809
+ decoder;
796
810
  responseResolve = null;
797
811
  responseReject = null;
798
812
  cmdQueue = [];
@@ -807,6 +821,9 @@ var SignalConnection = class _SignalConnection {
807
821
  */
808
822
  constructor(sock) {
809
823
  this.sock = sock;
824
+ const prevDecoder = _readlineDecoder.get(sock);
825
+ this.decoder = prevDecoder ?? new import_string_decoder.StringDecoder("utf8");
826
+ _readlineDecoder.delete(sock);
810
827
  const leftover = _readlineBuf.get(sock);
811
828
  if (leftover) {
812
829
  this.buf = leftover;
@@ -957,7 +974,7 @@ var SignalConnection = class _SignalConnection {
957
974
  }
958
975
  // ---- internals ----
959
976
  onData(chunk) {
960
- this.buf += chunk.toString("utf-8");
977
+ this.buf += this.decoder.write(chunk);
961
978
  if (this.buf.length > MAX_LINE_LENGTH * 2) {
962
979
  this.buf = "";
963
980
  this._closed = true;