procmesh-js 0.1.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/src/cli.js ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { Broker, Client } = require('./index');
7
+
8
+ function parseArgs(argv) {
9
+ const out = {};
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === '--socket' || a === '-s') out.socket = argv[++i];
13
+ else if (a === '--name' || a === '-n') out.name = argv[++i];
14
+ else if (a === '--token' || a === '-t') out.token = argv[++i];
15
+ else if (a === '--json') out.json = true;
16
+ else if (a === '--persist-dir') out.persistDir = argv[++i];
17
+ else if (a === '--no-persist') out.noPersist = true;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ /** True when `dir` resolves to somewhere inside the OS temp directory. */
23
+ function isUnderTmp(dir) {
24
+ if (!dir) return false;
25
+ const tmp = path.resolve(os.tmpdir());
26
+ const rel = path.relative(tmp, path.resolve(dir));
27
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
28
+ }
29
+
30
+ function usage() {
31
+ // eslint-disable-next-line no-console
32
+ console.log(`procmesh — shared in-memory cache & IPC for Node processes
33
+
34
+ Options: [--name <name>] [--socket <addr>] [--token <secret>]
35
+
36
+ Usage:
37
+ procmesh serve run a foreground broker
38
+ procmesh status check if a broker is up
39
+ procmesh stats print broker metrics ([--json])
40
+ procmesh stop ask the broker to shut down
41
+ `);
42
+ }
43
+
44
+ async function main() {
45
+ const [cmd, ...rest] = process.argv.slice(2);
46
+ const args = parseArgs(rest);
47
+
48
+ switch (cmd) {
49
+ case 'serve': {
50
+ // Persistence: on by default for a long-lived `serve` broker (it's the production entry
51
+ // point); disable with --no-persist. Dir from --persist-dir or PROCMESH_PERSIST_DIR.
52
+ const persist = args.noPersist ? { mode: 'off' } : { dir: args.persistDir };
53
+ const broker = new Broker({ name: args.name, address: args.socket, token: args.token, idleTimeout: 0, persist });
54
+ await broker.start();
55
+ // eslint-disable-next-line no-console
56
+ console.log(`procmesh broker listening on ${broker.address}`);
57
+ // Persistence is on by default for `serve`. Always say WHERE state is written, and warn loudly
58
+ // when it lands in the OS temp dir (which can be cleared on reboot — not safe for production).
59
+ if (broker.persist && broker.persist.enabled) {
60
+ const dir = broker.persist.dir;
61
+ // eslint-disable-next-line no-console
62
+ console.log(`procmesh persistence ON → ${dir} (mode: ${broker.persist.mode})`);
63
+ if (isUnderTmp(dir)) {
64
+ // eslint-disable-next-line no-console
65
+ console.warn(
66
+ `WARNING: persisting under the OS temp dir (${os.tmpdir()}), which may be cleared on ` +
67
+ 'reboot. Pass --persist-dir <path> (or PROCMESH_PERSIST_DIR) for durable storage.'
68
+ );
69
+ }
70
+ } else {
71
+ // eslint-disable-next-line no-console
72
+ console.log('procmesh persistence OFF (in-memory only)');
73
+ }
74
+ const stop = () => broker.close().then(() => process.exit(0));
75
+ process.on('SIGINT', stop);
76
+ process.on('SIGTERM', stop);
77
+ break;
78
+ }
79
+ case 'status': {
80
+ const client = new Client({ name: args.name, address: args.socket, token: args.token, autoSpawn: false, reconnect: false });
81
+ try {
82
+ await client.connect();
83
+ await client.ping();
84
+ const keys = await client.keys();
85
+ // eslint-disable-next-line no-console
86
+ console.log(`broker UP at ${client.address} — ${keys.length} key(s) cached`);
87
+ await client.close();
88
+ } catch (err) {
89
+ // eslint-disable-next-line no-console
90
+ console.log(`broker DOWN at ${client.address} (${err.code || err.message})`);
91
+ process.exit(1);
92
+ }
93
+ break;
94
+ }
95
+ case 'stats': {
96
+ const client = new Client({ name: args.name, address: args.socket, token: args.token, autoSpawn: false, reconnect: false });
97
+ try {
98
+ await client.connect();
99
+ const s = await client.stats();
100
+ await client.close();
101
+ if (args.json) {
102
+ // eslint-disable-next-line no-console
103
+ console.log(JSON.stringify(s, null, 2));
104
+ } else {
105
+ // eslint-disable-next-line no-console
106
+ console.log(
107
+ `broker UP at ${client.address}\n` +
108
+ ` uptime ${Math.round(s.uptimeMs / 1000)}s | conns ${s.connections} | cache ${s.cacheSize} keys\n` +
109
+ ` locks ${s.locks} (waiters ${s.lockWaiters}) | pending calls ${s.pendingCalls} | subs ${s.subscriptions}\n` +
110
+ ` dropped ${s.dropped} | reaped ${s.reaped} | cpu ${(s.cpuCoreFraction * 100).toFixed(1)}% of a core\n` +
111
+ ` rss ${Math.round(s.memory.rss / 1048576)}MB | heap ${Math.round(s.memory.heapUsed / 1048576)}MB`
112
+ );
113
+ }
114
+ } catch (err) {
115
+ // eslint-disable-next-line no-console
116
+ console.log(`broker DOWN at ${client.address} (${err.code || err.message})`);
117
+ process.exit(1);
118
+ }
119
+ break;
120
+ }
121
+ case 'stop': {
122
+ const client = new Client({ name: args.name, address: args.socket, token: args.token, autoSpawn: false, reconnect: false });
123
+ try {
124
+ await client.connect();
125
+ await client.shutdownBroker();
126
+ await client.close();
127
+ // eslint-disable-next-line no-console
128
+ console.log('broker shutting down');
129
+ } catch (err) {
130
+ // eslint-disable-next-line no-console
131
+ console.log(`could not reach broker (${err.code || err.message})`);
132
+ process.exit(1);
133
+ }
134
+ break;
135
+ }
136
+ default:
137
+ usage();
138
+ if (cmd) process.exit(1);
139
+ }
140
+ }
141
+
142
+ main().catch((err) => {
143
+ // eslint-disable-next-line no-console
144
+ console.error(err);
145
+ process.exit(1);
146
+ });
package/src/client.js ADDED
@@ -0,0 +1,512 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const path = require('path');
5
+ const EventEmitter = require('events');
6
+ const { spawn } = require('child_process');
7
+ const { Peer, TYPES, matchTopic } = require('./protocol');
8
+ const { resolveCodec } = require('./codec');
9
+ const { resolveAddress, isPipe } = require('./transport');
10
+ const errors = require('./errors');
11
+
12
+ const delay = (ms) => new Promise((r) => setTimeout(r, ms));
13
+
14
+ /**
15
+ * Client handle to the shared broker. All methods are async and return Promises.
16
+ * The client transparently spawns a broker if none is running (autoSpawn), and
17
+ * reconnects with backoff, replaying subscriptions and RPC registrations.
18
+ *
19
+ * Emits: 'connect', 'disconnect', 'reconnect', 'error'.
20
+ */
21
+ class Client extends EventEmitter {
22
+ constructor(opts = {}) {
23
+ super();
24
+ this.opts = opts;
25
+ this.name = opts.name || 'default';
26
+ this.address = opts.address || resolveAddress(this.name);
27
+ this.codec = resolveCodec(opts.codec);
28
+ this.autoSpawn = opts.autoSpawn !== false;
29
+ // A custom { encode, decode } codec is a pair of functions that can't cross the `spawn`
30
+ // boundary, so an auto-spawned broker would silently fall back to JSON and every frame would
31
+ // then fail to decode. Refuse the footgun up front: run the broker yourself with the same codec.
32
+ if (this.autoSpawn && opts.codec && typeof opts.codec === 'object') {
33
+ throw new Error(
34
+ "a custom { encode, decode } codec can't be forwarded to an auto-spawned broker. Run the " +
35
+ 'broker yourself (procmesh serve / createBroker) with the same codec and pass ' +
36
+ "autoSpawn: false, or use the 'json' / 'msgpack' codec."
37
+ );
38
+ }
39
+ this.reconnectEnabled = opts.reconnect !== false;
40
+ this.token = opts.token || null;
41
+ this.callTimeout = opts.callTimeout || 30000;
42
+ this.maxReconnectDelay = opts.maxReconnectDelay || 5000;
43
+ // Optional client-initiated keepalive. The broker reaps dead *clients*, but only the client can
44
+ // notice a dead *broker* on a half-open link (or when the broker's heartbeat is disabled). When
45
+ // set (>0), ping on this interval and tear the link down — triggering reconnect — if no PONG
46
+ // comes back in time. 0 = rely on the broker heartbeat (default; unchanged behavior).
47
+ this.pingInterval = opts.pingInterval || 0;
48
+ this._pingTimer = null;
49
+
50
+ this.peer = null;
51
+ this.connected = false;
52
+ this.closed = false;
53
+ this.nextId = 1;
54
+ this.pending = new Map(); // id -> { resolve, reject, timer, t }
55
+ this.subscriptions = new Map(); // channel -> Set<handler>
56
+ this.handlers = new Map(); // procName -> fn
57
+
58
+ this._connectPromise = null;
59
+ this._reconnectAttempt = 0;
60
+ this._reconnectTimer = null;
61
+ }
62
+
63
+ // ---------------------------------------------------------------- connection
64
+
65
+ async connect() {
66
+ if (this.connected) return this;
67
+ if (!this._connectPromise) {
68
+ this._connectPromise = this._doConnect().finally(() => {
69
+ this._connectPromise = null;
70
+ });
71
+ }
72
+ await this._connectPromise;
73
+ return this;
74
+ }
75
+
76
+ async _doConnect() {
77
+ let spawned = false;
78
+ const maxAttempts = this.autoSpawn ? 100 : 1;
79
+ for (let attempt = 1; ; attempt++) {
80
+ if (this.closed) throw new errors.Disconnected('client is closed');
81
+ try {
82
+ await this._open();
83
+ return;
84
+ } catch (err) {
85
+ const retriable = err.code === 'ENOENT' || err.code === 'ECONNREFUSED';
86
+ if (!retriable || !this.autoSpawn || attempt >= maxAttempts) throw err;
87
+ if (!spawned) {
88
+ this._spawnBroker();
89
+ spawned = true;
90
+ }
91
+ await delay(Math.min(25 * attempt, 250));
92
+ }
93
+ }
94
+ }
95
+
96
+ _open() {
97
+ return new Promise((resolve, reject) => {
98
+ const socket = net.connect(this.address);
99
+ socket.setNoDelay(true);
100
+ const onError = (err) => {
101
+ socket.destroy();
102
+ reject(err);
103
+ };
104
+ socket.once('error', onError);
105
+ socket.once('connect', async () => {
106
+ socket.removeListener('error', onError);
107
+ this.peer = new Peer(socket, this.codec);
108
+ this.connected = true;
109
+ this._reconnectAttempt = 0;
110
+ this.peer.on('message', (msg) => this._onMessage(msg));
111
+ this.peer.on('close', () => this._onClose());
112
+ this.peer.on('error', () => {
113
+ /* 'close' handles it */
114
+ });
115
+ try {
116
+ await this._request(TYPES.HELLO, { name: this.name, token: this.token });
117
+ await this._replay();
118
+ this._startKeepalive();
119
+ this.emit('connect');
120
+ resolve();
121
+ } catch (err) {
122
+ // A rejected auth is fatal — don't loop reconnecting with a bad token.
123
+ if (err && err.code === 'EAUTH') this.reconnectEnabled = false;
124
+ reject(err);
125
+ }
126
+ });
127
+ });
128
+ }
129
+
130
+ _spawnBroker() {
131
+ const brokerOpts = {
132
+ address: this.address,
133
+ name: this.name,
134
+ codec: typeof this.opts.codec === 'string' ? this.opts.codec : 'json',
135
+ cache: this.opts.cache,
136
+ token: this.token,
137
+ idleTimeout: this.opts.brokerIdleTimeout == null ? 60000 : this.opts.brokerIdleTimeout,
138
+ };
139
+ const child = spawn(process.execPath, [path.join(__dirname, 'broker-bin.js')], {
140
+ detached: true,
141
+ stdio: 'ignore',
142
+ env: { ...process.env, PROCMESH_BROKER_OPTS: JSON.stringify(brokerOpts) },
143
+ });
144
+ child.unref();
145
+ }
146
+
147
+ async _replay() {
148
+ for (const channel of this.subscriptions.keys()) {
149
+ await this._request(TYPES.SUBSCRIBE, { channel });
150
+ }
151
+ for (const name of this.handlers.keys()) {
152
+ await this._request(TYPES.REGISTER, { name });
153
+ }
154
+ }
155
+
156
+ /** Begin client-side keepalive pings (no-op unless `pingInterval` was configured). */
157
+ _startKeepalive() {
158
+ this._stopKeepalive();
159
+ if (!this.pingInterval) return;
160
+ this._pingTimer = setInterval(() => {
161
+ if (!this.connected || !this.peer) return;
162
+ // An unanswered ping within the interval means the link is dead — drop it so `_onClose`
163
+ // fires and reconnect kicks in. (A late PONG after this just settles nothing.)
164
+ this._request(TYPES.PING, {}, this.pingInterval).catch(() => {
165
+ if (this.peer) this.peer.destroy();
166
+ });
167
+ }, this.pingInterval);
168
+ if (this._pingTimer.unref) this._pingTimer.unref();
169
+ }
170
+
171
+ _stopKeepalive() {
172
+ if (this._pingTimer) {
173
+ clearInterval(this._pingTimer);
174
+ this._pingTimer = null;
175
+ }
176
+ }
177
+
178
+ _onClose() {
179
+ this.connected = false;
180
+ this.peer = null;
181
+ this._stopKeepalive();
182
+ const err = new errors.Disconnected('connection to broker lost');
183
+ for (const p of this.pending.values()) {
184
+ clearTimeout(p.timer);
185
+ p.reject(err);
186
+ }
187
+ this.pending.clear();
188
+ this.emit('disconnect');
189
+ if (!this.closed && this.reconnectEnabled) this._scheduleReconnect();
190
+ }
191
+
192
+ _scheduleReconnect() {
193
+ if (this._reconnectTimer) return;
194
+ this._reconnectAttempt++;
195
+ // Exponential backoff with ±50% jitter so a fleet of clients reconnecting after a broker
196
+ // restart doesn't thunder back in lockstep.
197
+ const base = Math.min(100 * 2 ** (this._reconnectAttempt - 1), this.maxReconnectDelay);
198
+ const d = base * (0.5 + Math.random() * 0.5);
199
+ this._reconnectTimer = setTimeout(() => {
200
+ this._reconnectTimer = null;
201
+ if (this.closed) return;
202
+ this._doConnect()
203
+ .then(() => this.emit('reconnect'))
204
+ .catch(() => {
205
+ if (!this.closed) this._scheduleReconnect();
206
+ });
207
+ }, d);
208
+ if (this._reconnectTimer.unref) this._reconnectTimer.unref();
209
+ }
210
+
211
+ // ------------------------------------------------------------------ messaging
212
+
213
+ _request(t, payload = {}, timeout = this.callTimeout) {
214
+ return new Promise((resolve, reject) => {
215
+ if (!this.peer || !this.connected) {
216
+ reject(new errors.Disconnected());
217
+ return;
218
+ }
219
+ const id = this.nextId++;
220
+ const timer = setTimeout(() => {
221
+ this.pending.delete(id);
222
+ reject(new errors.CallTimeout(`request "${t}" timed out after ${timeout}ms`));
223
+ }, timeout);
224
+ if (timer.unref) timer.unref();
225
+ this.pending.set(id, { resolve, reject, timer, t });
226
+ this.peer.send({ t, id, ...payload });
227
+ });
228
+ }
229
+
230
+ _settle(id, fn) {
231
+ const p = this.pending.get(id);
232
+ if (!p) return;
233
+ this.pending.delete(id);
234
+ clearTimeout(p.timer);
235
+ fn(p);
236
+ }
237
+
238
+ _onMessage(msg) {
239
+ switch (msg.t) {
240
+ case TYPES.OK:
241
+ this._settle(msg.id, (p) => p.resolve(msg.value));
242
+ break;
243
+ case TYPES.WELCOME:
244
+ case TYPES.PONG:
245
+ this._settle(msg.id, (p) => p.resolve(msg.t === TYPES.PONG ? true : msg));
246
+ break;
247
+ case TYPES.ERR:
248
+ this._settle(msg.id, (p) => p.reject(new errors.RemoteError(msg.message, msg.code)));
249
+ break;
250
+ case TYPES.MESSAGE:
251
+ this._deliver(msg.channel, msg.payload);
252
+ break;
253
+ case TYPES.PING:
254
+ // Unsolicited broker heartbeat — answer so we're not reaped.
255
+ if (this.peer) this.peer.send({ t: TYPES.PONG });
256
+ break;
257
+ case TYPES.INVOKE:
258
+ this._onInvoke(msg);
259
+ break;
260
+ default:
261
+ break;
262
+ }
263
+ }
264
+
265
+ /** Route an incoming message to every local handler whose subscription matches. */
266
+ _deliver(channel, payload) {
267
+ // Collect matching handlers (deduped, so a handler subscribed both exactly and
268
+ // by pattern only fires once for a given message).
269
+ let matched = null;
270
+ for (const [sub, set] of this.subscriptions) {
271
+ if (matchTopic(sub, channel)) {
272
+ if (!matched) matched = new Set();
273
+ for (const h of set) matched.add(h);
274
+ }
275
+ }
276
+ if (!matched) return;
277
+ for (const h of matched) {
278
+ try {
279
+ h(payload, channel);
280
+ } catch (err) {
281
+ this.emit('error', err);
282
+ }
283
+ }
284
+ }
285
+
286
+ async _onInvoke(msg) {
287
+ const fn = this.handlers.get(msg.name);
288
+ if (!fn) {
289
+ this.peer.send({
290
+ t: TYPES.RESULT,
291
+ id: msg.id,
292
+ error: { message: `no handler "${msg.name}"`, code: 'ENOHANDLER' },
293
+ });
294
+ return;
295
+ }
296
+ try {
297
+ const result = await fn(...(msg.args || []));
298
+ if (this.peer) this.peer.send({ t: TYPES.RESULT, id: msg.id, result });
299
+ } catch (err) {
300
+ if (this.peer) {
301
+ this.peer.send({
302
+ t: TYPES.RESULT,
303
+ id: msg.id,
304
+ error: { message: err.message, code: err.code || 'EHANDLER' },
305
+ });
306
+ }
307
+ }
308
+ }
309
+
310
+ // --------------------------------------------------------------------- cache
311
+
312
+ get(key) {
313
+ return this._request(TYPES.GET, { key });
314
+ }
315
+
316
+ set(key, value, opts = {}) {
317
+ return this._request(TYPES.SET, { key, value, ttl: opts.ttl });
318
+ }
319
+
320
+ del(key) {
321
+ return this._request(TYPES.DEL, { key });
322
+ }
323
+
324
+ has(key) {
325
+ return this._request(TYPES.HAS, { key });
326
+ }
327
+
328
+ keys() {
329
+ return this._request(TYPES.KEYS, {});
330
+ }
331
+
332
+ clear() {
333
+ return this._request(TYPES.CLEAR, {});
334
+ }
335
+
336
+ async mget(keys) {
337
+ const res = await this._request(TYPES.MGET, { keys });
338
+ return res.values.map((v, i) => (res.found[i] ? v : undefined));
339
+ }
340
+
341
+ mset(entries) {
342
+ const list = Array.isArray(entries) ? entries : Object.entries(entries);
343
+ return this._request(TYPES.MSET, { entries: list });
344
+ }
345
+
346
+ // -------------------------------------------------------------------- atomic
347
+
348
+ incr(key, by = 1) {
349
+ return this._request(TYPES.INCR, { key, by });
350
+ }
351
+
352
+ decr(key, by = 1) {
353
+ return this._request(TYPES.DECR, { key, by });
354
+ }
355
+
356
+ cas(key, prev, next) {
357
+ return this._request(TYPES.CAS, { key, prev, next });
358
+ }
359
+
360
+ // -------------------------------------------------------------------- pub/sub
361
+
362
+ async subscribe(channel, handler) {
363
+ if (typeof handler !== 'function') throw new TypeError('subscribe requires a handler function');
364
+ let set = this.subscriptions.get(channel);
365
+ const isNew = !set;
366
+ if (!set) {
367
+ set = new Set();
368
+ this.subscriptions.set(channel, set);
369
+ }
370
+ set.add(handler);
371
+ if (isNew && this.connected) await this._request(TYPES.SUBSCRIBE, { channel });
372
+ return () => this.unsubscribe(channel, handler);
373
+ }
374
+
375
+ async unsubscribe(channel, handler) {
376
+ const set = this.subscriptions.get(channel);
377
+ if (!set) return;
378
+ if (handler) set.delete(handler);
379
+ else set.clear();
380
+ if (set.size === 0) {
381
+ this.subscriptions.delete(channel);
382
+ if (this.connected) await this._request(TYPES.UNSUBSCRIBE, { channel });
383
+ }
384
+ }
385
+
386
+ publish(channel, payload) {
387
+ return this._request(TYPES.PUBLISH, { channel, payload });
388
+ }
389
+
390
+ // ------------------------------------------------------------------------ rpc
391
+
392
+ async register(name, fn) {
393
+ if (typeof fn !== 'function') throw new TypeError('register requires a handler function');
394
+ this.handlers.set(name, fn);
395
+ if (this.connected) await this._request(TYPES.REGISTER, { name });
396
+ }
397
+
398
+ async unregister(name) {
399
+ this.handlers.delete(name);
400
+ if (this.connected) await this._request(TYPES.UNREGISTER, { name });
401
+ }
402
+
403
+ call(name, args = [], opts = {}) {
404
+ const timeout = opts.timeout || this.callTimeout;
405
+ // Send `timeout` so the broker can set a matching cleanup backstop: if the worker hangs while
406
+ // connected, the broker frees its `pending`/`inflight` state shortly after we give up here.
407
+ return this._request(TYPES.CALL, { name, args, timeout }, timeout);
408
+ }
409
+
410
+ // ---------------------------------------------------------------------- locks
411
+
412
+ /**
413
+ * Acquire a lock. Resolves with a release function, or null if not acquired
414
+ * (only possible when `wait` is 0 or the wait timed out).
415
+ */
416
+ async lock(key, opts = {}) {
417
+ const ttl = opts.ttl == null ? 30000 : opts.ttl;
418
+ const wait = opts.wait == null ? 0 : opts.wait;
419
+ const requestTimeout = wait > 0 ? wait + 5000 : this.callTimeout;
420
+ const reply = await this._request(TYPES.LOCK, { key, ttl, wait }, requestTimeout);
421
+ // Tolerate the legacy boolean reply shape (pre-v2 broker) as well as { acquired, token }.
422
+ const acquired = reply === true || (reply && reply.acquired === true);
423
+ const token = reply && reply.token != null ? reply.token : null;
424
+ if (!acquired) return null;
425
+ let released = false;
426
+ const release = async () => {
427
+ if (released) return;
428
+ released = true;
429
+ try {
430
+ await this._request(TYPES.UNLOCK, { key });
431
+ } catch {
432
+ /* broker gone — lock expires via TTL */
433
+ }
434
+ };
435
+ // The fencing token of this grant — pass to fenced* ops (or an external resource) so a
436
+ // write from a holder whose lock expired (TTL overrun) is rejected with EFENCED.
437
+ release.token = token;
438
+ return release;
439
+ }
440
+
441
+ /**
442
+ * Run `fn` while holding `key`; always releases. Throws LockTimeout if not acquired.
443
+ * `fn` receives a context `{ token, set, cas, del }` whose set/cas/del are fenced by this
444
+ * grant's token, so a write that lands after a TTL overrun (lock already re-granted) is
445
+ * rejected with EFENCED instead of silently corrupting shared state. Callers that ignore
446
+ * the argument keep working unchanged.
447
+ */
448
+ async withLock(key, fn, opts = {}) {
449
+ const wait = opts.wait == null ? 10000 : opts.wait;
450
+ const release = await this.lock(key, { wait, ttl: opts.ttl });
451
+ if (!release) throw new errors.LockTimeout(`could not acquire lock "${key}"`);
452
+ const token = release.token;
453
+ const ctx = {
454
+ token,
455
+ set: (k, value, o = {}) => this.fencedSet(key, token, k, value, o),
456
+ cas: (k, prev, next) => this.fencedCas(key, token, k, prev, next),
457
+ del: (k) => this.fencedDel(key, token, k),
458
+ };
459
+ try {
460
+ return await fn(ctx);
461
+ } finally {
462
+ await release();
463
+ }
464
+ }
465
+
466
+ // ------------------------------------------------------- fenced mutations (lock-guarded)
467
+
468
+ /** Set `key` only if `token` is current for lock `lockKey`; else rejects EFENCED. */
469
+ fencedSet(lockKey, token, key, value, opts = {}) {
470
+ return this._request(TYPES.FSET, { key: lockKey, token, k: key, value, ttl: opts.ttl });
471
+ }
472
+
473
+ /** Compare-and-set guarded by lock `lockKey`'s fencing token; rejects EFENCED if stale. */
474
+ fencedCas(lockKey, token, key, prev, next) {
475
+ return this._request(TYPES.FCAS, { key: lockKey, token, k: key, prev, next });
476
+ }
477
+
478
+ /** Delete guarded by lock `lockKey`'s fencing token; rejects EFENCED if stale. */
479
+ fencedDel(lockKey, token, key) {
480
+ return this._request(TYPES.FDEL, { key: lockKey, token, k: key });
481
+ }
482
+
483
+ // ---------------------------------------------------------------- misc / admin
484
+
485
+ ping() {
486
+ return this._request(TYPES.PING, {});
487
+ }
488
+
489
+ /** Fetch a point-in-time operational snapshot from the broker. */
490
+ stats() {
491
+ return this._request(TYPES.STATS, {});
492
+ }
493
+
494
+ /** Ask the broker to shut down (used by the CLI `stop` command). */
495
+ shutdownBroker() {
496
+ return this._request(TYPES.SHUTDOWN, {});
497
+ }
498
+
499
+ async close() {
500
+ this.closed = true;
501
+ this._stopKeepalive();
502
+ if (this._reconnectTimer) {
503
+ clearTimeout(this._reconnectTimer);
504
+ this._reconnectTimer = null;
505
+ }
506
+ if (this.peer) this.peer.destroy();
507
+ this.peer = null;
508
+ this.connected = false;
509
+ }
510
+ }
511
+
512
+ module.exports = Client;