entangld 2.4.1 → 2.7.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/lib/Datastore.js CHANGED
@@ -1,10 +1,25 @@
1
- const { v4:uuidv4 } = require("uuid");
1
+ const { randomUUID: uuidv4 } = require("node:crypto");
2
2
  const EventEmitter = require("events");
3
3
  const uuid_regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ const MAX_TIMEOUT_MS = 2147483647; // Longer setTimeout delays fire after 1 ms
5
+
6
+ /**
7
+ * Check that an optional signal looks like an AbortSignal
8
+ *
9
+ * @private
10
+ * @param {AbortSignal} [signal] the signal to check.
11
+ * @throws {TypeError} if signal is not undefined or an AbortSignal.
12
+ */
13
+ function check_signal(signal) {
14
+ if (signal === undefined) return;
15
+ if (signal === null || typeof (signal.addEventListener) != "function"
16
+ || typeof (signal.removeEventListener) != "function")
17
+ throw TypeError("signal must be an AbortSignal");
18
+ }
4
19
 
5
20
  const Entangld_Message = require("./Message.js");
6
21
  const Subscription = require("./Subscription.js");
7
- const { EntangldError, RPCError } = require("./error.js");
22
+ const { EntangldError, EntangldTimeoutError, RPCError } = require("./error.js");
8
23
  const {
9
24
  is_beneath,
10
25
  extract_from_path,
@@ -114,7 +129,7 @@ class Entangld extends EventEmitter {
114
129
 
115
130
  // Re-subscribe
116
131
  subscriptions.forEach(s => {
117
- this._subscribe(s.path, s.callback, s.upstream, s.uuid, s.every);
132
+ this._subscribe(s.path, s.callback, s.upstream, s.uuid, s.every, s.signal);
118
133
  });
119
134
  }
120
135
 
@@ -195,7 +210,7 @@ class Entangld extends EventEmitter {
195
210
  } else if (msg.type == "value") {
196
211
  // Incoming value reply
197
212
  let resolve = this._requests[msg.uuid];
198
- resolve(msg.value);
213
+ if (resolve) resolve(msg.value);
199
214
 
200
215
  //Clean up request
201
216
  delete this._requests[msg.uuid];
@@ -405,11 +420,18 @@ class Entangld extends EventEmitter {
405
420
  *
406
421
  * @param {string} path the path to query (like "system.voltage").
407
422
  * @param {Array} [args=[]] the arguments to pass to the RPC.
423
+ * @param {object} [options]
424
+ * @param {number} [options.timeout_ms] reject with an EntangldTimeoutError
425
+ * if no reply arrives within this many milliseconds. This stops the wait,
426
+ * not the RPC, which may still run. Omit to wait indefinitely.
427
+ * @param {AbortSignal} [options.signal] reject with signal.reason when
428
+ * this aborts. This also stops the wait, not the RPC.
408
429
  * @throws {RPCError} if the rpc call experienced an error
409
430
  * @return {Promise} promise resolving to return value of the rpc
410
431
  */
411
- call_rpc(path, args=[]) {
412
- return this.get(path,args).then((args) => {
432
+ async call_rpc(path, args=[], { timeout_ms, signal }={}) {
433
+ return this.get(path,args,{ timeout_ms, signal }).then((args) => {
434
+ if ( !Array.isArray(args) || args.length != 2) throw new EntangldError(`Endpoint is not configured for RPC: '${path}'`);
413
435
  if (args[0] === false) throw RPCError.from_remote_error(args[1]);
414
436
  if ( typeof(args[0]) == "string" ) return args[0];
415
437
  return args[1];
@@ -470,14 +492,31 @@ class Entangld extends EventEmitter {
470
492
  * @param {string} path the path to query (like "system.voltage").
471
493
  * @param {object} [params] the parameters to be passed to the remote
472
494
  * function (RPC) or the maximum depth of the returned object (normal mode).
473
- *
474
- * @throws {TypeError} if path is not a string.
495
+ * @param {object} [options]
496
+ * @param {number} [options.timeout_ms] reject with an EntangldTimeoutError
497
+ * if no value arrives within this many milliseconds. Applies to remote
498
+ * paths and to local functions. It is never sent to a remote store. Omit
499
+ * to wait indefinitely.
500
+ * @param {AbortSignal} [options.signal] reject with signal.reason when
501
+ * this aborts. If it has already aborted, nothing is sent and no function
502
+ * is called. This only stops the wait, not any work a remote store or
503
+ * local function has started.
504
+ *
505
+ * @throws {TypeError} if path is not a string, timeout_ms is not a
506
+ * positive number of at most 2147483647, or signal is not an AbortSignal.
475
507
  * @return {Promise} promise resolving to the object at that path.
476
508
  */
477
- get(path, params) {
509
+ get(path, params, { timeout_ms, signal }={}) {
478
510
  // Sanity check
479
511
  if (typeof (path) != "string")
480
512
  throw TypeError("path must be a string");
513
+ if (timeout_ms !== undefined
514
+ && !(Number.isFinite(timeout_ms) && timeout_ms > 0 && timeout_ms <= MAX_TIMEOUT_MS))
515
+ throw TypeError(`timeout_ms must be a positive number of at most ${MAX_TIMEOUT_MS}`);
516
+ check_signal(signal);
517
+
518
+ if (signal !== undefined && signal.aborted)
519
+ return Promise.reject(signal.reason);
481
520
 
482
521
  // If an attach()ed store path masks (matches but is shorter than) the
483
522
  // path, we are returning that store
@@ -500,7 +539,7 @@ class Entangld extends EventEmitter {
500
539
  && result.constructor.name == "Promise") ?
501
540
  result : new Promise((res) => res(result));
502
541
 
503
- return p.then((val) => {
542
+ return this._with_limits(p, path, { timeout_ms, signal }).then((val) => {
504
543
 
505
544
  // If we have a remaining path, try to navigate it before
506
545
  // returning
@@ -540,11 +579,65 @@ class Entangld extends EventEmitter {
540
579
  // Request the data from the remote store
541
580
  const msg = Entangld_Message.get(tree, params);
542
581
 
543
- return new Promise((res) => {
582
+ const reply = new Promise((res) => {
544
583
  this._requests[msg.uuid] = res;
545
584
  this._transmit(msg, obj);
546
585
  });
547
586
 
587
+ return this._with_limits(reply, path, { timeout_ms, signal }, () => {
588
+ delete this._requests[msg.uuid];
589
+ });
590
+ }
591
+
592
+ /**
593
+ * Reject if a promise does not settle in time, or a signal aborts first
594
+ *
595
+ * @private
596
+ * @param {Promise} promise the promise to wait on.
597
+ * @param {string} path the path being read, for the timeout error.
598
+ * @param {object} limits
599
+ * @param {number} [limits.timeout_ms] the time limit.
600
+ * @param {AbortSignal} [limits.signal] the signal to watch.
601
+ * @param {function} [on_limit] cleanup to run if a limit is reached.
602
+ *
603
+ * @return {Promise} the original promise if there are no limits, otherwise
604
+ * a promise which settles as the original does, or rejects when a limit
605
+ * is reached.
606
+ */
607
+ _with_limits(promise, path, { timeout_ms, signal }, on_limit = () => { }) {
608
+
609
+ if (timeout_ms === undefined && signal === undefined) return promise;
610
+
611
+ let timer;
612
+ let on_abort;
613
+
614
+ const stop = () => {
615
+ clearTimeout(timer);
616
+ if (signal !== undefined) signal.removeEventListener("abort", on_abort);
617
+ };
618
+
619
+ const limit = new Promise((_, reject) => {
620
+ const give_up = (err) => {
621
+ stop();
622
+ on_limit();
623
+ reject(err);
624
+ };
625
+
626
+ if (timeout_ms !== undefined) {
627
+ timer = setTimeout(() => {
628
+ give_up(new EntangldTimeoutError(path, timeout_ms));
629
+ }, timeout_ms);
630
+ }
631
+
632
+ if (signal !== undefined) {
633
+ on_abort = () => give_up(signal.reason);
634
+ signal.addEventListener("abort", on_abort);
635
+ }
636
+ });
637
+
638
+ promise.then(stop, stop);
639
+
640
+ return Promise.race([promise, limit]);
548
641
  }
549
642
 
550
643
  /**
@@ -581,14 +674,24 @@ class Entangld extends EventEmitter {
581
674
  *
582
675
  * @param {string} path the path to watch. Use of '*' is allowed as a wildcard (e.g. "system.*")
583
676
  * @param {function} func the callback - will be of the form (path, value).
584
- * @param {number|null} [every=null] the number of `set` messages to wait before calling callback
585
- *
586
- * @throws {TypeError} if path is not a string.
677
+ * @param {number|null|object} [options=null] an options object, or the value of options.every.
678
+ * @param {number|null} [options.every=null] the number of `set` messages to wait before calling callback
679
+ * @param {AbortSignal} [options.signal] unsubscribe when this aborts. If it
680
+ * has already aborted, no subscription is created. The uuid is still
681
+ * returned, as it would be for a subscription which has since ended.
682
+ * Calling unsubscribe() after an abort throws, see unsubscribe().
683
+ *
684
+ * @throws {TypeError} if path is not a string, or signal is not an AbortSignal.
587
685
  * @return {uuidv4} - the uuid of the subscription
588
686
  */
589
- subscribe(path, func, every = null) {
687
+ subscribe(path, func, options = null) {
688
+
689
+ const is_object = typeof (options) == "object" && options !== null;
690
+ const { every = null, signal } = is_object ? options : { every: options };
590
691
 
591
- return this._subscribe(path, func, null, null, every);
692
+ check_signal(signal);
693
+
694
+ return this._subscribe(path, func, null, null, every, signal);
592
695
  }
593
696
 
594
697
  /**
@@ -602,6 +705,8 @@ class Entangld extends EventEmitter {
602
705
  * @param {function} func the callback - will be of the form (path, value).
603
706
  * @param {(Entangld|null)} [upstream=null] the Engangld next upstream in the path.
604
707
  * @param {(uuidv4|null)} [uuid=null] the UUID to use for this subscription.
708
+ * @param {number|null} [every=null] the number of `set` messages to wait before calling callback
709
+ * @param {AbortSignal} [signal] unsubscribe when this aborts.
605
710
  *
606
711
  * @emits {str} subscription - when this datastore is the terminal datastore of a
607
712
  * subscription request, this datastore emits the path
@@ -609,7 +714,7 @@ class Entangld extends EventEmitter {
609
714
  * @throws {TypeError} if path is not a string.
610
715
  * @return {uuidv4} - the uuid of the subscription
611
716
  */
612
- _subscribe(path, func, upstream = null, uuid = null, every = null) {
717
+ _subscribe(path, func, upstream = null, uuid = null, every = null, signal = undefined) {
613
718
 
614
719
 
615
720
  // Supply a UUID if none provided
@@ -620,6 +725,8 @@ class Entangld extends EventEmitter {
620
725
  if (typeof (path) != "string")
621
726
  throw TypeError("path must be a string");
622
727
 
728
+ if (signal !== undefined && signal.aborted) return uuid;
729
+
623
730
  let [obj, /*namespace*/, tree] = this._get_remote_object(path);
624
731
 
625
732
  // Create the subscription object
@@ -643,6 +750,15 @@ class Entangld extends EventEmitter {
643
750
  // Add the new subscription here
644
751
  this._subscriptions.push(new_sub);
645
752
 
753
+ if (signal !== undefined) {
754
+ new_sub.watch_signal(signal, () => {
755
+ // Unlike unsubscribe(), finding nothing here isn't an error
756
+ this._unsubscribe(this._subscriptions.filter(
757
+ s => s.matches_uuid(uuid) && !s.is_pass_through
758
+ ));
759
+ });
760
+ }
761
+
646
762
  // Commission any subscribes downstream, if this is a remote subscription
647
763
  if (new_sub.has_downstream) {
648
764
  const msg = Entangld_Message.subscribe(tree, uuid, every);
@@ -681,6 +797,11 @@ class Entangld extends EventEmitter {
681
797
  * path will be deleted, so if you have multiple subscriptions on a single path,
682
798
  * and only want one of them to be removed, you must provide the uuid instead.
683
799
  *
800
+ * Caution - this throws if the subscription has already ended. That includes
801
+ * a subscription ended by its signal, or a signal which had already aborted
802
+ * when subscribe() was called. If you mix signals with explicit unsubscribes,
803
+ * check `signal.aborted` first or catch the error.
804
+ *
684
805
  * @param {(String|uuidv4)} path_or_uuid - the path (or uuid) to unwatch.
685
806
  * @throws {EntangldError} if no subscriptions were found.
686
807
  * @return {number} count of subscriptions removed.
@@ -765,9 +886,12 @@ class Entangld extends EventEmitter {
765
886
  const uuids = subscriptions.map(s => s.uuid);
766
887
 
767
888
  // Drop all subscriptions with matching uuid
768
- this._subscriptions = this._subscriptions.filter(
769
- s => !(uuids.includes(s.uuid))
770
- );
889
+ this._subscriptions = this._subscriptions.filter((s) => {
890
+ if (!uuids.includes(s.uuid)) return true;
891
+
892
+ s.unwatch_signal();
893
+ return false;
894
+ });
771
895
 
772
896
  // Notify the downstream for any deleted subscriptions that are remote
773
897
  subscriptions.forEach((s) => {
package/lib/Message.js CHANGED
@@ -1,31 +1,30 @@
1
- const { v4:uuidv4 } = require("uuid");
1
+ const { randomUUID: uuidv4 } = require("node:crypto");
2
2
 
3
3
  /**
4
4
  * Message class for Entangld.
5
5
  *
6
6
  * These messages are used for executing datastore operations between
7
7
  * remote datastores. In these relationships, there is always an upstream
8
- * and downstream pair, so that `get`, `set`, `push` and `subscribe` messages
9
- * are created upstream and passed downstream while `event` and `value` messages
10
- * are created downstream and are passed back up. `unsubscribe` events can travel
11
- * in both directions. To maintain consistency, the internal `.path` attribute
8
+ * and downstream pair, so that `get`, `set`, `push`, `subscribe` and `unsubscribe`
9
+ * messages are created upstream and passed downstream while `event` and `value`
10
+ * messages are created downstream and are passed back up. To maintain consistency,
11
+ * the internal `.path` attribute
12
12
  * will always refer a path relative to the downstream datastore, since the
13
13
  * downstream datastores do not necessarilly have access to the upstream store
14
14
  * structure, and so cannot generally construct upstream paths. This means that
15
15
  * the `.path` attribute is a `tree` relative to the upstream datastore, and
16
16
  * the upstream path can be reconstructed as:
17
- * ```javascript
18
- * > upstream._namespaces.get(downstream) + "." + msg.path;
17
+ * ```js
18
+ * upstream._namespaces.get(downstream) + "." + msg.path;
19
19
  * ```
20
- * Since `unsubscribe` messages can pass either upstream or downstream, the notion
21
- * of a path is ill-defined, and so unsubscribe messages should have their `.path`
22
- * attributes set to undefined or null.
20
+ * Since `unsubscribe` messages identify a subscription by its `.uuid`, they should
21
+ * have their `.path` attributes set to undefined or null.
23
22
  *
24
23
  * Most messages will also have a `.uuid` attribute. For `get`/`value` messages,
25
24
  * this allows for the value to be properly linked back up with the original `get`
26
25
  * message. For the `subscribe`/`unsubscribe`/`event` messages, this allows for
27
26
  * callback functions to be trigger properly, and for unsubscribe messages to
28
- * propogate both directions. `set`/`push` messages do not use the `.uuid` attribute
27
+ * propogate down the chain. `set`/`push` messages do not use the `.uuid` attribute
29
28
  * since they require no response.
30
29
  */
31
30
  class Entangld_Message {
@@ -38,6 +38,29 @@ class Subscription {
38
38
  this.every = every;
39
39
  }
40
40
  this.index = -1; // guarentee that the first `set` triggers
41
+ this.signal = undefined;
42
+ this._on_abort = undefined;
43
+ }
44
+
45
+ /**
46
+ * Run a callback when a signal aborts
47
+ *
48
+ * @param {AbortSignal} signal - the signal to watch
49
+ * @param {function} on_abort - the callback
50
+ */
51
+ watch_signal(signal, on_abort) {
52
+ this.signal = signal;
53
+ this._on_abort = on_abort;
54
+ signal.addEventListener("abort", on_abort);
55
+ }
56
+
57
+ /**
58
+ * Stop watching this subscription's signal, if it has one
59
+ */
60
+ unwatch_signal() {
61
+ if (this.signal === undefined) return;
62
+
63
+ this.signal.removeEventListener("abort", this._on_abort);
41
64
  }
42
65
 
43
66
  /**
package/lib/error.js CHANGED
@@ -12,6 +12,20 @@ class EntangldError extends Error {
12
12
  }
13
13
  }
14
14
 
15
+ /**
16
+ * Error for a request which got no reply in time.
17
+ *
18
+ * @extends EntangldError
19
+ */
20
+ class EntangldTimeoutError extends EntangldError {
21
+ constructor(path, timeout_ms) {
22
+ super(`No reply for '${path}' within ${timeout_ms} ms`);
23
+
24
+ this.path = path;
25
+ this.timeout_ms = timeout_ms;
26
+ }
27
+ }
28
+
15
29
  /**
16
30
  * Remote Error class for Entangld.
17
31
  */
@@ -39,5 +53,6 @@ class RPCError extends Error {
39
53
 
40
54
  module.exports = exports = {
41
55
  EntangldError,
56
+ EntangldTimeoutError,
42
57
  RPCError,
43
58
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangld",
3
- "version": "2.4.1",
3
+ "version": "2.7.0",
4
4
  "description": "Synchronized key-value stores with RPCs and events",
5
5
  "main": "index.js",
6
6
  "jshintConfig": {
@@ -34,8 +34,5 @@
34
34
  "mocha": "^10.0.0",
35
35
  "nodemon": "^2.0.16",
36
36
  "sockhop": "^1.4.3"
37
- },
38
- "dependencies": {
39
- "uuid": "^8.3.2"
40
37
  }
41
38
  }