vrpc 3.14.0 → 3.15.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.
@@ -14,7 +14,7 @@ procedure calls (RPC).
14
14
  Author: Dr. Burkhard C. Heisen (https://github.com/heisenware/vrpc)
15
15
 
16
16
  Licensed under the MIT License <http://opensource.org/licenses/MIT>.
17
- Copyright (c) 2018 - 2022 Dr. Burkhard C. Heisen <burkhard.heisen@heisenware.com>.
17
+ Copyright (c) 2018 Dr. Burkhard C. Heisen <burkhard.heisen@heisenware.com>.
18
18
 
19
19
  Permission is hereby granted, free of charge, to any person obtaining a copy
20
20
  of this software and associated documentation files (the "Software"), to deal
@@ -41,8 +41,12 @@ const mqtt = require('mqtt')
41
41
  const EventEmitter = require('events')
42
42
  const SubscribeRetrier = require('./SubscribeRetrier')
43
43
  const ReconnectBackoff = require('./ReconnectBackoff')
44
+ const NamePattern = require('./namePattern')
44
45
 
45
- const VRPC_PROTOCOL_VERSION = 3
46
+ const VRPC_PROTOCOL_VERSION = 4
47
+
48
+ // a function declared `@returns {Registration}` (or a promise of one)
49
+ const DECLARED_REGISTRATION = /^(Promise\.?<\s*)?Registration\s*>?$/
46
50
 
47
51
  // mqtt clients this client ended on purpose: the 'client disconnecting'
48
52
  // workaround must never revive one of them
@@ -159,21 +163,36 @@ class VrpcClient extends EventEmitter {
159
163
  this._client = null
160
164
  this._cachedSubscriptions = {}
161
165
  this._proxies = {}
162
- // refused subscriptions (SUBACK qos=128) are retried until granted
163
- this._subscribeRetrier = new SubscribeRetrier({
164
- subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
165
- log: this._log
166
- })
167
- this._callbackIds = new WeakMap()
166
+ // `<agent>/<class>/<instance>` -> the isolated instances this connection
167
+ // created (no class info lists them)
168
+ this._ownIsolated = new Map()
169
+ // callback -> Map(subscription identity -> topic), see _subscriptionTopic
170
+ this._subscriptionTopics = new WeakMap()
171
+ this._subscriptionCount = 0
168
172
  this._emitterListener = new WeakMap()
169
173
  this._pendingSubscriptions = new Map()
170
174
  this._pendingUnsubscriptions = new Map()
175
+ // agents this client declares its subscriptions to again once they
176
+ // announce themselves: after this client's own reconnect, and after the
177
+ // agent's loss (the loss heals)
178
+ this._redeclare = new Set()
179
+ // agent -> withdrawals that could not be sent yet ({ sub, context })
180
+ this._pendingWithdrawals = new Map()
181
+ // the client's own inbox is subscribed (answers can arrive)
182
+ this._inboxReady = false
183
+ // labels callbacks carry towards the agent's list of registrations
184
+ this._subscriberLabels = new WeakMap()
171
185
  if (log === 'console') {
172
186
  this._log = console
173
187
  this._log.debug = () => {}
174
188
  } else {
175
189
  this._log = log
176
190
  }
191
+ // refused subscriptions are retried until granted
192
+ this._subscribeRetrier = new SubscribeRetrier({
193
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
194
+ log: this._log
195
+ })
177
196
  }
178
197
 
179
198
  /**
@@ -289,6 +308,9 @@ class VrpcClient extends EventEmitter {
289
308
  // Clear the cache to ensure a fresh state upon reconnection
290
309
  this._agents = {}
291
310
  this._proxies = {}
311
+ // the agents delete this connection's isolated instances once its
312
+ // will is out
313
+ this._ownIsolated.clear()
292
314
  this.emit('offline')
293
315
  })
294
316
 
@@ -306,6 +328,14 @@ class VrpcClient extends EventEmitter {
306
328
  })
307
329
 
308
330
  this._client.on('connect', async () => {
331
+ // the subscriptions this client holds are declared again, to every
332
+ // agent announcing protocol 4, once the inbox stands (an agent that
333
+ // saw this connection go has withdrawn them; one that did not keeps
334
+ // them, declaring again is a no-op there)
335
+ this._inboxReady = false
336
+ for (const sub of Object.values(this._cachedSubscriptions)) {
337
+ this._redeclare.add(sub.agent)
338
+ }
309
339
  // forward the good news
310
340
  this.emit('connect')
311
341
  // everything is subscribed afresh below: pending retries are moot
@@ -325,6 +355,13 @@ class VrpcClient extends EventEmitter {
325
355
  }
326
356
  // Single wildcard catches BOTH RPC responses (length 3) AND events (length 4)
327
357
  await this._mqttSubscribe(`${this._vrpcConnectionId}/#`)
358
+ this._inboxReady = true
359
+ for (const agent of Array.from(this._redeclare)) {
360
+ this._redeclareTo(agent)
361
+ }
362
+ for (const agent of Array.from(this._pendingWithdrawals.keys())) {
363
+ this._flushWithdrawals(agent)
364
+ }
328
365
 
329
366
  // Re-subscribe to all cached event topics
330
367
  this._log.debug('Restoring event subscriptions after reconnect...')
@@ -351,13 +388,20 @@ class VrpcClient extends EventEmitter {
351
388
  }
352
389
  // AgentInfo message
353
390
  if (klass === '__agentInfo__') {
354
- const { status, hostname, version } = JSON.parse(message.toString())
391
+ const { status, hostname, version, v } = JSON.parse(
392
+ message.toString()
393
+ )
355
394
  this._createIfNotExist(agent)
356
395
  this._agents[agent].status = status
357
396
  this._agents[agent].hostname = hostname
358
397
  this._agents[agent].version = version
398
+ // the agent's protocol version (undefined before 3)
399
+ this._agents[agent].v = v
359
400
  if (status === 'offline') {
360
- this._clearCachedSubscriptions({ lostAgent: agent })
401
+ this._loseAgent(agent, v)
402
+ } else {
403
+ this._redeclareTo(agent)
404
+ this._flushWithdrawals(agent)
361
405
  }
362
406
  this.emit('agent', { domain, agent, status, hostname, version })
363
407
  // ClassInfo message
@@ -401,8 +445,8 @@ class VrpcClient extends EventEmitter {
401
445
  })
402
446
  // RPC message
403
447
  } else {
404
- const { i, a, e, r } = JSON.parse(message.toString())
405
- this._eventEmitter.emit(i, { a, e, r })
448
+ const { i, a, e, r, u, end } = JSON.parse(message.toString())
449
+ this._eventEmitter.emit(i, { a, e, r, u, end })
406
450
  }
407
451
  } catch (err) {
408
452
  this._log.error(
@@ -554,6 +598,14 @@ class VrpcClient extends EventEmitter {
554
598
  v: VRPC_PROTOCOL_VERSION
555
599
  }
556
600
  const proxy = await this._getProxy(agent, className, json)
601
+ // no class info lists an isolated instance: its creator remembers it
602
+ if (isIsolated) {
603
+ this._ownIsolated.set(`${agent}/${className}/${instance}`, {
604
+ agent,
605
+ className,
606
+ instance
607
+ })
608
+ }
557
609
  if (instance && cacheProxy) {
558
610
  this._proxies[`${agent}/${className}/${instance}`] = {
559
611
  agent,
@@ -618,6 +670,9 @@ class VrpcClient extends EventEmitter {
618
670
  this._mqttPublish(topic, JSON.stringify(json))
619
671
  delete this._proxies[`${agent}/${className}/${instance}`]
620
672
  const deleted = await this._handleAgentAnswer(json, agent)
673
+ if (deleted === true) {
674
+ this._ownIsolated.delete(`${agent}/${className}/${instance}`)
675
+ }
621
676
  // the answer and the class info that no longer lists the instance
622
677
  // travel on different topics, and a broker need not keep their order:
623
678
  // what this client deleted is gone for it at once
@@ -675,6 +730,7 @@ class VrpcClient extends EventEmitter {
675
730
  v: VRPC_PROTOCOL_VERSION
676
731
  }
677
732
  const topic = `${this._domain}/${agent}/${className}/__static__/${functionName}`
733
+ this._declare(json, topic)
678
734
  await this._waitUntilClassIsOnline(agent, className)
679
735
  this._mqttPublish(topic, JSON.stringify(json))
680
736
  return this._handleAgentAnswer(json, agent)
@@ -686,29 +742,61 @@ class VrpcClient extends EventEmitter {
686
742
  * registrations. In this case the instanceId of the emitter is injected as
687
743
  * first argument of any event callback.
688
744
  *
689
- * NOTE: When no agent was specified as class default and no agent is
690
- * specified when calling this function, callAll will act on the requested
691
- * class across all available agents. The same is true when explicitly using a
692
- * wildcard (*) as agent value.
745
+ * `instances` selects the instances: a name pattern (`line-*`, where `*`
746
+ * matches any run of characters) or a list of names and patterns. The
747
+ * agent calls the selected ones only and answers for those - one request,
748
+ * however many instances. An agent that does not select instances
749
+ * (before protocol 4) gets one call per selected instance instead; a
750
+ * subscription cannot be split like that and is refused there.
751
+ *
752
+ * `agent` may name several agents as well: a pattern (`edge-*`, `*` by
753
+ * default) asks every online agent that offers the class and matches, a
754
+ * list asks each agent listed. They are asked all at once, and every
755
+ * entry names its agent; an agent that fails as a whole answers an error
756
+ * for each of its instances the client knows - or one entry with the
757
+ * error alone when it knows none - and the other agents' answers stand.
693
758
  *
694
759
  * @param {Object} options
695
- * @param {String} options.className Name of the static function's class
696
- * @param {Array} [options.args] Positional arguments of the static function
697
- * call
698
- * @param {String} [options.agent] Agent name. If not provided class default
699
- * is used
700
- * @returns {Promise<Object[]>} An array of objects `{ id, val, err }`
701
- * carrying the instance id, the return value and potential errors
760
+ * @param {String} options.className Name of the class
761
+ * @param {String} options.functionName Name of the member function
762
+ * @param {Array} [options.args] Positional arguments of the call
763
+ * @param {String|Array.<String>} [options.agent] Agent name, pattern or
764
+ * list of names. If not provided class default is used
765
+ * @param {String|Array.<String>} [options.instances] The instances to call:
766
+ * a name pattern, or a list of names and patterns (all if not provided)
767
+ * @returns {Promise<Object[]>} An array of objects `{ id, val }` or
768
+ * `{ id, err }` carrying the instance id and its return value or error,
769
+ * and `agent` when the call addressed several agents
702
770
  */
703
771
  async callAll ({
704
772
  className,
705
773
  functionName,
706
774
  args = [],
707
- agent = this._agent
775
+ agent = this._agent,
776
+ instances
708
777
  } = {}) {
709
778
  if (!this._isConnected()) {
710
779
  throw new Error('Client is not connected')
711
780
  }
781
+ if (instances !== undefined && !NamePattern.isSelection(instances)) {
782
+ throw new Error(
783
+ `Invalid instances: ${JSON.stringify(instances)} (a name pattern, or a list of names and patterns)`
784
+ )
785
+ }
786
+ // a selection of everything is no selection
787
+ const selection =
788
+ instances === undefined || NamePattern.selectsAll(instances)
789
+ ? undefined
790
+ : instances
791
+ if (Array.isArray(agent) || agent.includes('*')) {
792
+ return this._callAllAgents({
793
+ className,
794
+ functionName,
795
+ args,
796
+ agents: agent,
797
+ selection
798
+ })
799
+ }
712
800
  const json = {
713
801
  c: className,
714
802
  f: functionName,
@@ -717,40 +805,418 @@ class VrpcClient extends EventEmitter {
717
805
  s: this._vrpcConnectionId,
718
806
  v: VRPC_PROTOCOL_VERSION
719
807
  }
720
- if (agent === '*') {
721
- const result = []
722
- const agents = this.getAvailableAgents()
723
- for (const agent of agents) {
724
- const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
725
- const wrapped = await this._wrapArguments({
808
+ let special = '__callAll__'
809
+ if (selection !== undefined) {
810
+ await this._waitUntilClassIsOnline(agent, className)
811
+ if (!this._selectsInstances(agent, className)) {
812
+ return this._callSelected({
726
813
  agent,
727
814
  className,
728
815
  functionName,
729
- args
816
+ args,
817
+ selection
730
818
  })
731
- if (!wrapped) continue // using cache
732
- json.a = [functionName, ...wrapped]
733
- await this._waitUntilClassIsOnline(agent, className)
734
- this._mqttPublish(topic, JSON.stringify(json))
735
- const tmp = await this._handleAgentAnswer(json, agent)
736
- result.push(...tmp)
737
819
  }
738
- return result
820
+ special = '__callMatching__'
739
821
  }
740
- const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
822
+ const topic = `${this._domain}/${agent}/${className}/__static__/${special}`
741
823
  const wrapped = await this._wrapArguments({
742
824
  agent,
743
825
  className,
744
826
  functionName,
745
- args
827
+ args,
828
+ isCallAll: true,
829
+ selection
746
830
  })
747
831
  if (!wrapped) return // using cache
748
- json.a = [functionName, ...wrapped]
832
+ json.a =
833
+ selection === undefined
834
+ ? [functionName, ...wrapped]
835
+ : [selection, functionName, ...wrapped]
836
+ this._declare(json, topic)
837
+ await this._waitUntilClassIsOnline(agent, className)
838
+ this._mqttPublish(topic, JSON.stringify(json))
839
+ return this._handleAgentAnswer(json, agent)
840
+ }
841
+
842
+ /**
843
+ * callAll on several agents: those a list names, or every online agent
844
+ * that offers the class and whose name a pattern matches - all at once,
845
+ * every entry naming its agent
846
+ */
847
+ async _callAllAgents ({ className, functionName, args, agents, selection }) {
848
+ const names = Array.isArray(agents)
849
+ ? agents
850
+ : this.getAvailableAgents().filter(
851
+ name =>
852
+ this._agents[name].classes[className] &&
853
+ NamePattern.matches(agents, name)
854
+ )
855
+ const answers = await Promise.all(
856
+ names.map(name =>
857
+ this.callAll({
858
+ className,
859
+ functionName,
860
+ args,
861
+ agent: name,
862
+ instances: selection
863
+ }).then(
864
+ result => (result || []).map(entry => ({ agent: name, ...entry })),
865
+ err => {
866
+ const error = { message: err.message }
867
+ const known = this.getAvailableInstances({ className, agent: name })
868
+ .filter(id => selection === undefined || NamePattern.selects(selection, id))
869
+ return known.length > 0
870
+ ? known.map(id => ({ agent: name, id, err: error }))
871
+ : [{ agent: name, err: error }]
872
+ }
873
+ )
874
+ )
875
+ )
876
+ return answers.flat()
877
+ }
878
+
879
+ // whether an agent selects instances itself: its class info lists
880
+ // __callMatching__ (agents of protocol 4)
881
+ _selectsInstances (agent, className) {
882
+ const info = this._agents[agent] && this._agents[agent].classes[className]
883
+ return (
884
+ !!info &&
885
+ info.staticFunctions.some(
886
+ f => this._stripSignature(f) === '__callMatching__'
887
+ )
888
+ )
889
+ }
890
+
891
+ /**
892
+ * A selection towards an agent that does not select instances: one call
893
+ * per selected instance, answered as callAll answers. A subscription is
894
+ * refused: its events, one per instance, would not name their instance.
895
+ */
896
+ async _callSelected ({ agent, className, functionName, args, selection }) {
897
+ if (args.some(x => this._isFunction(x) || this._isEmitter(x))) {
898
+ throw new Error(
899
+ `Agent ${agent} does not select instances for callbacks and events (its class info lists no __callMatching__): update it, or call without instances`
900
+ )
901
+ }
902
+ const selected = this.getAvailableInstances({ className, agent }).filter(
903
+ id => NamePattern.selects(selection, id)
904
+ )
905
+ return Promise.all(
906
+ selected.map(async id => {
907
+ try {
908
+ const proxy = await this.getInstance(id, { agent, className })
909
+ return { id, val: await proxy[functionName](...args) }
910
+ } catch (err) {
911
+ return { id, err: { message: err.message } }
912
+ }
913
+ })
914
+ )
915
+ }
916
+
917
+ /**
918
+ * Withdraws subscriptions.
919
+ *
920
+ * A subscription is what an event function call (or `on`) declared with a
921
+ * callback: one per target, function, arguments and callback, named by
922
+ * its subscription id (see getSubscriptions). Withdrawing it stops its events at once, and
923
+ * every agent holding it is told by id - however it was declared (a proxy,
924
+ * callStatic, callAll on one agent or on all of them). The agent releases
925
+ * the registration when the last subscription leaves.
926
+ *
927
+ * With `instance`, only what is held on that instance is withdrawn: a
928
+ * subscription declared there ends, and a callAll subscription - one id
929
+ * on every instance of its class - stops at that instance alone while
930
+ * the others keep delivering. The client remembers such a withdrawal and
931
+ * applies it again after its own reconnect, until the subscription is
932
+ * declared anew (a declaration names what is wanted now).
933
+ *
934
+ * Agents before protocol 4 cannot withdraw by id: an `on` subscription is
935
+ * removed there as before (removeListener), an `on<Name>` one through its
936
+ * `off<Name>` twin when the class has one; anything else ends locally
937
+ * (a callAll subscription withdrawn at one instance stops delivering its
938
+ * events there).
939
+ *
940
+ * @param {String|Array.<String>|Function|Object} target A subscription id
941
+ * (or several), a callback (all its subscriptions), or an
942
+ * `{ emitter, event }` contract (all of its subscriptions)
943
+ * @param {Object} [options]
944
+ * @param {String} [options.instance] Withdraw at this instance only
945
+ * @returns {Promise<Boolean>} true if anything was withdrawn
946
+ */
947
+ async unsubscribe (target, { instance } = {}) {
948
+ const all = Object.values(this._cachedSubscriptions)
949
+ let subs
950
+ if (typeof target === 'string' || Array.isArray(target)) {
951
+ const ids = Array.isArray(target) ? target : [target]
952
+ subs = ids
953
+ .map(id =>
954
+ this._cachedSubscriptions[id.startsWith('__e__') ? id.slice(5) : id]
955
+ )
956
+ .filter(Boolean)
957
+ } else if (this._isFunction(target)) {
958
+ subs = all.filter(sub => sub.callback === target)
959
+ } else if (this._isEmitter(target)) {
960
+ const callback = this._emitterCallback(target, { readOnly: true })
961
+ subs = callback ? all.filter(sub => sub.callback === callback) : []
962
+ } else {
963
+ throw new Error(
964
+ 'unsubscribe expects a subscription id, a callback or an { emitter, event } contract'
965
+ )
966
+ }
967
+ // at one instance: a callAll subscription stops there, one declared on
968
+ // that instance ends, any other is not held there
969
+ const atInstance = []
970
+ if (instance !== undefined && instance !== null) {
971
+ atInstance.push(...subs.filter(sub => sub.callAll))
972
+ subs = subs.filter(sub => !sub.callAll && sub.instance === instance)
973
+ }
974
+ if (subs.length === 0 && atInstance.length === 0) return false
975
+ // no event reaches the consumer from here on
976
+ subs.forEach(sub => this._dropSubscription(sub))
977
+ atInstance.forEach(sub => sub.withdrawn.add(instance))
978
+ const byAgent = new Map()
979
+ const entries = (agent, sub, context) => {
980
+ if (!byAgent.has(agent)) byAgent.set(agent, [])
981
+ byAgent.get(agent).push({ sub, context })
982
+ }
983
+ for (const sub of subs) entries(sub.agent, sub)
984
+ for (const sub of atInstance) entries(sub.agent, sub, instance)
985
+ const sends = []
986
+ for (const [agent, agentEntries] of byAgent) {
987
+ sends.push(this._withdrawAt(agent, agentEntries))
988
+ }
989
+ await Promise.allSettled(sends)
990
+ return true
991
+ }
992
+
993
+ /**
994
+ * Withdraws subscriptions at their agent: at once when the agent is online
995
+ * and this client connected, else as soon as both are again - an agent
996
+ * that only lost its connection still holds them.
997
+ *
998
+ * @param {String} agent
999
+ * @param {Array.<Object>} entries `{ sub, context }`, context (an
1000
+ * instance) for a withdrawal at one instance only
1001
+ */
1002
+ _withdrawAt (agent, entries) {
1003
+ const info = this._agents[agent]
1004
+ if (!this._isConnected() || !info || info.status !== 'online') {
1005
+ const pending = this._pendingWithdrawals.get(agent) || []
1006
+ pending.push(...entries)
1007
+ this._pendingWithdrawals.set(agent, pending)
1008
+ return Promise.resolve()
1009
+ }
1010
+ if (info.v >= 4) return this._sendUnsubscribe(agent, entries)
1011
+ // an older agent cannot withdraw at one instance: local only
1012
+ return Promise.allSettled(
1013
+ entries
1014
+ .filter(({ context }) => context === undefined)
1015
+ .map(({ sub }) => this._sendLegacyRemoval(sub))
1016
+ )
1017
+ }
1018
+
1019
+ // sends the withdrawals an agent missed, once it and this client are online
1020
+ _flushWithdrawals (agent) {
1021
+ const pending = this._pendingWithdrawals.get(agent)
1022
+ const info = this._agents[agent]
1023
+ if (!pending || !this._isConnected() || !info || info.status !== 'online') {
1024
+ return
1025
+ }
1026
+ this._pendingWithdrawals.delete(agent)
1027
+ this._withdrawAt(agent, pending)
1028
+ }
1029
+
1030
+ /**
1031
+ * An agent went offline: a loss, not an end. Its subscriptions are lost
1032
+ * until it serves again; an agent of protocol 4 gets them declared again
1033
+ * then (the loss heals - one that kept them changes nothing). Towards an
1034
+ * older agent a declaration again would register the handler twice: its
1035
+ * subscriptions are dropped, as they always were.
1036
+ */
1037
+ _loseAgent (agent, v) {
1038
+ if (!(v >= 4)) {
1039
+ this._redeclare.delete(agent)
1040
+ this._clearCachedSubscriptions({ lostAgent: agent })
1041
+ return
1042
+ }
1043
+ this._redeclare.add(agent)
1044
+ for (const sub of Object.values(this._cachedSubscriptions)) {
1045
+ if (sub.agent !== agent || sub.lost) continue
1046
+ sub.lost = true
1047
+ sub.confirmed = false
1048
+ this.emit('lost', sub.id, 'agentOffline', this._subscriptionInfo(sub))
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Lists the subscriptions this client holds.
1054
+ *
1055
+ * @param {Object} [filter]
1056
+ * @param {Function|Object} [filter.callback] Only the subscriptions of this
1057
+ * callback, or of this `{ emitter, event }` contract
1058
+ * @returns {Array.<Object>} `{ id, agent, className, instance,
1059
+ * functionName, event, callAll, confirmed, lost }` per subscription;
1060
+ * `confirmed` once a protocol-4 agent answered that it holds it, `lost`
1061
+ * while its agent is offline (it is declared again once the agent is back)
1062
+ */
1063
+ getSubscriptions ({ callback } = {}) {
1064
+ if (callback && this._isEmitter(callback)) {
1065
+ callback = this._emitterCallback(callback, { readOnly: true })
1066
+ if (!callback) return []
1067
+ }
1068
+ return Object.values(this._cachedSubscriptions)
1069
+ .filter(sub => !callback || sub.callback === callback)
1070
+ .map(sub => ({
1071
+ id: sub.id,
1072
+ ...this._subscriptionInfo(sub),
1073
+ confirmed: sub.confirmed,
1074
+ lost: sub.lost
1075
+ }))
1076
+ }
1077
+
1078
+ /**
1079
+ * Gives the subscriptions of a callback a label, which agents list with
1080
+ * their registrations (`__registrations__`), e.g. who listens.
1081
+ *
1082
+ * @param {Function|Object} callback The callback, or an `{ emitter, event }`
1083
+ * contract (labelled before or after it subscribes)
1084
+ * @param {String} label The label (sent with every declaration)
1085
+ */
1086
+ setSubscriberLabel (callback, label) {
1087
+ if (this._isEmitter(callback)) callback = this._emitterCallback(callback)
1088
+ this._subscriberLabels.set(callback, label)
1089
+ }
1090
+
1091
+ /**
1092
+ * Lists the registrations of an instance (or, without an instance, the
1093
+ * static ones of a class): function, arguments, subscriptions with their
1094
+ * labels, events and events per second. Needs a protocol-4 agent.
1095
+ *
1096
+ * @param {Object} options
1097
+ * @param {String} options.className Class name
1098
+ * @param {String} [options.instance] Instance name
1099
+ * @param {String} [options.agent] Agent name. If not provided class default is used
1100
+ * @returns {Promise<Array.<Object>>} The registrations
1101
+ */
1102
+ async getRegistrations ({ className, instance, agent = this._agent } = {}) {
1103
+ return this._callProtocol({ className, instance, agent }, '__registrations__', [])
1104
+ }
1105
+
1106
+ /**
1107
+ * Ends a registration for all its subscriptions; each subscriber learns
1108
+ * that it ended and by whom ('ended' event). Needs a protocol-4 agent.
1109
+ *
1110
+ * @param {Object} options
1111
+ * @param {String} options.className Class name
1112
+ * @param {String} [options.instance] Instance name (static registration without)
1113
+ * @param {String} [options.agent] Agent name. If not provided class default is used
1114
+ * @param {String} options.functionName The event function
1115
+ * @param {Array} [options.args] Its arguments without the handlers, as listed
1116
+ * @param {String} [options.by] Who ends it, told to every subscriber
1117
+ * @returns {Promise<Boolean>} Whether such a registration existed
1118
+ */
1119
+ async endRegistration ({
1120
+ className,
1121
+ instance,
1122
+ agent = this._agent,
1123
+ functionName,
1124
+ args = [],
1125
+ by = null
1126
+ } = {}) {
1127
+ return this._callProtocol({ className, instance, agent }, '__end__', [
1128
+ functionName,
1129
+ args,
1130
+ by
1131
+ ])
1132
+ }
1133
+
1134
+ async _callProtocol ({ className, instance, agent }, f, a) {
1135
+ if (!this._isConnected()) {
1136
+ throw new Error('Client is not connected')
1137
+ }
1138
+ if (instance && (!agent || agent === '*' || !className)) {
1139
+ const data = await this._getInstanceData(instance, { agent, className })
1140
+ agent = data.agent
1141
+ className = data.className
1142
+ }
1143
+ if (!agent || agent === '*') throw new Error('Agent must be specified')
749
1144
  await this._waitUntilClassIsOnline(agent, className)
1145
+ if (!(this._agents[agent].v >= 4)) {
1146
+ throw new Error(`Agent "${agent}" does not speak protocol 4`)
1147
+ }
1148
+ const json = {
1149
+ c: instance || className,
1150
+ f,
1151
+ a,
1152
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1153
+ s: this._vrpcConnectionId,
1154
+ v: VRPC_PROTOCOL_VERSION
1155
+ }
1156
+ const topic = `${this._domain}/${agent}/${className}/${instance || '__static__'}/${f}`
750
1157
  this._mqttPublish(topic, JSON.stringify(json))
751
1158
  return this._handleAgentAnswer(json, agent)
752
1159
  }
753
1160
 
1161
+ // withdraws by id on a protocol-4 agent, wherever it holds them (or, with
1162
+ // a context, at that instance only)
1163
+ async _sendUnsubscribe (agent, entries) {
1164
+ const className = entries[0].sub.className
1165
+ const json = {
1166
+ c: className,
1167
+ f: '__unsubscribe__',
1168
+ a: entries.map(({ sub, context }) =>
1169
+ context === undefined ? sub.id : { id: sub.id, context }
1170
+ ),
1171
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1172
+ s: this._vrpcConnectionId,
1173
+ v: VRPC_PROTOCOL_VERSION
1174
+ }
1175
+ const topic = `${this._domain}/${agent}/${className}/__static__/__unsubscribe__`
1176
+ this._mqttPublish(topic, JSON.stringify(json))
1177
+ try {
1178
+ await this._handleAgentAnswer(json, agent)
1179
+ } catch (err) {
1180
+ this._log.debug(`Withdrawing subscriptions failed: ${err.message}`)
1181
+ }
1182
+ }
1183
+
1184
+ // an agent before protocol 4: removeListener, or the class's off twin
1185
+ async _sendLegacyRemoval (sub) {
1186
+ if (sub.callAll || !sub.instance) return
1187
+ let f
1188
+ let a
1189
+ if (sub.functionName === 'on' || sub.functionName === 'addListener') {
1190
+ f = 'removeListener'
1191
+ a = [sub.event, sub.id]
1192
+ } else {
1193
+ const twin = sub.functionName.replace(/^on(?=[A-Z])/, 'off')
1194
+ const info =
1195
+ this._agents[sub.agent] &&
1196
+ this._agents[sub.agent].classes[sub.className]
1197
+ const functions = info ? info.memberFunctions.map(this._stripSignature) : []
1198
+ if (twin === sub.functionName || !functions.includes(twin)) return
1199
+ if (!sub.declaration) return
1200
+ f = twin
1201
+ a = sub.declaration.a
1202
+ }
1203
+ const json = {
1204
+ c: sub.instance,
1205
+ f,
1206
+ a,
1207
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1208
+ s: this._vrpcConnectionId,
1209
+ v: VRPC_PROTOCOL_VERSION
1210
+ }
1211
+ const topic = `${this._domain}/${sub.agent}/${sub.className}/${sub.instance}/${f}`
1212
+ this._mqttPublish(topic, JSON.stringify(json))
1213
+ try {
1214
+ await this._handleAgentAnswer(json, sub.agent)
1215
+ } catch (err) {
1216
+ this._log.debug(`Removing a subscription failed: ${err.message}`)
1217
+ }
1218
+ }
1219
+
754
1220
  /**
755
1221
  * Retrieves all information about the currently available components.
756
1222
  *
@@ -906,8 +1372,10 @@ class VrpcClient extends EventEmitter {
906
1372
  this._mqttPublish(agentTopic, null, { retain: true })
907
1373
  const classes = this.getAvailableClasses({ agent, mustBeOnline: false })
908
1374
  for (const className of classes) {
909
- const classTopic = `${this._domain}/${agent}/${className}/__classInfo__`
910
- this._mqttPublish(classTopic, null, { retain: true })
1375
+ for (const info of ['__classInfo__', '__classInfoConcise__']) {
1376
+ const classTopic = `${this._domain}/${agent}/${className}/${info}`
1377
+ this._mqttPublish(classTopic, null, { retain: true })
1378
+ }
911
1379
  }
912
1380
  // Synchronize local cache
913
1381
  delete this._agents[agent]
@@ -934,6 +1402,11 @@ class VrpcClient extends EventEmitter {
934
1402
  )
935
1403
  this._eventEmitter.removeAllListeners()
936
1404
  this.removeAllListeners()
1405
+ // the agents withdraw what this connection held: nothing to declare
1406
+ // again, nothing left to withdraw
1407
+ this._cachedSubscriptions = {}
1408
+ this._redeclare.clear()
1409
+ this._pendingWithdrawals.clear()
937
1410
  const client = this._client
938
1411
  this._client = null // Prevent race conditions during shutdown
939
1412
  return new Promise(resolve =>
@@ -966,13 +1439,6 @@ class VrpcClient extends EventEmitter {
966
1439
  return this._client && this._client.connected
967
1440
  }
968
1441
 
969
- _getCallbackId (callback, { readOnly = false } = {}) {
970
- if (!this._callbackIds.has(callback)) {
971
- if (readOnly) return null
972
- this._callbackIds.set(callback, nanoid(8))
973
- }
974
- return this._callbackIds.get(callback)
975
- }
976
1442
 
977
1443
  _generateUserName () {
978
1444
  return `${this._domain}:client@${os.hostname()}-${os.platform()}-js`
@@ -1045,31 +1511,36 @@ class VrpcClient extends EventEmitter {
1045
1511
  client.subscribe(
1046
1512
  topic,
1047
1513
  { qos: this._qos, ...options },
1048
- (err, granted) => {
1049
- if (err) {
1514
+ (subscribeError, subs) => {
1515
+ const { refused, granted, error } = SubscribeRetrier.outcome(
1516
+ subscribeError,
1517
+ subs
1518
+ )
1519
+ if (error) {
1050
1520
  this._log.warn(
1051
- `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
1521
+ `Could not subscribe to topic(s): '${topic}', because: ${error.message}`
1052
1522
  )
1053
1523
  if (
1054
- err.message === 'client disconnecting' &&
1524
+ error.message === 'client disconnecting' &&
1055
1525
  this._isRevivable(client)
1056
1526
  ) {
1057
1527
  // This most certainly relates to a bug in MQTT.js (#1284)
1058
- const msg = `Failed to subscribe, because: ${err.message}`
1528
+ const msg = `Failed to subscribe, because: ${error.message}`
1059
1529
  this._log.error(msg)
1060
- this.emit('error', err)
1530
+ this.emit('error', error)
1061
1531
  this._log.info('Forcing reconnect now...')
1062
1532
  client.disconnecting = false
1063
1533
  client.reconnect()
1064
1534
  reject(new Error(msg))
1065
- } else if (err.message === 'client disconnecting') {
1066
- reject(err)
1535
+ } else {
1536
+ // the connection broke under the subscribe: the next connect
1537
+ // subscribes everything afresh
1538
+ reject(error)
1067
1539
  }
1068
1540
  } else {
1541
+ let err
1069
1542
  const topicArray = Array.isArray(topic) ? topic : [topic]
1070
- const erroneousGranted = granted
1071
- .filter(x => x.qos === 128)
1072
- .map(x => x.topic)
1543
+ const erroneousGranted = refused
1073
1544
  if (erroneousGranted.length > 0) {
1074
1545
  err = new Error(
1075
1546
  `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
@@ -1080,9 +1551,7 @@ class VrpcClient extends EventEmitter {
1080
1551
  this.emit('error', err)
1081
1552
  this._subscribeRetrier.schedule(erroneousGranted, options)
1082
1553
  }
1083
- this._subscribeRetrier.granted(
1084
- granted.filter(x => x.qos !== 128).map(x => x.topic)
1085
- )
1554
+ this._subscribeRetrier.granted(granted.map(x => x.topic))
1086
1555
  const reducedQos = granted.filter(x => x.qos < this._qos)
1087
1556
  if (reducedQos.length > 0) {
1088
1557
  err = new Error(
@@ -1099,7 +1568,7 @@ class VrpcClient extends EventEmitter {
1099
1568
  this._log.warn(err.message)
1100
1569
  this.emit('error', err)
1101
1570
  }
1102
- if (granted.length === 0) {
1571
+ if (granted.length === 0 && refused.length === 0) {
1103
1572
  this._log.debug(`Already subscribed to: ${topic}`)
1104
1573
  }
1105
1574
  resolve()
@@ -1160,15 +1629,19 @@ class VrpcClient extends EventEmitter {
1160
1629
  if (this._hasClassOnline(agent, className)) return
1161
1630
  return new Promise((resolve, reject) => {
1162
1631
  let timer = null
1632
+ // the class info and the agent info travel on topics of their own and
1633
+ // may come in either order: each of them can complete the picture
1163
1634
  const checkClass = () => {
1164
1635
  if (this._hasClassOnline(agent, className)) {
1165
1636
  if (timer) clearTimeout(timer)
1166
1637
  this.removeListener('class', checkClass)
1638
+ this.removeListener('agent', checkClass)
1167
1639
  resolve()
1168
1640
  }
1169
1641
  }
1170
1642
  timer = setTimeout(() => {
1171
1643
  this.removeListener('class', checkClass)
1644
+ this.removeListener('agent', checkClass)
1172
1645
  reject(
1173
1646
  new Error(
1174
1647
  `Connection to class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
@@ -1176,6 +1649,7 @@ class VrpcClient extends EventEmitter {
1176
1649
  )
1177
1650
  }, this._timeout)
1178
1651
  this.on('class', checkClass)
1652
+ this.on('agent', checkClass)
1179
1653
  })
1180
1654
  }
1181
1655
 
@@ -1214,23 +1688,18 @@ class VrpcClient extends EventEmitter {
1214
1688
  throw new Error('VRPC does not support removing all listeners')
1215
1689
  }
1216
1690
 
1217
- const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1218
- topic => {
1219
- const sub = this._cachedSubscriptions[topic]
1220
- return sub.instance === instance && sub.event === eventName
1221
- }
1222
- )
1223
-
1224
- obsoleteTopics.forEach(topic => {
1225
- const id = `__e__${topic}`
1226
- const sub = this._cachedSubscriptions[topic]
1227
-
1228
- sub.listeners.forEach(({ handler }) => {
1229
- this._eventEmitter.removeListener(id, handler)
1230
- })
1231
-
1232
- delete this._cachedSubscriptions[topic]
1233
- })
1691
+ // this client's subscriptions of that event on this instance end;
1692
+ // a protocol-4 agent withdraws exactly those, never touching the
1693
+ // emitter's other listeners
1694
+ Object.values(this._cachedSubscriptions)
1695
+ .filter(
1696
+ sub =>
1697
+ sub.agent === agent &&
1698
+ sub.className === className &&
1699
+ sub.instance === instance &&
1700
+ sub.event === eventName
1701
+ )
1702
+ .forEach(sub => this._dropSubscription(sub))
1234
1703
  try {
1235
1704
  const json = {
1236
1705
  c: instance,
@@ -1276,6 +1745,7 @@ class VrpcClient extends EventEmitter {
1276
1745
  s: this._vrpcConnectionId,
1277
1746
  v: VRPC_PROTOCOL_VERSION
1278
1747
  }
1748
+ this._declare(json, `${targetTopic}/${functionName}`)
1279
1749
  this._mqttPublish(
1280
1750
  `${targetTopic}/${functionName}`,
1281
1751
  JSON.stringify(json)
@@ -1311,6 +1781,7 @@ class VrpcClient extends EventEmitter {
1311
1781
  s: this._vrpcConnectionId,
1312
1782
  v: VRPC_PROTOCOL_VERSION
1313
1783
  }
1784
+ this._declare(json, `${targetTopic}/${functionName}`)
1314
1785
  this._mqttPublish(
1315
1786
  `${targetTopic}/${functionName}`,
1316
1787
  JSON.stringify(json)
@@ -1322,22 +1793,37 @@ class VrpcClient extends EventEmitter {
1322
1793
  )
1323
1794
  }
1324
1795
  }
1325
- proxy.vrpcOff = functionName => {
1326
- const topic = `${this._domain}/${agent}/${className}/${instance}-${functionName}`
1327
- const id = `__e__${topic}`
1328
- this._eventEmitter.removeAllListeners(id)
1796
+ // withdraws this client's subscriptions of a function on this instance
1797
+ proxy.vrpcOff = async functionName => {
1798
+ const ids = Object.values(this._cachedSubscriptions)
1799
+ .filter(
1800
+ sub =>
1801
+ sub.agent === agent &&
1802
+ sub.className === className &&
1803
+ sub.instance === instance &&
1804
+ sub.functionName === functionName
1805
+ )
1806
+ .map(sub => sub.id)
1807
+ if (ids.length === 0) return false
1808
+ return this.unsubscribe(ids)
1329
1809
  }
1330
1810
  }
1331
1811
  return proxy
1332
1812
  }
1333
1813
 
1334
- async _handleAgentAnswer ({ i, c, f }, agent) {
1814
+ async _handleAgentAnswer ({ i, c, f, a }, agent) {
1335
1815
  return new Promise((resolve, reject) => {
1336
1816
  const msg = `Function call "${c}::${f}()" on agent "${agent}" timed out (> ${this._timeout} ms)`
1337
1817
  const timer = setTimeout(() => {
1338
1818
  this._eventEmitter.removeAllListeners(i)
1339
1819
  reject(new Error(msg))
1340
1820
  }, this._timeout)
1821
+ // a protocol-4 agent that answers a declaring call without an error
1822
+ // holds every subscription the call carried
1823
+ const confirm = () => {
1824
+ const info = this._agents[agent]
1825
+ if (info && info.v >= 4) this._confirmSubscriptions(a)
1826
+ }
1341
1827
  this._eventEmitter.once(i, data => {
1342
1828
  clearTimeout(timer)
1343
1829
  if (data.e) {
@@ -1356,10 +1842,12 @@ class VrpcClient extends EventEmitter {
1356
1842
  new Error(`[vrpc ${agent}-${c}-${f}]: ${message}`, { cause })
1357
1843
  )
1358
1844
  } else {
1845
+ confirm()
1359
1846
  resolve(promiseData.r)
1360
1847
  }
1361
1848
  })
1362
1849
  } else {
1850
+ confirm()
1363
1851
  resolve(ret)
1364
1852
  }
1365
1853
  }
@@ -1396,51 +1884,114 @@ class VrpcClient extends EventEmitter {
1396
1884
  })
1397
1885
  }
1398
1886
 
1887
+ /**
1888
+ * Whether a function is an event function: declared `@returns
1889
+ * {Registration}` (when the class info carries meta data), else named `on`
1890
+ * followed by a capital letter. The prefixes `notify`, `monitor` and
1891
+ * `signal` still count (deprecated, gone in 4.0).
1892
+ */
1893
+ _isEventFunction (agent, className, functionName) {
1894
+ const info =
1895
+ this._agents[agent] &&
1896
+ this._agents[agent].classes &&
1897
+ this._agents[agent].classes[className]
1898
+ const meta = info && info.meta && info.meta[functionName]
1899
+ const type = meta && meta.ret && meta.ret.type
1900
+ if (typeof type === 'string' && DECLARED_REGISTRATION.test(type.trim())) {
1901
+ return true
1902
+ }
1903
+ if (/^on[A-Z]/.test(functionName)) return true
1904
+ return /^(notify|monitor|signal)/.test(functionName)
1905
+ }
1906
+
1399
1907
  async _wrapArguments ({
1400
1908
  agent,
1401
1909
  className,
1402
1910
  instance,
1403
1911
  proxyId,
1404
1912
  functionName,
1405
- args
1913
+ args,
1914
+ isCallAll = false,
1915
+ selection
1406
1916
  }) {
1407
1917
  const wrapped = []
1408
1918
 
1409
- // Heuristic for continuous event emitters and their removal
1410
- const isContinuousEmitter = /^(on|notify|monitor|signal)/.test(functionName)
1919
+ const isEventFunction = this._isEventFunction(
1920
+ agent,
1921
+ className,
1922
+ functionName
1923
+ )
1924
+ // continuous removers (legacy): withdraw the callback's subscription
1411
1925
  const isContinuousRemover = /^(off|remove|unmonitor|unsignal)/.test(
1412
1926
  functionName
1413
1927
  )
1928
+ const isEmitterRemover =
1929
+ (functionName === 'off' || functionName === 'removeListener') &&
1930
+ typeof args[0] === 'string'
1414
1931
 
1415
1932
  for (const [i, x] of args.entries()) {
1416
- if (this._isFunction(x)) {
1417
- const callback = x
1933
+ const isEmitterContract = this._isEmitter(x)
1934
+ if (this._isFunction(x) || isEmitterContract) {
1935
+ const removes =
1936
+ !isEventFunction &&
1937
+ ((isEmitterRemover && i === 1) || isContinuousRemover)
1938
+ const callback = isEmitterContract
1939
+ ? this._emitterCallback(x, { readOnly: removes })
1940
+ : x
1418
1941
  if (
1419
- functionName === 'vrpcOn' ||
1420
- (functionName === 'on' && i === 1 && typeof args[0] === 'string') ||
1421
- isContinuousEmitter
1942
+ !removes &&
1943
+ (isEmitterContract ||
1944
+ functionName === 'vrpcOn' ||
1945
+ ((functionName === 'on' || functionName === 'addListener') &&
1946
+ i === 1 &&
1947
+ typeof args[0] === 'string') ||
1948
+ isEventFunction)
1422
1949
  ) {
1423
1950
  const event = typeof args[0] === 'string' ? args[0] : functionName
1951
+ // vrpcOn names the event function first
1952
+ const isVrpcOn = functionName === 'vrpcOn'
1424
1953
  const id = await this._onRemoteEvent({
1425
1954
  agent,
1426
1955
  className,
1427
1956
  instance,
1428
1957
  event,
1429
- callback
1958
+ callback,
1959
+ functionName: isVrpcOn ? args[0] : functionName,
1960
+ args: isVrpcOn ? args.slice(1) : args,
1961
+ position: isVrpcOn ? i - 1 : i,
1962
+ isCallAll,
1963
+ selection
1430
1964
  })
1431
1965
  wrapped.push(id)
1432
1966
  continue
1433
- } else if (
1434
- ((functionName === 'off' || functionName === 'removeListener') &&
1435
- i === 1 &&
1436
- typeof args[0] === 'string') ||
1437
- isContinuousRemover
1438
- ) {
1439
- const id = await this._offRemoteEvent({ callback })
1440
- if (
1441
- !id &&
1442
- (functionName === 'off' || functionName === 'removeListener')
1443
- ) {
1967
+ } else if (removes) {
1968
+ const subs = callback
1969
+ ? this._offRemoteEvent({
1970
+ agent,
1971
+ className,
1972
+ instance,
1973
+ isCallAll,
1974
+ selection,
1975
+ functionName,
1976
+ args,
1977
+ position: i,
1978
+ callback
1979
+ })
1980
+ : []
1981
+ if (subs.length > 0) {
1982
+ const info = this._agents[agent]
1983
+ if (info && info.v >= 4) {
1984
+ // protocol 4 withdraws by id and never calls the class's
1985
+ // remover: handled here
1986
+ await this._withdrawAt(agent, subs.map(sub => ({ sub })))
1987
+ return null
1988
+ }
1989
+ // an agent before protocol 4 knows only the remover call, which
1990
+ // carries the id (further subscriptions end locally)
1991
+ wrapped.push(subs[0].id)
1992
+ continue
1993
+ }
1994
+ if (functionName === 'off' || functionName === 'removeListener') {
1444
1995
  // Nothing of ours to remove - the subscription was already
1445
1996
  // dropped (the instance or its agent went away and the cache
1446
1997
  // was cleared, or the listener was never registered). Sending
@@ -1448,43 +1999,28 @@ class VrpcClient extends EventEmitter {
1448
1999
  // its EventEmitter rejects. Handled locally: nothing to do.
1449
2000
  return null
1450
2001
  }
1451
- wrapped.push(id || null)
1452
- continue
1453
- } else {
1454
- // Regular function callback (can be static or member function)
1455
- const remoteId = proxyId || `${agent}-${className}`
1456
- const id = `__f__${remoteId}-${functionName}-${i}-${
1457
- this._invokeId++ % Number.MAX_SAFE_INTEGER
1458
- }`
1459
- wrapped.push(id)
1460
- this._eventEmitter.once(id, ({ a }) => x.apply(null, a))
1461
- continue
1462
- }
1463
- }
1464
-
1465
- // special case of an EventEmitter provided as argument
1466
- if (this._isEmitter(x)) {
1467
- const { emitter, event } = x
1468
- let callback = (...args) => emitter.emit(event, ...args)
1469
- if (this._emitterListener.has(emitter)) {
1470
- const eventMap = this._emitterListener.get(emitter)
1471
- if (eventMap.has(event)) {
1472
- callback = eventMap.get(event)
1473
- } else {
1474
- eventMap.set(event, callback)
2002
+ // no subscription of this callback: an { emitter, event }
2003
+ // contract stays empty, a function is a plain callback after all
2004
+ // (removeItem(id, done))
2005
+ if (isEmitterContract) {
2006
+ wrapped.push(null)
2007
+ continue
1475
2008
  }
1476
- } else {
1477
- this._emitterListener.set(emitter, new Map([[event, callback]]))
1478
2009
  }
1479
-
1480
- const id = await this._onRemoteEvent({
1481
- agent,
1482
- className,
1483
- instance,
1484
- event,
1485
- callback
1486
- })
2010
+ // Regular function callback (can be static or member function)
2011
+ const remoteId = proxyId || `${agent}-${className}`
2012
+ const id = `__f__${remoteId}-${functionName}-${i}-${
2013
+ this._invokeId++ % Number.MAX_SAFE_INTEGER
2014
+ }`
1487
2015
  wrapped.push(id)
2016
+ // a callback the agent could not encode is told, never called (CALL-10)
2017
+ this._eventEmitter.once(id, ({ a, e }) => {
2018
+ if (e) {
2019
+ this.emit('undelivered', id, e, { agent, className, instance, functionName })
2020
+ return
2021
+ }
2022
+ callback.apply(null, a)
2023
+ })
1488
2024
  continue
1489
2025
  }
1490
2026
  // default behavior is to not touch
@@ -1493,98 +2029,378 @@ class VrpcClient extends EventEmitter {
1493
2029
  return wrapped
1494
2030
  }
1495
2031
 
1496
- async _onRemoteEvent ({ agent, className, instance, event, callback }) {
1497
- const callbackId = this._getCallbackId(callback)
2032
+ // the one callback standing for an { emitter, event } contract
2033
+ _emitterCallback ({ emitter, event }, { readOnly = false } = {}) {
2034
+ let eventMap = this._emitterListener.get(emitter)
2035
+ if (eventMap && eventMap.has(event)) return eventMap.get(event)
2036
+ if (readOnly) return null
2037
+ const callback = (...args) => emitter.emit(event, ...args)
2038
+ if (!eventMap) {
2039
+ eventMap = new Map()
2040
+ this._emitterListener.set(emitter, eventMap)
2041
+ }
2042
+ eventMap.set(event, callback)
2043
+ return callback
2044
+ }
1498
2045
 
1499
- // Generate a short, sanitized hash
1500
- const hashInput = `${agent}-${className}-${
1501
- instance || '__static__'
1502
- }-${event}-${callbackId}`
1503
- const topicId = VrpcClient._createHash(hashInput, 12)
2046
+ /**
2047
+ * What a subscription is to the agent: its function (`addListener` is
2048
+ * `on`) and arguments compared by value, the callback's own position
2049
+ * named and other callbacks marked. Two subscriptions of one callback
2050
+ * differ in it whenever the agent keeps them apart, so their ids differ:
2051
+ * an id names one registration per instance.
2052
+ */
2053
+ _subscriptionKey (functionName, args, position) {
2054
+ const func = functionName === 'addListener' ? 'on' : functionName
2055
+ const marked = args.map((x, i) => {
2056
+ if (i === position) return '\u0000vrpc-self'
2057
+ if (this._isFunction(x) || this._isEmitter(x)) return '\u0000vrpc-callback'
2058
+ return x
2059
+ })
2060
+ return `${func}\u0000${VrpcClient._canonical(marked)}`
2061
+ }
1504
2062
 
1505
- // Route directly to the client's single event inbox wildcard
1506
- const topic = `${this._vrpcConnectionId}/${topicId}`
1507
- const id = `__e__${topic}`
2063
+ // arguments compared by value: equal JSON once object keys are sorted
2064
+ static _canonical (value) {
2065
+ const sortKeys = x => {
2066
+ if (Array.isArray(x)) return x.map(sortKeys)
2067
+ if (x && typeof x === 'object' && x.constructor === Object) {
2068
+ const sorted = {}
2069
+ for (const key of Object.keys(x).sort()) sorted[key] = sortKeys(x[key])
2070
+ return sorted
2071
+ }
2072
+ return x
2073
+ }
2074
+ return JSON.stringify(sortKeys(value))
2075
+ }
1508
2076
 
1509
- const handler = ({ a }) => callback.apply(null, a)
2077
+ // the event function a remover undoes: off/removeListener -> on,
2078
+ // offValue -> onValue, unmonitorX -> monitorX
2079
+ static _twinOf (functionName) {
2080
+ if (functionName === 'off' || functionName === 'removeListener') return 'on'
2081
+ return functionName.replace(/^(off|un)(?=[A-Za-z])/, m =>
2082
+ m === 'off' ? 'on' : ''
2083
+ )
2084
+ }
2085
+
2086
+ // what a subscription is, but its callback: target, the instances a
2087
+ // callAll selects, and subscription key
2088
+ static _identity ({ agent, className, instance, isCallAll, selection, key }) {
2089
+ const target = [agent, className, instance || null, !!isCallAll, key]
2090
+ return JSON.stringify(selection === undefined ? target : [...target, selection])
2091
+ }
1510
2092
 
1511
- // Only bind to the actual emitter on the first registration
1512
- if (this._eventEmitter.listenerCount(id) === 0) {
1513
- this._eventEmitter.on(id, handler)
2093
+ /**
2094
+ * The topic a subscription's events arrive on; its id is `__e__<topic>`.
2095
+ * One per identity (target, function, arguments and callback), counted,
2096
+ * not hashed: two identities never share one. An identity keeps its
2097
+ * topic while its callback lives, so a subscription declared again after
2098
+ * a loss has the id an agent may still hold (which then changes
2099
+ * nothing); a withdrawn one gets a fresh id when declared anew (see
2100
+ * _dropSubscription), so a late withdrawal never meets the new one.
2101
+ */
2102
+ _subscriptionTopic (callback, identity, { readOnly = false } = {}) {
2103
+ let topics = this._subscriptionTopics.get(callback)
2104
+ if (!topics) {
2105
+ if (readOnly) return null
2106
+ topics = new Map()
2107
+ this._subscriptionTopics.set(callback, topics)
1514
2108
  }
2109
+ let topic = topics.get(identity)
2110
+ if (!topic && !readOnly) {
2111
+ // routed by the client's single event inbox wildcard
2112
+ topic = `${this._vrpcConnectionId}/s${(this._subscriptionCount++).toString(36)}`
2113
+ topics.set(identity, topic)
2114
+ }
2115
+ return topic || null
2116
+ }
1515
2117
 
1516
- if (!this._cachedSubscriptions[topic]) {
1517
- // Store metadata explicitly
1518
- this._cachedSubscriptions[topic] = {
1519
- agent,
1520
- className,
1521
- instance,
1522
- event,
1523
- listeners: [{ callback, handler }]
2118
+ /**
2119
+ * A subscription: one per target, function, arguments and callback, held
2120
+ * once however often it is declared (the agent keeps it as a set member,
2121
+ * too).
2122
+ */
2123
+ async _onRemoteEvent ({
2124
+ agent,
2125
+ className,
2126
+ instance,
2127
+ event,
2128
+ callback,
2129
+ functionName,
2130
+ args,
2131
+ position,
2132
+ isCallAll = false,
2133
+ selection
2134
+ }) {
2135
+ const identity = VrpcClient._identity({
2136
+ agent,
2137
+ className,
2138
+ instance,
2139
+ isCallAll,
2140
+ selection,
2141
+ key: this._subscriptionKey(functionName, args, position)
2142
+ })
2143
+ const topic = this._subscriptionTopic(callback, identity)
2144
+ const id = `__e__${topic}`
2145
+ if (this._cachedSubscriptions[topic]) return id
2146
+ const sub = {
2147
+ id,
2148
+ identity,
2149
+ agent,
2150
+ className,
2151
+ instance,
2152
+ event,
2153
+ functionName,
2154
+ callAll: isCallAll,
2155
+ // the instances a callAll selects (absent: all)
2156
+ selection,
2157
+ callback,
2158
+ handler: null,
2159
+ declaration: null,
2160
+ confirmed: false,
2161
+ // its agent went offline: declared again once it is back
2162
+ lost: false,
2163
+ // callAll: the instances it was withdrawn at (unsubscribe's instance)
2164
+ withdrawn: new Set()
2165
+ }
2166
+ // an ended notice carries `end` and never reaches the callback, nor
2167
+ // does an event the agent could not encode (`e`, EV-22); a callAll
2168
+ // event names its instance first
2169
+ sub.handler = ({ a, end, e }) => {
2170
+ if (end) {
2171
+ this._handleEnded(topic, end)
2172
+ return
1524
2173
  }
1525
- } else {
1526
- this._cachedSubscriptions[topic].listeners.push({ callback, handler })
2174
+ if (e) {
2175
+ this.emit('undelivered', id, e, this._subscriptionInfo(sub))
2176
+ return
2177
+ }
2178
+ if (sub.withdrawn.size > 0 && Array.isArray(a) && sub.withdrawn.has(a[0])) {
2179
+ return
2180
+ }
2181
+ callback.apply(null, a)
1527
2182
  }
1528
-
2183
+ this._eventEmitter.on(id, sub.handler)
2184
+ this._cachedSubscriptions[topic] = sub
1529
2185
  return id
1530
2186
  }
1531
2187
 
1532
- async _offRemoteEvent ({ callback }) {
1533
- const callbackId = this._getCallbackId(callback, { readOnly: true })
1534
- if (!callbackId) return null
1535
-
1536
- let foundId = null
1537
- const topics = Object.keys(this._cachedSubscriptions)
1538
-
1539
- topics.forEach(topic => {
1540
- const id = `__e__${topic}`
1541
- const sub = this._cachedSubscriptions[topic]
1542
-
1543
- const index = sub.listeners.findIndex(x => x.callback === callback)
1544
-
1545
- if (index !== -1) {
1546
- // Decrease the "reference count" by removing one instance
1547
- sub.listeners.splice(index, 1)
2188
+ /**
2189
+ * The subscriptions a remover call names, withdrawn locally: the one its
2190
+ * arguments name (offValue(1, cb) undoes onValue(1, cb)), else all of the
2191
+ * callback's subscriptions of that function on the target (offValue(cb)
2192
+ * after onValue(1, cb) and onValue(2, cb)), or of any function when the
2193
+ * remover names none the client knows.
2194
+ */
2195
+ _offRemoteEvent ({
2196
+ agent,
2197
+ className,
2198
+ instance,
2199
+ isCallAll,
2200
+ selection,
2201
+ functionName,
2202
+ args,
2203
+ position,
2204
+ callback
2205
+ }) {
2206
+ const twin = VrpcClient._twinOf(functionName)
2207
+ const exact = this._subscriptionTopic(
2208
+ callback,
2209
+ VrpcClient._identity({
2210
+ agent,
2211
+ className,
2212
+ instance,
2213
+ isCallAll,
2214
+ selection,
2215
+ key: this._subscriptionKey(twin, args, position)
2216
+ }),
2217
+ { readOnly: true }
2218
+ )
2219
+ let subs = exact && this._cachedSubscriptions[exact]
2220
+ ? [this._cachedSubscriptions[exact]]
2221
+ : []
2222
+ if (subs.length === 0) {
2223
+ const ofCallback = Object.values(this._cachedSubscriptions).filter(
2224
+ sub =>
2225
+ sub.callback === callback &&
2226
+ sub.agent === agent &&
2227
+ sub.className === className &&
2228
+ sub.instance === instance &&
2229
+ sub.callAll === !!isCallAll &&
2230
+ JSON.stringify(sub.selection) === JSON.stringify(selection)
2231
+ )
2232
+ const ofTwin = ofCallback.filter(
2233
+ sub =>
2234
+ (sub.functionName === 'addListener' ? 'on' : sub.functionName) ===
2235
+ twin
2236
+ )
2237
+ subs = ofTwin.length > 0 ? ofTwin : ofCallback
2238
+ }
2239
+ subs.forEach(sub => this._dropSubscription(sub))
2240
+ return subs
2241
+ }
1548
2242
 
1549
- // Only remove from the local event emitter if NO references remain
1550
- if (sub.listeners.length === 0) {
1551
- delete this._cachedSubscriptions[topic]
2243
+ /**
2244
+ * Ends a subscription locally: no event reaches its callback any more. A
2245
+ * withdrawn or ended subscription also gives up its id (declared anew, it
2246
+ * gets a fresh one); a lost one keeps it (`forget: false`), so declaring
2247
+ * it again matches what an agent that went away and came back still
2248
+ * holds.
2249
+ */
2250
+ _dropSubscription (sub, { forget = true } = {}) {
2251
+ this._eventEmitter.removeListener(sub.id, sub.handler)
2252
+ const topic = sub.id.slice(5)
2253
+ if (this._cachedSubscriptions[topic] === sub) {
2254
+ delete this._cachedSubscriptions[topic]
2255
+ }
2256
+ if (forget) {
2257
+ const topics = this._subscriptionTopics.get(sub.callback)
2258
+ if (topics && topics.get(sub.identity) === topic) {
2259
+ topics.delete(sub.identity)
2260
+ }
2261
+ }
2262
+ }
1552
2263
 
1553
- // Cleanly wipe all listeners for this specific id
1554
- this._eventEmitter.removeAllListeners(id)
1555
- }
1556
- foundId = id
2264
+ /**
2265
+ * Remembers the call that declared a subscription (to declare it again
2266
+ * after a reconnect) and sends the subscriber labels along.
2267
+ */
2268
+ _declare (json, topic) {
2269
+ const labels = {}
2270
+ let labelled = false
2271
+ for (const arg of json.a) {
2272
+ if (typeof arg !== 'string' || !arg.startsWith('__e__')) continue
2273
+ const sub = this._cachedSubscriptions[arg.slice(5)]
2274
+ if (!sub) continue
2275
+ sub.declaration = { topic, c: json.c, f: json.f, a: json.a }
2276
+ // a declaration names what is wanted now: earlier withdrawals at
2277
+ // single instances are over
2278
+ sub.withdrawn.clear()
2279
+ const label = this._subscriberLabels.get(sub.callback)
2280
+ if (label !== undefined) {
2281
+ labels[arg] = label
2282
+ labelled = true
1557
2283
  }
1558
- })
1559
- return foundId
2284
+ }
2285
+ if (labelled) {
2286
+ json.l = labels
2287
+ for (const id of Object.keys(labels)) {
2288
+ const sub = this._cachedSubscriptions[id.slice(5)]
2289
+ if (sub && sub.declaration) sub.declaration.l = labels
2290
+ }
2291
+ }
1560
2292
  }
1561
2293
 
1562
- _clearCachedSubscriptions ({ lostAgent, lostInstance, agent, className }) {
1563
- const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1564
- topic => {
1565
- const sub = this._cachedSubscriptions[topic]
1566
- if (lostAgent !== undefined) return sub.agent === lostAgent
1567
- // Instance ids are unique per agent only: a twin of the same id on
1568
- // another agent (a deployed app next to its builder) keeps its
1569
- // subscriptions when this one goes
1570
- return (
1571
- sub.instance === lostInstance &&
1572
- sub.agent === agent &&
1573
- sub.className === className
1574
- )
2294
+ // marks the subscriptions among a declaring call's arguments as held
2295
+ _confirmSubscriptions (args) {
2296
+ if (!Array.isArray(args)) return
2297
+ for (const arg of args) {
2298
+ const sub =
2299
+ typeof arg === 'string' &&
2300
+ arg.startsWith('__e__') &&
2301
+ this._cachedSubscriptions[arg.slice(5)]
2302
+ if (sub) sub.confirmed = true
2303
+ }
2304
+ }
2305
+
2306
+ // declares every subscription held towards an agent again (protocol 4)
2307
+ _redeclareTo (agent) {
2308
+ if (!this._inboxReady || !this._redeclare.has(agent)) return
2309
+ const info = this._agents[agent]
2310
+ if (!info || info.status !== 'online') return
2311
+ this._redeclare.delete(agent)
2312
+ if (!(info.v >= 4)) return // an older agent learns nothing new
2313
+ const declarations = new Map()
2314
+ for (const sub of Object.values(this._cachedSubscriptions)) {
2315
+ if (sub.agent !== agent || !sub.declaration) continue
2316
+ const { topic, a } = sub.declaration
2317
+ const key = `${topic}\u0000${JSON.stringify(a)}`
2318
+ if (!declarations.has(key)) {
2319
+ declarations.set(key, { declaration: sub.declaration, subs: [] })
1575
2320
  }
1576
- )
2321
+ declarations.get(key).subs.push(sub)
2322
+ }
2323
+ for (const { declaration, subs } of declarations.values()) {
2324
+ const { topic, c, f, a, l } = declaration
2325
+ const json = {
2326
+ c,
2327
+ f,
2328
+ a,
2329
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
2330
+ s: this._vrpcConnectionId,
2331
+ v: VRPC_PROTOCOL_VERSION
2332
+ }
2333
+ if (l) json.l = l
2334
+ this._mqttPublish(topic, JSON.stringify(json))
2335
+ this._handleAgentAnswer(json, agent).then(
2336
+ () => {
2337
+ const entries = []
2338
+ for (const sub of subs) {
2339
+ if (this._cachedSubscriptions[sub.id.slice(5)] !== sub) continue
2340
+ // a lost subscription is back
2341
+ if (sub.lost) {
2342
+ sub.lost = false
2343
+ this.emit('healed', sub.id, this._subscriptionInfo(sub))
2344
+ }
2345
+ // what was withdrawn at single instances stays withdrawn there
2346
+ for (const context of sub.withdrawn) entries.push({ sub, context })
2347
+ }
2348
+ if (entries.length > 0) return this._sendUnsubscribe(agent, entries)
2349
+ },
2350
+ err => {
2351
+ for (const sub of subs) {
2352
+ if (this._cachedSubscriptions[sub.id.slice(5)] !== sub) continue
2353
+ this._dropSubscription(sub, { forget: false })
2354
+ this.emit('lost', sub.id, err.message, this._subscriptionInfo(sub))
2355
+ }
2356
+ }
2357
+ )
2358
+ }
2359
+ }
1577
2360
 
1578
- obsoleteTopics.forEach(topic => {
1579
- const id = `__e__${topic}`
1580
- this._log.debug(`Clearing subscriptions for obsolete topic: ${topic}`)
1581
- const sub = this._cachedSubscriptions[topic]
2361
+ _subscriptionInfo (sub) {
2362
+ return {
2363
+ agent: sub.agent,
2364
+ className: sub.className,
2365
+ instance: sub.instance,
2366
+ functionName: sub.functionName,
2367
+ event: sub.event,
2368
+ callAll: sub.callAll,
2369
+ ...(sub.selection === undefined ? {} : { instances: sub.selection })
2370
+ }
2371
+ }
1582
2372
 
1583
- sub.listeners.forEach(({ handler }) => {
1584
- this._eventEmitter.removeListener(id, handler)
1585
- })
2373
+ // the agent ended a registration this subscription was part of
2374
+ _handleEnded (topic, end) {
2375
+ const sub = this._cachedSubscriptions[topic]
2376
+ if (!sub) return
2377
+ const info = {
2378
+ ...this._subscriptionInfo(sub),
2379
+ context: end.context,
2380
+ failed: !!end.failed
2381
+ }
2382
+ // a callAll subscription lives on at the other instances
2383
+ if (!sub.callAll) this._dropSubscription(sub)
2384
+ this.emit('ended', sub.id, end.reason, end.by, info)
2385
+ }
1586
2386
 
1587
- delete this._cachedSubscriptions[topic]
2387
+ _clearCachedSubscriptions ({ lostAgent, lostInstance, agent, className }) {
2388
+ const obsolete = Object.values(this._cachedSubscriptions).filter(sub => {
2389
+ if (lostAgent !== undefined) return sub.agent === lostAgent
2390
+ // Instance ids are unique per agent only: a twin of the same id on
2391
+ // another agent (a deployed app next to its builder) keeps its
2392
+ // subscriptions when this one goes
2393
+ return (
2394
+ sub.instance === lostInstance &&
2395
+ sub.agent === agent &&
2396
+ sub.className === className
2397
+ )
2398
+ })
2399
+ const reason = lostAgent !== undefined ? 'agentOffline' : 'instanceGone'
2400
+ obsolete.forEach(sub => {
2401
+ this._log.debug(`Clearing subscription for obsolete topic: ${sub.id}`)
2402
+ this._dropSubscription(sub, { forget: false })
2403
+ this.emit('lost', sub.id, reason, this._subscriptionInfo(sub))
1588
2404
  })
1589
2405
  }
1590
2406
 
@@ -1615,6 +2431,8 @@ class VrpcClient extends EventEmitter {
1615
2431
  }
1616
2432
 
1617
2433
  async _getInstanceData (instance, options) {
2434
+ const own = this._findOwnIsolated(instance, options)
2435
+ if (own) return own
1618
2436
  const instanceData = this._getInstanceFromCache(instance, options)
1619
2437
  if (!instanceData) {
1620
2438
  if (options.noWait) {
@@ -1625,6 +2443,17 @@ class VrpcClient extends EventEmitter {
1625
2443
  return instanceData
1626
2444
  }
1627
2445
 
2446
+ // an isolated instance this connection created, when it is the one match
2447
+ _findOwnIsolated (instance, { agent, className } = {}) {
2448
+ const hits = Array.from(this._ownIsolated.values()).filter(
2449
+ x =>
2450
+ x.instance === instance &&
2451
+ (!agent || agent === '*' || x.agent === agent) &&
2452
+ (!className || x.className === className)
2453
+ )
2454
+ return hits.length === 1 ? { agent: hits[0].agent, className: hits[0].className } : null
2455
+ }
2456
+
1628
2457
  _cachedProxy (instance, { agent, className } = {}) {
1629
2458
  // Instance ids are unique per agent only: a cached proxy is served
1630
2459
  // solely when it is the one match for the given agent and class - twins
@@ -1740,6 +2569,72 @@ class VrpcClient extends EventEmitter {
1740
2569
  * @param {String} info.className - Class name
1741
2570
  */
1742
2571
 
2572
+ /**
2573
+ * Event 'ended'
2574
+ *
2575
+ * Emitted when a registration a subscription held ended for a reason the
2576
+ * subscriber did not cause: its source ended it, or a member did
2577
+ * (`endRegistration`). The subscription is gone; it never reaches the
2578
+ * callback. A callAll subscription lives on at its other instances.
2579
+ *
2580
+ * @event VrpcClient#ended
2581
+ *
2582
+ * @param {String} id - The subscription id
2583
+ * @param {String} reason - Why it ended (an error message when it failed)
2584
+ * @param {String|null} by - Who ended it (`'source'`, or what the member said)
2585
+ * @param {Object} info - The subscription (agent, className, instance,
2586
+ * functionName, event, callAll), `context` (the instance or class) and
2587
+ * `failed`
2588
+ */
2589
+
2590
+ /**
2591
+ * Event 'undelivered'
2592
+ *
2593
+ * Emitted instead of an event, or a one-shot callback, whose arguments the
2594
+ * agent could not encode as JSON (a BigInt, say). The callback is not
2595
+ * called for it; a subscription stays and receives the next event. Agents
2596
+ * before protocol 4 drop such events without telling.
2597
+ *
2598
+ * @event VrpcClient#undelivered
2599
+ *
2600
+ * @param {String} id - The subscription id, or the one-shot callback's id
2601
+ * @param {Object} error - What the agent says, `{ message }`
2602
+ * @param {Object} info - The subscription (agent, className, instance,
2603
+ * functionName, event, callAll), or for a one-shot callback its agent,
2604
+ * className, instance and functionName
2605
+ */
2606
+
2607
+ /**
2608
+ * Event 'lost'
2609
+ *
2610
+ * Emitted when a subscription is lost: its agent went offline
2611
+ * (`'agentOffline'`), its instance went away (`'instanceGone'`), or a
2612
+ * declaration again failed (the error's message). A subscription on an agent
2613
+ * of protocol 4 that went offline is declared again once the agent is back
2614
+ * ('healed'); every other lost subscription is gone, and declaring it again
2615
+ * is the consumer's.
2616
+ *
2617
+ * @event VrpcClient#lost
2618
+ *
2619
+ * @param {String} id - The subscription id
2620
+ * @param {String} reason - `'agentOffline'`, `'instanceGone'` or an error message
2621
+ * @param {Object} info - The subscription (agent, className, instance,
2622
+ * functionName, event, callAll)
2623
+ */
2624
+
2625
+ /**
2626
+ * Event 'healed'
2627
+ *
2628
+ * Emitted when a subscription lost with its agent is back: the agent serves
2629
+ * again and holds it.
2630
+ *
2631
+ * @event VrpcClient#healed
2632
+ *
2633
+ * @param {String} id - The subscription id
2634
+ * @param {Object} info - The subscription (agent, className, instance,
2635
+ * functionName, event, callAll)
2636
+ */
2637
+
1743
2638
  /**
1744
2639
  * Event 'connect'
1745
2640
  *