entangld 2.6.0 → 2.7.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/lib/Datastore.js CHANGED
@@ -1,10 +1,25 @@
1
- const { randomUUID: uuidv4 } = require("node:crypto");
1
+ const uuidv4 = require("./uuid.js");
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,17 @@ 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
- async 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) => {
413
434
  if ( !Array.isArray(args) || args.length != 2) throw new EntangldError(`Endpoint is not configured for RPC: '${path}'`);
414
435
  if (args[0] === false) throw RPCError.from_remote_error(args[1]);
415
436
  if ( typeof(args[0]) == "string" ) return args[0];
@@ -471,14 +492,31 @@ class Entangld extends EventEmitter {
471
492
  * @param {string} path the path to query (like "system.voltage").
472
493
  * @param {object} [params] the parameters to be passed to the remote
473
494
  * function (RPC) or the maximum depth of the returned object (normal mode).
474
- *
475
- * @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.
476
507
  * @return {Promise} promise resolving to the object at that path.
477
508
  */
478
- get(path, params) {
509
+ get(path, params, { timeout_ms, signal }={}) {
479
510
  // Sanity check
480
511
  if (typeof (path) != "string")
481
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);
482
520
 
483
521
  // If an attach()ed store path masks (matches but is shorter than) the
484
522
  // path, we are returning that store
@@ -501,7 +539,7 @@ class Entangld extends EventEmitter {
501
539
  && result.constructor.name == "Promise") ?
502
540
  result : new Promise((res) => res(result));
503
541
 
504
- return p.then((val) => {
542
+ return this._with_limits(p, path, { timeout_ms, signal }).then((val) => {
505
543
 
506
544
  // If we have a remaining path, try to navigate it before
507
545
  // returning
@@ -541,11 +579,65 @@ class Entangld extends EventEmitter {
541
579
  // Request the data from the remote store
542
580
  const msg = Entangld_Message.get(tree, params);
543
581
 
544
- return new Promise((res) => {
582
+ const reply = new Promise((res) => {
545
583
  this._requests[msg.uuid] = res;
546
584
  this._transmit(msg, obj);
547
585
  });
548
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]);
549
641
  }
550
642
 
551
643
  /**
@@ -582,14 +674,24 @@ class Entangld extends EventEmitter {
582
674
  *
583
675
  * @param {string} path the path to watch. Use of '*' is allowed as a wildcard (e.g. "system.*")
584
676
  * @param {function} func the callback - will be of the form (path, value).
585
- * @param {number|null} [every=null] the number of `set` messages to wait before calling callback
586
- *
587
- * @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.
588
685
  * @return {uuidv4} - the uuid of the subscription
589
686
  */
590
- 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 };
591
691
 
592
- return this._subscribe(path, func, null, null, every);
692
+ check_signal(signal);
693
+
694
+ return this._subscribe(path, func, null, null, every, signal);
593
695
  }
594
696
 
595
697
  /**
@@ -603,6 +705,8 @@ class Entangld extends EventEmitter {
603
705
  * @param {function} func the callback - will be of the form (path, value).
604
706
  * @param {(Entangld|null)} [upstream=null] the Engangld next upstream in the path.
605
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.
606
710
  *
607
711
  * @emits {str} subscription - when this datastore is the terminal datastore of a
608
712
  * subscription request, this datastore emits the path
@@ -610,7 +714,7 @@ class Entangld extends EventEmitter {
610
714
  * @throws {TypeError} if path is not a string.
611
715
  * @return {uuidv4} - the uuid of the subscription
612
716
  */
613
- _subscribe(path, func, upstream = null, uuid = null, every = null) {
717
+ _subscribe(path, func, upstream = null, uuid = null, every = null, signal = undefined) {
614
718
 
615
719
 
616
720
  // Supply a UUID if none provided
@@ -621,6 +725,8 @@ class Entangld extends EventEmitter {
621
725
  if (typeof (path) != "string")
622
726
  throw TypeError("path must be a string");
623
727
 
728
+ if (signal !== undefined && signal.aborted) return uuid;
729
+
624
730
  let [obj, /*namespace*/, tree] = this._get_remote_object(path);
625
731
 
626
732
  // Create the subscription object
@@ -644,6 +750,15 @@ class Entangld extends EventEmitter {
644
750
  // Add the new subscription here
645
751
  this._subscriptions.push(new_sub);
646
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
+
647
762
  // Commission any subscribes downstream, if this is a remote subscription
648
763
  if (new_sub.has_downstream) {
649
764
  const msg = Entangld_Message.subscribe(tree, uuid, every);
@@ -682,6 +797,11 @@ class Entangld extends EventEmitter {
682
797
  * path will be deleted, so if you have multiple subscriptions on a single path,
683
798
  * and only want one of them to be removed, you must provide the uuid instead.
684
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
+ *
685
805
  * @param {(String|uuidv4)} path_or_uuid - the path (or uuid) to unwatch.
686
806
  * @throws {EntangldError} if no subscriptions were found.
687
807
  * @return {number} count of subscriptions removed.
@@ -766,9 +886,12 @@ class Entangld extends EventEmitter {
766
886
  const uuids = subscriptions.map(s => s.uuid);
767
887
 
768
888
  // Drop all subscriptions with matching uuid
769
- this._subscriptions = this._subscriptions.filter(
770
- s => !(uuids.includes(s.uuid))
771
- );
889
+ this._subscriptions = this._subscriptions.filter((s) => {
890
+ if (!uuids.includes(s.uuid)) return true;
891
+
892
+ s.unwatch_signal();
893
+ return false;
894
+ });
772
895
 
773
896
  // Notify the downstream for any deleted subscriptions that are remote
774
897
  subscriptions.forEach((s) => {
package/lib/Message.js CHANGED
@@ -1,31 +1,30 @@
1
- const { randomUUID: uuidv4 } = require("node:crypto");
1
+ const uuidv4 = require("./uuid.js");
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/lib/uuid.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A random (v4) uuid, in Node and in browsers.
3
+ *
4
+ * Browsers on secure pages and Node 19 and later have crypto.randomUUID() on
5
+ * globalThis. Node 18 has it in its crypto module, which the "browser" field in
6
+ * package.json leaves out of browser bundles. A browser page that isn't secure
7
+ * (plain http, not localhost) has only crypto.getRandomValues().
8
+ *
9
+ * @author Claude <noreply@anthropic.com>
10
+ * Reviewed-by: Jonathan D. B. Van Schenck <jvschenck@novadynamics.com>
11
+ */
12
+
13
+ /**
14
+ * A v4 uuid from 16 random bytes.
15
+ *
16
+ * @param {Uint8Array} bytes
17
+ * @return {string}
18
+ */
19
+ function format_v4(bytes) {
20
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
21
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
22
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
23
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
24
+ }
25
+
26
+ /**
27
+ * Pick the uuid source for this runtime.
28
+ *
29
+ * @param {object} [web_crypto] - globalThis.crypto, if any.
30
+ * @param {Function} [load_node_crypto] - returns Node's crypto module, or {} in a browser bundle.
31
+ * @return {Function} returns a new uuid string.
32
+ */
33
+ // randomUUID needs Node 14.17 anyway, which has globalThis
34
+ function make_uuid(web_crypto = globalThis.crypto, load_node_crypto = () => require("crypto")) { // eslint-disable-line node/no-unsupported-features/es-builtins
35
+ if ( typeof web_crypto?.randomUUID == "function" ) return () => web_crypto.randomUUID();
36
+ let node_crypto = {};
37
+ try {
38
+ node_crypto = load_node_crypto() || {};
39
+ } catch (err) {
40
+ // Not in Node
41
+ }
42
+ if ( typeof node_crypto.randomUUID == "function" ) return () => node_crypto.randomUUID();
43
+ if ( typeof web_crypto?.getRandomValues == "function" ) {
44
+ return () => format_v4(web_crypto.getRandomValues(new Uint8Array(16)));
45
+ }
46
+ throw new Error("entangld needs crypto.randomUUID or crypto.getRandomValues");
47
+ }
48
+
49
+ module.exports = make_uuid();
50
+ module.exports.make_uuid = make_uuid;
51
+ module.exports.format_v4 = format_v4;
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "entangld",
3
- "version": "2.6.0",
3
+ "version": "2.7.1",
4
4
  "description": "Synchronized key-value stores with RPCs and events",
5
5
  "main": "index.js",
6
+ "browser": {
7
+ "crypto": false
8
+ },
6
9
  "jshintConfig": {
7
10
  "esversion": 6,
8
11
  "unused": true