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/.github/workflows/mocha-tests.yml +1 -1
- package/API.md +754 -0
- package/README.md +212 -799
- package/docs/protocol.md +484 -0
- package/generate_docs.js +3 -3
- package/lib/Datastore.js +144 -20
- package/lib/Message.js +10 -11
- package/lib/Subscription.js +23 -0
- package/lib/error.js +15 -0
- package/package.json +1 -4
- package/test/signals.js +590 -0
- package/test/sockets.js +7 -2
- package/test/tests.js +10 -0
- package/test/timeouts.js +173 -0
- package/.gitmodules +0 -3
- package/README.hbs +0 -190
- package/tags +0 -244
package/lib/Datastore.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
const {
|
|
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
|
-
* @
|
|
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
|
-
|
|
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} [
|
|
585
|
-
*
|
|
586
|
-
* @
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 `
|
|
9
|
-
* are created upstream and passed downstream while `event` and `value`
|
|
10
|
-
* are created downstream and are passed back up.
|
|
11
|
-
*
|
|
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
|
-
* ```
|
|
18
|
-
*
|
|
17
|
+
* ```js
|
|
18
|
+
* upstream._namespaces.get(downstream) + "." + msg.path;
|
|
19
19
|
* ```
|
|
20
|
-
* Since `unsubscribe` messages
|
|
21
|
-
*
|
|
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
|
|
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 {
|
package/lib/Subscription.js
CHANGED
|
@@ -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.
|
|
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
|
}
|