vrpc 3.14.0 → 3.16.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,39 @@ 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
+ // the answers this client waits for ({ agent, second, lose, forget }),
184
+ // lost when the path an answer would travel breaks (see _loseAnswers)
185
+ this._waiting = new Set()
186
+ // labels callbacks carry towards the agent's list of registrations
187
+ this._subscriberLabels = new WeakMap()
171
188
  if (log === 'console') {
172
189
  this._log = console
173
190
  this._log.debug = () => {}
174
191
  } else {
175
192
  this._log = log
176
193
  }
194
+ // refused subscriptions are retried until granted
195
+ this._subscribeRetrier = new SubscribeRetrier({
196
+ subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
197
+ log: this._log
198
+ })
177
199
  }
178
200
 
179
201
  /**
@@ -289,6 +311,9 @@ class VrpcClient extends EventEmitter {
289
311
  // Clear the cache to ensure a fresh state upon reconnection
290
312
  this._agents = {}
291
313
  this._proxies = {}
314
+ // the agents delete this connection's isolated instances once its
315
+ // will is out
316
+ this._ownIsolated.clear()
292
317
  this.emit('offline')
293
318
  })
294
319
 
@@ -301,11 +326,25 @@ class VrpcClient extends EventEmitter {
301
326
  this.emit('error', err)
302
327
  })
303
328
 
329
+ // a clean session keeps nothing for this connection while it is gone:
330
+ // an answer published meanwhile never arrives
331
+ this._client.on('close', () => {
332
+ this._loseAnswers(() => true, 'this client lost its connection')
333
+ })
334
+
304
335
  this._client.stream.on('error', err => {
305
336
  this.emit('error', err)
306
337
  })
307
338
 
308
339
  this._client.on('connect', async () => {
340
+ // the subscriptions this client holds are declared again, to every
341
+ // agent announcing protocol 4, once the inbox stands (an agent that
342
+ // saw this connection go has withdrawn them; one that did not keeps
343
+ // them, declaring again is a no-op there)
344
+ this._inboxReady = false
345
+ for (const sub of Object.values(this._cachedSubscriptions)) {
346
+ this._redeclare.add(sub.agent)
347
+ }
309
348
  // forward the good news
310
349
  this.emit('connect')
311
350
  // everything is subscribed afresh below: pending retries are moot
@@ -325,6 +364,13 @@ class VrpcClient extends EventEmitter {
325
364
  }
326
365
  // Single wildcard catches BOTH RPC responses (length 3) AND events (length 4)
327
366
  await this._mqttSubscribe(`${this._vrpcConnectionId}/#`)
367
+ this._inboxReady = true
368
+ for (const agent of Array.from(this._redeclare)) {
369
+ this._redeclareTo(agent)
370
+ }
371
+ for (const agent of Array.from(this._pendingWithdrawals.keys())) {
372
+ this._flushWithdrawals(agent)
373
+ }
328
374
 
329
375
  // Re-subscribe to all cached event topics
330
376
  this._log.debug('Restoring event subscriptions after reconnect...')
@@ -340,7 +386,7 @@ class VrpcClient extends EventEmitter {
340
386
  }
341
387
  })
342
388
 
343
- this._client.on('message', (topic, message) => {
389
+ this._client.on('message', (topic, message, packet) => {
344
390
  if (message.length === 0) return
345
391
  try {
346
392
  const tokens = topic.split('/')
@@ -351,13 +397,33 @@ class VrpcClient extends EventEmitter {
351
397
  }
352
398
  // AgentInfo message
353
399
  if (klass === '__agentInfo__') {
354
- const { status, hostname, version } = JSON.parse(message.toString())
400
+ const { status, hostname, version, v } = JSON.parse(
401
+ message.toString()
402
+ )
355
403
  this._createIfNotExist(agent)
356
404
  this._agents[agent].status = status
357
405
  this._agents[agent].hostname = hostname
358
406
  this._agents[agent].version = version
407
+ // the agent's protocol version (undefined before 3)
408
+ this._agents[agent].v = v
359
409
  if (status === 'offline') {
360
- this._clearCachedSubscriptions({ lostAgent: agent })
410
+ this._loseAnswers(
411
+ w => w.second && w.agent === agent,
412
+ 'the agent went offline'
413
+ )
414
+ this._loseAgent(agent, v)
415
+ } else {
416
+ // an agent announces itself from a new connection only (a
417
+ // retained announcement is old news a subscription brings):
418
+ // the promises its former connection was to answer are gone
419
+ if (!packet.retain) {
420
+ this._loseAnswers(
421
+ w => w.second && w.agent === agent,
422
+ 'the agent connected anew'
423
+ )
424
+ }
425
+ this._redeclareTo(agent)
426
+ this._flushWithdrawals(agent)
361
427
  }
362
428
  this.emit('agent', { domain, agent, status, hostname, version })
363
429
  // ClassInfo message
@@ -401,8 +467,8 @@ class VrpcClient extends EventEmitter {
401
467
  })
402
468
  // RPC message
403
469
  } else {
404
- const { i, a, e, r } = JSON.parse(message.toString())
405
- this._eventEmitter.emit(i, { a, e, r })
470
+ const { i, a, e, r, u, end } = JSON.parse(message.toString())
471
+ this._eventEmitter.emit(i, { a, e, r, u, end })
406
472
  }
407
473
  } catch (err) {
408
474
  this._log.error(
@@ -554,6 +620,14 @@ class VrpcClient extends EventEmitter {
554
620
  v: VRPC_PROTOCOL_VERSION
555
621
  }
556
622
  const proxy = await this._getProxy(agent, className, json)
623
+ // no class info lists an isolated instance: its creator remembers it
624
+ if (isIsolated) {
625
+ this._ownIsolated.set(`${agent}/${className}/${instance}`, {
626
+ agent,
627
+ className,
628
+ instance
629
+ })
630
+ }
557
631
  if (instance && cacheProxy) {
558
632
  this._proxies[`${agent}/${className}/${instance}`] = {
559
633
  agent,
@@ -618,6 +692,9 @@ class VrpcClient extends EventEmitter {
618
692
  this._mqttPublish(topic, JSON.stringify(json))
619
693
  delete this._proxies[`${agent}/${className}/${instance}`]
620
694
  const deleted = await this._handleAgentAnswer(json, agent)
695
+ if (deleted === true) {
696
+ this._ownIsolated.delete(`${agent}/${className}/${instance}`)
697
+ }
621
698
  // the answer and the class info that no longer lists the instance
622
699
  // travel on different topics, and a broker need not keep their order:
623
700
  // what this client deleted is gone for it at once
@@ -675,6 +752,7 @@ class VrpcClient extends EventEmitter {
675
752
  v: VRPC_PROTOCOL_VERSION
676
753
  }
677
754
  const topic = `${this._domain}/${agent}/${className}/__static__/${functionName}`
755
+ this._declare(json, topic)
678
756
  await this._waitUntilClassIsOnline(agent, className)
679
757
  this._mqttPublish(topic, JSON.stringify(json))
680
758
  return this._handleAgentAnswer(json, agent)
@@ -686,29 +764,61 @@ class VrpcClient extends EventEmitter {
686
764
  * registrations. In this case the instanceId of the emitter is injected as
687
765
  * first argument of any event callback.
688
766
  *
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.
767
+ * `instances` selects the instances: a name pattern (`line-*`, where `*`
768
+ * matches any run of characters) or a list of names and patterns. The
769
+ * agent calls the selected ones only and answers for those - one request,
770
+ * however many instances. An agent that does not select instances
771
+ * (before protocol 4) gets one call per selected instance instead; a
772
+ * subscription cannot be split like that and is refused there.
773
+ *
774
+ * `agent` may name several agents as well: a pattern (`edge-*`, `*` by
775
+ * default) asks every online agent that offers the class and matches, a
776
+ * list asks each agent listed. They are asked all at once, and every
777
+ * entry names its agent; an agent that fails as a whole answers an error
778
+ * for each of its instances the client knows - or one entry with the
779
+ * error alone when it knows none - and the other agents' answers stand.
693
780
  *
694
781
  * @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
782
+ * @param {String} options.className Name of the class
783
+ * @param {String} options.functionName Name of the member function
784
+ * @param {Array} [options.args] Positional arguments of the call
785
+ * @param {String|Array.<String>} [options.agent] Agent name, pattern or
786
+ * list of names. If not provided class default is used
787
+ * @param {String|Array.<String>} [options.instances] The instances to call:
788
+ * a name pattern, or a list of names and patterns (all if not provided)
789
+ * @returns {Promise<Object[]>} An array of objects `{ id, val }` or
790
+ * `{ id, err }` carrying the instance id and its return value or error,
791
+ * and `agent` when the call addressed several agents
702
792
  */
703
793
  async callAll ({
704
794
  className,
705
795
  functionName,
706
796
  args = [],
707
- agent = this._agent
797
+ agent = this._agent,
798
+ instances
708
799
  } = {}) {
709
800
  if (!this._isConnected()) {
710
801
  throw new Error('Client is not connected')
711
802
  }
803
+ if (instances !== undefined && !NamePattern.isSelection(instances)) {
804
+ throw new Error(
805
+ `Invalid instances: ${JSON.stringify(instances)} (a name pattern, or a list of names and patterns)`
806
+ )
807
+ }
808
+ // a selection of everything is no selection
809
+ const selection =
810
+ instances === undefined || NamePattern.selectsAll(instances)
811
+ ? undefined
812
+ : instances
813
+ if (Array.isArray(agent) || agent.includes('*')) {
814
+ return this._callAllAgents({
815
+ className,
816
+ functionName,
817
+ args,
818
+ agents: agent,
819
+ selection
820
+ })
821
+ }
712
822
  const json = {
713
823
  c: className,
714
824
  f: functionName,
@@ -717,40 +827,418 @@ class VrpcClient extends EventEmitter {
717
827
  s: this._vrpcConnectionId,
718
828
  v: VRPC_PROTOCOL_VERSION
719
829
  }
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({
830
+ let special = '__callAll__'
831
+ if (selection !== undefined) {
832
+ await this._waitUntilClassIsOnline(agent, className)
833
+ if (!this._selectsInstances(agent, className)) {
834
+ return this._callSelected({
726
835
  agent,
727
836
  className,
728
837
  functionName,
729
- args
838
+ args,
839
+ selection
730
840
  })
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
841
  }
738
- return result
842
+ special = '__callMatching__'
739
843
  }
740
- const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
844
+ const topic = `${this._domain}/${agent}/${className}/__static__/${special}`
741
845
  const wrapped = await this._wrapArguments({
742
846
  agent,
743
847
  className,
744
848
  functionName,
745
- args
849
+ args,
850
+ isCallAll: true,
851
+ selection
746
852
  })
747
853
  if (!wrapped) return // using cache
748
- json.a = [functionName, ...wrapped]
854
+ json.a =
855
+ selection === undefined
856
+ ? [functionName, ...wrapped]
857
+ : [selection, functionName, ...wrapped]
858
+ this._declare(json, topic)
859
+ await this._waitUntilClassIsOnline(agent, className)
860
+ this._mqttPublish(topic, JSON.stringify(json))
861
+ return this._handleAgentAnswer(json, agent)
862
+ }
863
+
864
+ /**
865
+ * callAll on several agents: those a list names, or every online agent
866
+ * that offers the class and whose name a pattern matches - all at once,
867
+ * every entry naming its agent
868
+ */
869
+ async _callAllAgents ({ className, functionName, args, agents, selection }) {
870
+ const names = Array.isArray(agents)
871
+ ? agents
872
+ : this.getAvailableAgents().filter(
873
+ name =>
874
+ this._agents[name].classes[className] &&
875
+ NamePattern.matches(agents, name)
876
+ )
877
+ const answers = await Promise.all(
878
+ names.map(name =>
879
+ this.callAll({
880
+ className,
881
+ functionName,
882
+ args,
883
+ agent: name,
884
+ instances: selection
885
+ }).then(
886
+ result => (result || []).map(entry => ({ agent: name, ...entry })),
887
+ err => {
888
+ const error = { message: err.message }
889
+ const known = this.getAvailableInstances({ className, agent: name })
890
+ .filter(id => selection === undefined || NamePattern.selects(selection, id))
891
+ return known.length > 0
892
+ ? known.map(id => ({ agent: name, id, err: error }))
893
+ : [{ agent: name, err: error }]
894
+ }
895
+ )
896
+ )
897
+ )
898
+ return answers.flat()
899
+ }
900
+
901
+ // whether an agent selects instances itself: its class info lists
902
+ // __callMatching__ (agents of protocol 4)
903
+ _selectsInstances (agent, className) {
904
+ const info = this._agents[agent] && this._agents[agent].classes[className]
905
+ return (
906
+ !!info &&
907
+ info.staticFunctions.some(
908
+ f => this._stripSignature(f) === '__callMatching__'
909
+ )
910
+ )
911
+ }
912
+
913
+ /**
914
+ * A selection towards an agent that does not select instances: one call
915
+ * per selected instance, answered as callAll answers. A subscription is
916
+ * refused: its events, one per instance, would not name their instance.
917
+ */
918
+ async _callSelected ({ agent, className, functionName, args, selection }) {
919
+ if (args.some(x => this._isFunction(x) || this._isEmitter(x))) {
920
+ throw new Error(
921
+ `Agent ${agent} does not select instances for callbacks and events (its class info lists no __callMatching__): update it, or call without instances`
922
+ )
923
+ }
924
+ const selected = this.getAvailableInstances({ className, agent }).filter(
925
+ id => NamePattern.selects(selection, id)
926
+ )
927
+ return Promise.all(
928
+ selected.map(async id => {
929
+ try {
930
+ const proxy = await this.getInstance(id, { agent, className })
931
+ return { id, val: await proxy[functionName](...args) }
932
+ } catch (err) {
933
+ return { id, err: { message: err.message } }
934
+ }
935
+ })
936
+ )
937
+ }
938
+
939
+ /**
940
+ * Withdraws subscriptions.
941
+ *
942
+ * A subscription is what an event function call (or `on`) declared with a
943
+ * callback: one per target, function, arguments and callback, named by
944
+ * its subscription id (see getSubscriptions). Withdrawing it stops its events at once, and
945
+ * every agent holding it is told by id - however it was declared (a proxy,
946
+ * callStatic, callAll on one agent or on all of them). The agent releases
947
+ * the registration when the last subscription leaves.
948
+ *
949
+ * With `instance`, only what is held on that instance is withdrawn: a
950
+ * subscription declared there ends, and a callAll subscription - one id
951
+ * on every instance of its class - stops at that instance alone while
952
+ * the others keep delivering. The client remembers such a withdrawal and
953
+ * applies it again after its own reconnect, until the subscription is
954
+ * declared anew (a declaration names what is wanted now).
955
+ *
956
+ * Agents before protocol 4 cannot withdraw by id: an `on` subscription is
957
+ * removed there as before (removeListener), an `on<Name>` one through its
958
+ * `off<Name>` twin when the class has one; anything else ends locally
959
+ * (a callAll subscription withdrawn at one instance stops delivering its
960
+ * events there).
961
+ *
962
+ * @param {String|Array.<String>|Function|Object} target A subscription id
963
+ * (or several), a callback (all its subscriptions), or an
964
+ * `{ emitter, event }` contract (all of its subscriptions)
965
+ * @param {Object} [options]
966
+ * @param {String} [options.instance] Withdraw at this instance only
967
+ * @returns {Promise<Boolean>} true if anything was withdrawn
968
+ */
969
+ async unsubscribe (target, { instance } = {}) {
970
+ const all = Object.values(this._cachedSubscriptions)
971
+ let subs
972
+ if (typeof target === 'string' || Array.isArray(target)) {
973
+ const ids = Array.isArray(target) ? target : [target]
974
+ subs = ids
975
+ .map(id =>
976
+ this._cachedSubscriptions[id.startsWith('__e__') ? id.slice(5) : id]
977
+ )
978
+ .filter(Boolean)
979
+ } else if (this._isFunction(target)) {
980
+ subs = all.filter(sub => sub.callback === target)
981
+ } else if (this._isEmitter(target)) {
982
+ const callback = this._emitterCallback(target, { readOnly: true })
983
+ subs = callback ? all.filter(sub => sub.callback === callback) : []
984
+ } else {
985
+ throw new Error(
986
+ 'unsubscribe expects a subscription id, a callback or an { emitter, event } contract'
987
+ )
988
+ }
989
+ // at one instance: a callAll subscription stops there, one declared on
990
+ // that instance ends, any other is not held there
991
+ const atInstance = []
992
+ if (instance !== undefined && instance !== null) {
993
+ atInstance.push(...subs.filter(sub => sub.callAll))
994
+ subs = subs.filter(sub => !sub.callAll && sub.instance === instance)
995
+ }
996
+ if (subs.length === 0 && atInstance.length === 0) return false
997
+ // no event reaches the consumer from here on
998
+ subs.forEach(sub => this._dropSubscription(sub))
999
+ atInstance.forEach(sub => sub.withdrawn.add(instance))
1000
+ const byAgent = new Map()
1001
+ const entries = (agent, sub, context) => {
1002
+ if (!byAgent.has(agent)) byAgent.set(agent, [])
1003
+ byAgent.get(agent).push({ sub, context })
1004
+ }
1005
+ for (const sub of subs) entries(sub.agent, sub)
1006
+ for (const sub of atInstance) entries(sub.agent, sub, instance)
1007
+ const sends = []
1008
+ for (const [agent, agentEntries] of byAgent) {
1009
+ sends.push(this._withdrawAt(agent, agentEntries))
1010
+ }
1011
+ await Promise.allSettled(sends)
1012
+ return true
1013
+ }
1014
+
1015
+ /**
1016
+ * Withdraws subscriptions at their agent: at once when the agent is online
1017
+ * and this client connected, else as soon as both are again - an agent
1018
+ * that only lost its connection still holds them.
1019
+ *
1020
+ * @param {String} agent
1021
+ * @param {Array.<Object>} entries `{ sub, context }`, context (an
1022
+ * instance) for a withdrawal at one instance only
1023
+ */
1024
+ _withdrawAt (agent, entries) {
1025
+ const info = this._agents[agent]
1026
+ if (!this._isConnected() || !info || info.status !== 'online') {
1027
+ const pending = this._pendingWithdrawals.get(agent) || []
1028
+ pending.push(...entries)
1029
+ this._pendingWithdrawals.set(agent, pending)
1030
+ return Promise.resolve()
1031
+ }
1032
+ if (info.v >= 4) return this._sendUnsubscribe(agent, entries)
1033
+ // an older agent cannot withdraw at one instance: local only
1034
+ return Promise.allSettled(
1035
+ entries
1036
+ .filter(({ context }) => context === undefined)
1037
+ .map(({ sub }) => this._sendLegacyRemoval(sub))
1038
+ )
1039
+ }
1040
+
1041
+ // sends the withdrawals an agent missed, once it and this client are online
1042
+ _flushWithdrawals (agent) {
1043
+ const pending = this._pendingWithdrawals.get(agent)
1044
+ const info = this._agents[agent]
1045
+ if (!pending || !this._isConnected() || !info || info.status !== 'online') {
1046
+ return
1047
+ }
1048
+ this._pendingWithdrawals.delete(agent)
1049
+ this._withdrawAt(agent, pending)
1050
+ }
1051
+
1052
+ /**
1053
+ * An agent went offline: a loss, not an end. Its subscriptions are lost
1054
+ * until it serves again; an agent of protocol 4 gets them declared again
1055
+ * then (the loss heals - one that kept them changes nothing). Towards an
1056
+ * older agent a declaration again would register the handler twice: its
1057
+ * subscriptions are dropped, as they always were.
1058
+ */
1059
+ _loseAgent (agent, v) {
1060
+ if (!(v >= 4)) {
1061
+ this._redeclare.delete(agent)
1062
+ this._clearCachedSubscriptions({ lostAgent: agent })
1063
+ return
1064
+ }
1065
+ this._redeclare.add(agent)
1066
+ for (const sub of Object.values(this._cachedSubscriptions)) {
1067
+ if (sub.agent !== agent || sub.lost) continue
1068
+ sub.lost = true
1069
+ sub.confirmed = false
1070
+ this.emit('lost', sub.id, 'agentOffline', this._subscriptionInfo(sub))
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Lists the subscriptions this client holds.
1076
+ *
1077
+ * @param {Object} [filter]
1078
+ * @param {Function|Object} [filter.callback] Only the subscriptions of this
1079
+ * callback, or of this `{ emitter, event }` contract
1080
+ * @returns {Array.<Object>} `{ id, agent, className, instance,
1081
+ * functionName, event, callAll, confirmed, lost }` per subscription;
1082
+ * `confirmed` once a protocol-4 agent answered that it holds it, `lost`
1083
+ * while its agent is offline (it is declared again once the agent is back)
1084
+ */
1085
+ getSubscriptions ({ callback } = {}) {
1086
+ if (callback && this._isEmitter(callback)) {
1087
+ callback = this._emitterCallback(callback, { readOnly: true })
1088
+ if (!callback) return []
1089
+ }
1090
+ return Object.values(this._cachedSubscriptions)
1091
+ .filter(sub => !callback || sub.callback === callback)
1092
+ .map(sub => ({
1093
+ id: sub.id,
1094
+ ...this._subscriptionInfo(sub),
1095
+ confirmed: sub.confirmed,
1096
+ lost: sub.lost
1097
+ }))
1098
+ }
1099
+
1100
+ /**
1101
+ * Gives the subscriptions of a callback a label, which agents list with
1102
+ * their registrations (`__registrations__`), e.g. who listens.
1103
+ *
1104
+ * @param {Function|Object} callback The callback, or an `{ emitter, event }`
1105
+ * contract (labelled before or after it subscribes)
1106
+ * @param {String} label The label (sent with every declaration)
1107
+ */
1108
+ setSubscriberLabel (callback, label) {
1109
+ if (this._isEmitter(callback)) callback = this._emitterCallback(callback)
1110
+ this._subscriberLabels.set(callback, label)
1111
+ }
1112
+
1113
+ /**
1114
+ * Lists the registrations of an instance (or, without an instance, the
1115
+ * static ones of a class): function, arguments, subscriptions with their
1116
+ * labels, events and events per second. Needs a protocol-4 agent.
1117
+ *
1118
+ * @param {Object} options
1119
+ * @param {String} options.className Class name
1120
+ * @param {String} [options.instance] Instance name
1121
+ * @param {String} [options.agent] Agent name. If not provided class default is used
1122
+ * @returns {Promise<Array.<Object>>} The registrations
1123
+ */
1124
+ async getRegistrations ({ className, instance, agent = this._agent } = {}) {
1125
+ return this._callProtocol({ className, instance, agent }, '__registrations__', [])
1126
+ }
1127
+
1128
+ /**
1129
+ * Ends a registration for all its subscriptions; each subscriber learns
1130
+ * that it ended and by whom ('ended' event). Needs a protocol-4 agent.
1131
+ *
1132
+ * @param {Object} options
1133
+ * @param {String} options.className Class name
1134
+ * @param {String} [options.instance] Instance name (static registration without)
1135
+ * @param {String} [options.agent] Agent name. If not provided class default is used
1136
+ * @param {String} options.functionName The event function
1137
+ * @param {Array} [options.args] Its arguments without the handlers, as listed
1138
+ * @param {String} [options.by] Who ends it, told to every subscriber
1139
+ * @returns {Promise<Boolean>} Whether such a registration existed
1140
+ */
1141
+ async endRegistration ({
1142
+ className,
1143
+ instance,
1144
+ agent = this._agent,
1145
+ functionName,
1146
+ args = [],
1147
+ by = null
1148
+ } = {}) {
1149
+ return this._callProtocol({ className, instance, agent }, '__end__', [
1150
+ functionName,
1151
+ args,
1152
+ by
1153
+ ])
1154
+ }
1155
+
1156
+ async _callProtocol ({ className, instance, agent }, f, a) {
1157
+ if (!this._isConnected()) {
1158
+ throw new Error('Client is not connected')
1159
+ }
1160
+ if (instance && (!agent || agent === '*' || !className)) {
1161
+ const data = await this._getInstanceData(instance, { agent, className })
1162
+ agent = data.agent
1163
+ className = data.className
1164
+ }
1165
+ if (!agent || agent === '*') throw new Error('Agent must be specified')
749
1166
  await this._waitUntilClassIsOnline(agent, className)
1167
+ if (!(this._agents[agent].v >= 4)) {
1168
+ throw new Error(`Agent "${agent}" does not speak protocol 4`)
1169
+ }
1170
+ const json = {
1171
+ c: instance || className,
1172
+ f,
1173
+ a,
1174
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1175
+ s: this._vrpcConnectionId,
1176
+ v: VRPC_PROTOCOL_VERSION
1177
+ }
1178
+ const topic = `${this._domain}/${agent}/${className}/${instance || '__static__'}/${f}`
750
1179
  this._mqttPublish(topic, JSON.stringify(json))
751
1180
  return this._handleAgentAnswer(json, agent)
752
1181
  }
753
1182
 
1183
+ // withdraws by id on a protocol-4 agent, wherever it holds them (or, with
1184
+ // a context, at that instance only)
1185
+ async _sendUnsubscribe (agent, entries) {
1186
+ const className = entries[0].sub.className
1187
+ const json = {
1188
+ c: className,
1189
+ f: '__unsubscribe__',
1190
+ a: entries.map(({ sub, context }) =>
1191
+ context === undefined ? sub.id : { id: sub.id, context }
1192
+ ),
1193
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1194
+ s: this._vrpcConnectionId,
1195
+ v: VRPC_PROTOCOL_VERSION
1196
+ }
1197
+ const topic = `${this._domain}/${agent}/${className}/__static__/__unsubscribe__`
1198
+ this._mqttPublish(topic, JSON.stringify(json))
1199
+ try {
1200
+ await this._handleAgentAnswer(json, agent)
1201
+ } catch (err) {
1202
+ this._log.debug(`Withdrawing subscriptions failed: ${err.message}`)
1203
+ }
1204
+ }
1205
+
1206
+ // an agent before protocol 4: removeListener, or the class's off twin
1207
+ async _sendLegacyRemoval (sub) {
1208
+ if (sub.callAll || !sub.instance) return
1209
+ let f
1210
+ let a
1211
+ if (sub.functionName === 'on' || sub.functionName === 'addListener') {
1212
+ f = 'removeListener'
1213
+ a = [sub.event, sub.id]
1214
+ } else {
1215
+ const twin = sub.functionName.replace(/^on(?=[A-Z])/, 'off')
1216
+ const info =
1217
+ this._agents[sub.agent] &&
1218
+ this._agents[sub.agent].classes[sub.className]
1219
+ const functions = info ? info.memberFunctions.map(this._stripSignature) : []
1220
+ if (twin === sub.functionName || !functions.includes(twin)) return
1221
+ if (!sub.declaration) return
1222
+ f = twin
1223
+ a = sub.declaration.a
1224
+ }
1225
+ const json = {
1226
+ c: sub.instance,
1227
+ f,
1228
+ a,
1229
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
1230
+ s: this._vrpcConnectionId,
1231
+ v: VRPC_PROTOCOL_VERSION
1232
+ }
1233
+ const topic = `${this._domain}/${sub.agent}/${sub.className}/${sub.instance}/${f}`
1234
+ this._mqttPublish(topic, JSON.stringify(json))
1235
+ try {
1236
+ await this._handleAgentAnswer(json, sub.agent)
1237
+ } catch (err) {
1238
+ this._log.debug(`Removing a subscription failed: ${err.message}`)
1239
+ }
1240
+ }
1241
+
754
1242
  /**
755
1243
  * Retrieves all information about the currently available components.
756
1244
  *
@@ -906,8 +1394,10 @@ class VrpcClient extends EventEmitter {
906
1394
  this._mqttPublish(agentTopic, null, { retain: true })
907
1395
  const classes = this.getAvailableClasses({ agent, mustBeOnline: false })
908
1396
  for (const className of classes) {
909
- const classTopic = `${this._domain}/${agent}/${className}/__classInfo__`
910
- this._mqttPublish(classTopic, null, { retain: true })
1397
+ for (const info of ['__classInfo__', '__classInfoConcise__']) {
1398
+ const classTopic = `${this._domain}/${agent}/${className}/${info}`
1399
+ this._mqttPublish(classTopic, null, { retain: true })
1400
+ }
911
1401
  }
912
1402
  // Synchronize local cache
913
1403
  delete this._agents[agent]
@@ -932,8 +1422,14 @@ class VrpcClient extends EventEmitter {
932
1422
  v: VRPC_PROTOCOL_VERSION
933
1423
  })
934
1424
  )
1425
+ this._loseAnswers(() => true, 'the client ended')
935
1426
  this._eventEmitter.removeAllListeners()
936
1427
  this.removeAllListeners()
1428
+ // the agents withdraw what this connection held: nothing to declare
1429
+ // again, nothing left to withdraw
1430
+ this._cachedSubscriptions = {}
1431
+ this._redeclare.clear()
1432
+ this._pendingWithdrawals.clear()
937
1433
  const client = this._client
938
1434
  this._client = null // Prevent race conditions during shutdown
939
1435
  return new Promise(resolve =>
@@ -966,13 +1462,6 @@ class VrpcClient extends EventEmitter {
966
1462
  return this._client && this._client.connected
967
1463
  }
968
1464
 
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
1465
 
977
1466
  _generateUserName () {
978
1467
  return `${this._domain}:client@${os.hostname()}-${os.platform()}-js`
@@ -1045,31 +1534,36 @@ class VrpcClient extends EventEmitter {
1045
1534
  client.subscribe(
1046
1535
  topic,
1047
1536
  { qos: this._qos, ...options },
1048
- (err, granted) => {
1049
- if (err) {
1537
+ (subscribeError, subs) => {
1538
+ const { refused, granted, error } = SubscribeRetrier.outcome(
1539
+ subscribeError,
1540
+ subs
1541
+ )
1542
+ if (error) {
1050
1543
  this._log.warn(
1051
- `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
1544
+ `Could not subscribe to topic(s): '${topic}', because: ${error.message}`
1052
1545
  )
1053
1546
  if (
1054
- err.message === 'client disconnecting' &&
1547
+ error.message === 'client disconnecting' &&
1055
1548
  this._isRevivable(client)
1056
1549
  ) {
1057
1550
  // This most certainly relates to a bug in MQTT.js (#1284)
1058
- const msg = `Failed to subscribe, because: ${err.message}`
1551
+ const msg = `Failed to subscribe, because: ${error.message}`
1059
1552
  this._log.error(msg)
1060
- this.emit('error', err)
1553
+ this.emit('error', error)
1061
1554
  this._log.info('Forcing reconnect now...')
1062
1555
  client.disconnecting = false
1063
1556
  client.reconnect()
1064
1557
  reject(new Error(msg))
1065
- } else if (err.message === 'client disconnecting') {
1066
- reject(err)
1558
+ } else {
1559
+ // the connection broke under the subscribe: the next connect
1560
+ // subscribes everything afresh
1561
+ reject(error)
1067
1562
  }
1068
1563
  } else {
1564
+ let err
1069
1565
  const topicArray = Array.isArray(topic) ? topic : [topic]
1070
- const erroneousGranted = granted
1071
- .filter(x => x.qos === 128)
1072
- .map(x => x.topic)
1566
+ const erroneousGranted = refused
1073
1567
  if (erroneousGranted.length > 0) {
1074
1568
  err = new Error(
1075
1569
  `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
@@ -1080,9 +1574,7 @@ class VrpcClient extends EventEmitter {
1080
1574
  this.emit('error', err)
1081
1575
  this._subscribeRetrier.schedule(erroneousGranted, options)
1082
1576
  }
1083
- this._subscribeRetrier.granted(
1084
- granted.filter(x => x.qos !== 128).map(x => x.topic)
1085
- )
1577
+ this._subscribeRetrier.granted(granted.map(x => x.topic))
1086
1578
  const reducedQos = granted.filter(x => x.qos < this._qos)
1087
1579
  if (reducedQos.length > 0) {
1088
1580
  err = new Error(
@@ -1099,7 +1591,7 @@ class VrpcClient extends EventEmitter {
1099
1591
  this._log.warn(err.message)
1100
1592
  this.emit('error', err)
1101
1593
  }
1102
- if (granted.length === 0) {
1594
+ if (granted.length === 0 && refused.length === 0) {
1103
1595
  this._log.debug(`Already subscribed to: ${topic}`)
1104
1596
  }
1105
1597
  resolve()
@@ -1135,12 +1627,16 @@ class VrpcClient extends EventEmitter {
1135
1627
  await this._waitUntilClassIsOnline(agent, className)
1136
1628
  this._mqttPublish(topic, JSON.stringify(json))
1137
1629
  return new Promise((resolve, reject) => {
1138
- const msg = `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
1630
+ const call = `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}"`
1139
1631
  const timer = setTimeout(() => {
1140
- this._eventEmitter.removeAllListeners(i)
1141
- reject(new Error(msg))
1632
+ forget()
1633
+ reject(new Error(`${call} timed out (> ${this._timeout} ms)`))
1142
1634
  }, this._timeout)
1143
- this._eventEmitter.once(i, ({ e, r }) => {
1635
+ const lose = reason => {
1636
+ clearTimeout(timer)
1637
+ reject(new Error(`${call} lost its answer: ${reason}`))
1638
+ }
1639
+ const forget = this._awaitAnswer(i, { agent, lose }, ({ e, r }) => {
1144
1640
  clearTimeout(timer)
1145
1641
  if (e) {
1146
1642
  // the agent answers a throwing constructor with { message, cause }
@@ -1160,15 +1656,19 @@ class VrpcClient extends EventEmitter {
1160
1656
  if (this._hasClassOnline(agent, className)) return
1161
1657
  return new Promise((resolve, reject) => {
1162
1658
  let timer = null
1659
+ // the class info and the agent info travel on topics of their own and
1660
+ // may come in either order: each of them can complete the picture
1163
1661
  const checkClass = () => {
1164
1662
  if (this._hasClassOnline(agent, className)) {
1165
1663
  if (timer) clearTimeout(timer)
1166
1664
  this.removeListener('class', checkClass)
1665
+ this.removeListener('agent', checkClass)
1167
1666
  resolve()
1168
1667
  }
1169
1668
  }
1170
1669
  timer = setTimeout(() => {
1171
1670
  this.removeListener('class', checkClass)
1671
+ this.removeListener('agent', checkClass)
1172
1672
  reject(
1173
1673
  new Error(
1174
1674
  `Connection to class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
@@ -1176,6 +1676,7 @@ class VrpcClient extends EventEmitter {
1176
1676
  )
1177
1677
  }, this._timeout)
1178
1678
  this.on('class', checkClass)
1679
+ this.on('agent', checkClass)
1179
1680
  })
1180
1681
  }
1181
1682
 
@@ -1214,23 +1715,18 @@ class VrpcClient extends EventEmitter {
1214
1715
  throw new Error('VRPC does not support removing all listeners')
1215
1716
  }
1216
1717
 
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
- })
1718
+ // this client's subscriptions of that event on this instance end;
1719
+ // a protocol-4 agent withdraws exactly those, never touching the
1720
+ // emitter's other listeners
1721
+ Object.values(this._cachedSubscriptions)
1722
+ .filter(
1723
+ sub =>
1724
+ sub.agent === agent &&
1725
+ sub.className === className &&
1726
+ sub.instance === instance &&
1727
+ sub.event === eventName
1728
+ )
1729
+ .forEach(sub => this._dropSubscription(sub))
1234
1730
  try {
1235
1731
  const json = {
1236
1732
  c: instance,
@@ -1276,6 +1772,7 @@ class VrpcClient extends EventEmitter {
1276
1772
  s: this._vrpcConnectionId,
1277
1773
  v: VRPC_PROTOCOL_VERSION
1278
1774
  }
1775
+ this._declare(json, `${targetTopic}/${functionName}`)
1279
1776
  this._mqttPublish(
1280
1777
  `${targetTopic}/${functionName}`,
1281
1778
  JSON.stringify(json)
@@ -1311,6 +1808,7 @@ class VrpcClient extends EventEmitter {
1311
1808
  s: this._vrpcConnectionId,
1312
1809
  v: VRPC_PROTOCOL_VERSION
1313
1810
  }
1811
+ this._declare(json, `${targetTopic}/${functionName}`)
1314
1812
  this._mqttPublish(
1315
1813
  `${targetTopic}/${functionName}`,
1316
1814
  JSON.stringify(json)
@@ -1322,32 +1820,53 @@ class VrpcClient extends EventEmitter {
1322
1820
  )
1323
1821
  }
1324
1822
  }
1325
- proxy.vrpcOff = functionName => {
1326
- const topic = `${this._domain}/${agent}/${className}/${instance}-${functionName}`
1327
- const id = `__e__${topic}`
1328
- this._eventEmitter.removeAllListeners(id)
1823
+ // withdraws this client's subscriptions of a function on this instance
1824
+ proxy.vrpcOff = async functionName => {
1825
+ const ids = Object.values(this._cachedSubscriptions)
1826
+ .filter(
1827
+ sub =>
1828
+ sub.agent === agent &&
1829
+ sub.className === className &&
1830
+ sub.instance === instance &&
1831
+ sub.functionName === functionName
1832
+ )
1833
+ .map(sub => sub.id)
1834
+ if (ids.length === 0) return false
1835
+ return this.unsubscribe(ids)
1329
1836
  }
1330
1837
  }
1331
1838
  return proxy
1332
1839
  }
1333
1840
 
1334
- async _handleAgentAnswer ({ i, c, f }, agent) {
1841
+ async _handleAgentAnswer ({ i, c, f, a }, agent) {
1335
1842
  return new Promise((resolve, reject) => {
1336
- const msg = `Function call "${c}::${f}()" on agent "${agent}" timed out (> ${this._timeout} ms)`
1843
+ const call = `Function call "${c}::${f}()" on agent "${agent}"`
1337
1844
  const timer = setTimeout(() => {
1338
- this._eventEmitter.removeAllListeners(i)
1339
- reject(new Error(msg))
1845
+ forget()
1846
+ reject(new Error(`${call} timed out (> ${this._timeout} ms)`))
1340
1847
  }, this._timeout)
1341
- this._eventEmitter.once(i, data => {
1848
+ const lose = reason => {
1849
+ clearTimeout(timer)
1850
+ reject(new Error(`${call} lost its answer: ${reason}`))
1851
+ }
1852
+ // a protocol-4 agent that answers a declaring call without an error
1853
+ // holds every subscription the call carried
1854
+ const confirm = () => {
1855
+ const info = this._agents[agent]
1856
+ if (info && info.v >= 4) this._confirmSubscriptions(a)
1857
+ }
1858
+ const forget = this._awaitAnswer(i, { agent, lose }, data => {
1342
1859
  clearTimeout(timer)
1343
1860
  if (data.e) {
1344
1861
  const { message, cause } = VrpcClient._prepareError(data.e)
1345
1862
  reject(new Error(`[vrpc ${agent}-${c}-${f}]: ${message}`, { cause }))
1346
1863
  } else {
1347
1864
  const ret = data.r
1348
- // Handle functions returning a promise
1865
+ // Handle functions returning a promise: the result follows when
1866
+ // it settles, however long that takes - unless the path it would
1867
+ // travel breaks first
1349
1868
  if (typeof ret === 'string' && ret.substring(0, 5) === '__p__') {
1350
- this._eventEmitter.once(ret, promiseData => {
1869
+ this._awaitAnswer(ret, { agent, lose, second: true }, promiseData => {
1351
1870
  if (promiseData.e) {
1352
1871
  const { message, cause } = VrpcClient._prepareError(
1353
1872
  promiseData.e
@@ -1356,10 +1875,12 @@ class VrpcClient extends EventEmitter {
1356
1875
  new Error(`[vrpc ${agent}-${c}-${f}]: ${message}`, { cause })
1357
1876
  )
1358
1877
  } else {
1878
+ confirm()
1359
1879
  resolve(promiseData.r)
1360
1880
  }
1361
1881
  })
1362
1882
  } else {
1883
+ confirm()
1363
1884
  resolve(ret)
1364
1885
  }
1365
1886
  }
@@ -1367,6 +1888,44 @@ class VrpcClient extends EventEmitter {
1367
1888
  })
1368
1889
  }
1369
1890
 
1891
+ /**
1892
+ * Waits for the answer with id `id`.
1893
+ *
1894
+ * @param {String} id
1895
+ * @param {Object} waiter
1896
+ * @param {String} waiter.agent the agent that answers
1897
+ * @param {Function} waiter.lose called with a reason when the answer can
1898
+ * no longer arrive
1899
+ * @param {Boolean} [waiter.second] a promise's result, the second answer
1900
+ * to its call
1901
+ * @param {Function} onAnswer called with the answer
1902
+ * @returns {Function} stops waiting, without a call to `lose`
1903
+ */
1904
+ _awaitAnswer (id, { agent, lose, second = false }, onAnswer) {
1905
+ const waiter = { agent, lose, second }
1906
+ const listener = data => {
1907
+ this._waiting.delete(waiter)
1908
+ onAnswer(data)
1909
+ }
1910
+ waiter.forget = () => {
1911
+ this._waiting.delete(waiter)
1912
+ this._eventEmitter.removeListener(id, listener)
1913
+ }
1914
+ this._waiting.add(waiter)
1915
+ this._eventEmitter.once(id, listener)
1916
+ return waiter.forget
1917
+ }
1918
+
1919
+ // the answers that can no longer arrive: their calls reject at once, and
1920
+ // a late answer finds no listener
1921
+ _loseAnswers (selects, reason) {
1922
+ for (const waiter of Array.from(this._waiting)) {
1923
+ if (!selects(waiter)) continue
1924
+ waiter.forget()
1925
+ waiter.lose(reason)
1926
+ }
1927
+ }
1928
+
1370
1929
  static _prepareError (rpcError) {
1371
1930
  if (typeof rpcError === 'object') {
1372
1931
  return { message: rpcError.message, cause: rpcError.cause }
@@ -1396,51 +1955,114 @@ class VrpcClient extends EventEmitter {
1396
1955
  })
1397
1956
  }
1398
1957
 
1958
+ /**
1959
+ * Whether a function is an event function: declared `@returns
1960
+ * {Registration}` (when the class info carries meta data), else named `on`
1961
+ * followed by a capital letter. The prefixes `notify`, `monitor` and
1962
+ * `signal` still count (deprecated, gone in 4.0).
1963
+ */
1964
+ _isEventFunction (agent, className, functionName) {
1965
+ const info =
1966
+ this._agents[agent] &&
1967
+ this._agents[agent].classes &&
1968
+ this._agents[agent].classes[className]
1969
+ const meta = info && info.meta && info.meta[functionName]
1970
+ const type = meta && meta.ret && meta.ret.type
1971
+ if (typeof type === 'string' && DECLARED_REGISTRATION.test(type.trim())) {
1972
+ return true
1973
+ }
1974
+ if (/^on[A-Z]/.test(functionName)) return true
1975
+ return /^(notify|monitor|signal)/.test(functionName)
1976
+ }
1977
+
1399
1978
  async _wrapArguments ({
1400
1979
  agent,
1401
1980
  className,
1402
1981
  instance,
1403
1982
  proxyId,
1404
1983
  functionName,
1405
- args
1984
+ args,
1985
+ isCallAll = false,
1986
+ selection
1406
1987
  }) {
1407
1988
  const wrapped = []
1408
1989
 
1409
- // Heuristic for continuous event emitters and their removal
1410
- const isContinuousEmitter = /^(on|notify|monitor|signal)/.test(functionName)
1990
+ const isEventFunction = this._isEventFunction(
1991
+ agent,
1992
+ className,
1993
+ functionName
1994
+ )
1995
+ // continuous removers (legacy): withdraw the callback's subscription
1411
1996
  const isContinuousRemover = /^(off|remove|unmonitor|unsignal)/.test(
1412
1997
  functionName
1413
1998
  )
1999
+ const isEmitterRemover =
2000
+ (functionName === 'off' || functionName === 'removeListener') &&
2001
+ typeof args[0] === 'string'
1414
2002
 
1415
2003
  for (const [i, x] of args.entries()) {
1416
- if (this._isFunction(x)) {
1417
- const callback = x
2004
+ const isEmitterContract = this._isEmitter(x)
2005
+ if (this._isFunction(x) || isEmitterContract) {
2006
+ const removes =
2007
+ !isEventFunction &&
2008
+ ((isEmitterRemover && i === 1) || isContinuousRemover)
2009
+ const callback = isEmitterContract
2010
+ ? this._emitterCallback(x, { readOnly: removes })
2011
+ : x
1418
2012
  if (
1419
- functionName === 'vrpcOn' ||
1420
- (functionName === 'on' && i === 1 && typeof args[0] === 'string') ||
1421
- isContinuousEmitter
2013
+ !removes &&
2014
+ (isEmitterContract ||
2015
+ functionName === 'vrpcOn' ||
2016
+ ((functionName === 'on' || functionName === 'addListener') &&
2017
+ i === 1 &&
2018
+ typeof args[0] === 'string') ||
2019
+ isEventFunction)
1422
2020
  ) {
1423
2021
  const event = typeof args[0] === 'string' ? args[0] : functionName
2022
+ // vrpcOn names the event function first
2023
+ const isVrpcOn = functionName === 'vrpcOn'
1424
2024
  const id = await this._onRemoteEvent({
1425
2025
  agent,
1426
2026
  className,
1427
2027
  instance,
1428
2028
  event,
1429
- callback
2029
+ callback,
2030
+ functionName: isVrpcOn ? args[0] : functionName,
2031
+ args: isVrpcOn ? args.slice(1) : args,
2032
+ position: isVrpcOn ? i - 1 : i,
2033
+ isCallAll,
2034
+ selection
1430
2035
  })
1431
2036
  wrapped.push(id)
1432
2037
  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
- ) {
2038
+ } else if (removes) {
2039
+ const subs = callback
2040
+ ? this._offRemoteEvent({
2041
+ agent,
2042
+ className,
2043
+ instance,
2044
+ isCallAll,
2045
+ selection,
2046
+ functionName,
2047
+ args,
2048
+ position: i,
2049
+ callback
2050
+ })
2051
+ : []
2052
+ if (subs.length > 0) {
2053
+ const info = this._agents[agent]
2054
+ if (info && info.v >= 4) {
2055
+ // protocol 4 withdraws by id and never calls the class's
2056
+ // remover: handled here
2057
+ await this._withdrawAt(agent, subs.map(sub => ({ sub })))
2058
+ return null
2059
+ }
2060
+ // an agent before protocol 4 knows only the remover call, which
2061
+ // carries the id (further subscriptions end locally)
2062
+ wrapped.push(subs[0].id)
2063
+ continue
2064
+ }
2065
+ if (functionName === 'off' || functionName === 'removeListener') {
1444
2066
  // Nothing of ours to remove - the subscription was already
1445
2067
  // dropped (the instance or its agent went away and the cache
1446
2068
  // was cleared, or the listener was never registered). Sending
@@ -1448,43 +2070,28 @@ class VrpcClient extends EventEmitter {
1448
2070
  // its EventEmitter rejects. Handled locally: nothing to do.
1449
2071
  return null
1450
2072
  }
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)
2073
+ // no subscription of this callback: an { emitter, event }
2074
+ // contract stays empty, a function is a plain callback after all
2075
+ // (removeItem(id, done))
2076
+ if (isEmitterContract) {
2077
+ wrapped.push(null)
2078
+ continue
1475
2079
  }
1476
- } else {
1477
- this._emitterListener.set(emitter, new Map([[event, callback]]))
1478
2080
  }
1479
-
1480
- const id = await this._onRemoteEvent({
1481
- agent,
1482
- className,
1483
- instance,
1484
- event,
1485
- callback
1486
- })
2081
+ // Regular function callback (can be static or member function)
2082
+ const remoteId = proxyId || `${agent}-${className}`
2083
+ const id = `__f__${remoteId}-${functionName}-${i}-${
2084
+ this._invokeId++ % Number.MAX_SAFE_INTEGER
2085
+ }`
1487
2086
  wrapped.push(id)
2087
+ // a callback the agent could not encode is told, never called (CALL-10)
2088
+ this._eventEmitter.once(id, ({ a, e }) => {
2089
+ if (e) {
2090
+ this.emit('undelivered', id, e, { agent, className, instance, functionName })
2091
+ return
2092
+ }
2093
+ callback.apply(null, a)
2094
+ })
1488
2095
  continue
1489
2096
  }
1490
2097
  // default behavior is to not touch
@@ -1493,98 +2100,378 @@ class VrpcClient extends EventEmitter {
1493
2100
  return wrapped
1494
2101
  }
1495
2102
 
1496
- async _onRemoteEvent ({ agent, className, instance, event, callback }) {
1497
- const callbackId = this._getCallbackId(callback)
2103
+ // the one callback standing for an { emitter, event } contract
2104
+ _emitterCallback ({ emitter, event }, { readOnly = false } = {}) {
2105
+ let eventMap = this._emitterListener.get(emitter)
2106
+ if (eventMap && eventMap.has(event)) return eventMap.get(event)
2107
+ if (readOnly) return null
2108
+ const callback = (...args) => emitter.emit(event, ...args)
2109
+ if (!eventMap) {
2110
+ eventMap = new Map()
2111
+ this._emitterListener.set(emitter, eventMap)
2112
+ }
2113
+ eventMap.set(event, callback)
2114
+ return callback
2115
+ }
2116
+
2117
+ /**
2118
+ * What a subscription is to the agent: its function (`addListener` is
2119
+ * `on`) and arguments compared by value, the callback's own position
2120
+ * named and other callbacks marked. Two subscriptions of one callback
2121
+ * differ in it whenever the agent keeps them apart, so their ids differ:
2122
+ * an id names one registration per instance.
2123
+ */
2124
+ _subscriptionKey (functionName, args, position) {
2125
+ const func = functionName === 'addListener' ? 'on' : functionName
2126
+ const marked = args.map((x, i) => {
2127
+ if (i === position) return '\u0000vrpc-self'
2128
+ if (this._isFunction(x) || this._isEmitter(x)) return '\u0000vrpc-callback'
2129
+ return x
2130
+ })
2131
+ return `${func}\u0000${VrpcClient._canonical(marked)}`
2132
+ }
1498
2133
 
1499
- // Generate a short, sanitized hash
1500
- const hashInput = `${agent}-${className}-${
1501
- instance || '__static__'
1502
- }-${event}-${callbackId}`
1503
- const topicId = VrpcClient._createHash(hashInput, 12)
2134
+ // arguments compared by value: equal JSON once object keys are sorted
2135
+ static _canonical (value) {
2136
+ const sortKeys = x => {
2137
+ if (Array.isArray(x)) return x.map(sortKeys)
2138
+ if (x && typeof x === 'object' && x.constructor === Object) {
2139
+ const sorted = {}
2140
+ for (const key of Object.keys(x).sort()) sorted[key] = sortKeys(x[key])
2141
+ return sorted
2142
+ }
2143
+ return x
2144
+ }
2145
+ return JSON.stringify(sortKeys(value))
2146
+ }
1504
2147
 
1505
- // Route directly to the client's single event inbox wildcard
1506
- const topic = `${this._vrpcConnectionId}/${topicId}`
1507
- const id = `__e__${topic}`
2148
+ // the event function a remover undoes: off/removeListener -> on,
2149
+ // offValue -> onValue, unmonitorX -> monitorX
2150
+ static _twinOf (functionName) {
2151
+ if (functionName === 'off' || functionName === 'removeListener') return 'on'
2152
+ return functionName.replace(/^(off|un)(?=[A-Za-z])/, m =>
2153
+ m === 'off' ? 'on' : ''
2154
+ )
2155
+ }
1508
2156
 
1509
- const handler = ({ a }) => callback.apply(null, a)
2157
+ // what a subscription is, but its callback: target, the instances a
2158
+ // callAll selects, and subscription key
2159
+ static _identity ({ agent, className, instance, isCallAll, selection, key }) {
2160
+ const target = [agent, className, instance || null, !!isCallAll, key]
2161
+ return JSON.stringify(selection === undefined ? target : [...target, selection])
2162
+ }
1510
2163
 
1511
- // Only bind to the actual emitter on the first registration
1512
- if (this._eventEmitter.listenerCount(id) === 0) {
1513
- this._eventEmitter.on(id, handler)
2164
+ /**
2165
+ * The topic a subscription's events arrive on; its id is `__e__<topic>`.
2166
+ * One per identity (target, function, arguments and callback), counted,
2167
+ * not hashed: two identities never share one. An identity keeps its
2168
+ * topic while its callback lives, so a subscription declared again after
2169
+ * a loss has the id an agent may still hold (which then changes
2170
+ * nothing); a withdrawn one gets a fresh id when declared anew (see
2171
+ * _dropSubscription), so a late withdrawal never meets the new one.
2172
+ */
2173
+ _subscriptionTopic (callback, identity, { readOnly = false } = {}) {
2174
+ let topics = this._subscriptionTopics.get(callback)
2175
+ if (!topics) {
2176
+ if (readOnly) return null
2177
+ topics = new Map()
2178
+ this._subscriptionTopics.set(callback, topics)
2179
+ }
2180
+ let topic = topics.get(identity)
2181
+ if (!topic && !readOnly) {
2182
+ // routed by the client's single event inbox wildcard
2183
+ topic = `${this._vrpcConnectionId}/s${(this._subscriptionCount++).toString(36)}`
2184
+ topics.set(identity, topic)
1514
2185
  }
2186
+ return topic || null
2187
+ }
1515
2188
 
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 }]
2189
+ /**
2190
+ * A subscription: one per target, function, arguments and callback, held
2191
+ * once however often it is declared (the agent keeps it as a set member,
2192
+ * too).
2193
+ */
2194
+ async _onRemoteEvent ({
2195
+ agent,
2196
+ className,
2197
+ instance,
2198
+ event,
2199
+ callback,
2200
+ functionName,
2201
+ args,
2202
+ position,
2203
+ isCallAll = false,
2204
+ selection
2205
+ }) {
2206
+ const identity = VrpcClient._identity({
2207
+ agent,
2208
+ className,
2209
+ instance,
2210
+ isCallAll,
2211
+ selection,
2212
+ key: this._subscriptionKey(functionName, args, position)
2213
+ })
2214
+ const topic = this._subscriptionTopic(callback, identity)
2215
+ const id = `__e__${topic}`
2216
+ if (this._cachedSubscriptions[topic]) return id
2217
+ const sub = {
2218
+ id,
2219
+ identity,
2220
+ agent,
2221
+ className,
2222
+ instance,
2223
+ event,
2224
+ functionName,
2225
+ callAll: isCallAll,
2226
+ // the instances a callAll selects (absent: all)
2227
+ selection,
2228
+ callback,
2229
+ handler: null,
2230
+ declaration: null,
2231
+ confirmed: false,
2232
+ // its agent went offline: declared again once it is back
2233
+ lost: false,
2234
+ // callAll: the instances it was withdrawn at (unsubscribe's instance)
2235
+ withdrawn: new Set()
2236
+ }
2237
+ // an ended notice carries `end` and never reaches the callback, nor
2238
+ // does an event the agent could not encode (`e`, EV-22); a callAll
2239
+ // event names its instance first
2240
+ sub.handler = ({ a, end, e }) => {
2241
+ if (end) {
2242
+ this._handleEnded(topic, end)
2243
+ return
1524
2244
  }
1525
- } else {
1526
- this._cachedSubscriptions[topic].listeners.push({ callback, handler })
2245
+ if (e) {
2246
+ this.emit('undelivered', id, e, this._subscriptionInfo(sub))
2247
+ return
2248
+ }
2249
+ if (sub.withdrawn.size > 0 && Array.isArray(a) && sub.withdrawn.has(a[0])) {
2250
+ return
2251
+ }
2252
+ callback.apply(null, a)
1527
2253
  }
1528
-
2254
+ this._eventEmitter.on(id, sub.handler)
2255
+ this._cachedSubscriptions[topic] = sub
1529
2256
  return id
1530
2257
  }
1531
2258
 
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)
2259
+ /**
2260
+ * The subscriptions a remover call names, withdrawn locally: the one its
2261
+ * arguments name (offValue(1, cb) undoes onValue(1, cb)), else all of the
2262
+ * callback's subscriptions of that function on the target (offValue(cb)
2263
+ * after onValue(1, cb) and onValue(2, cb)), or of any function when the
2264
+ * remover names none the client knows.
2265
+ */
2266
+ _offRemoteEvent ({
2267
+ agent,
2268
+ className,
2269
+ instance,
2270
+ isCallAll,
2271
+ selection,
2272
+ functionName,
2273
+ args,
2274
+ position,
2275
+ callback
2276
+ }) {
2277
+ const twin = VrpcClient._twinOf(functionName)
2278
+ const exact = this._subscriptionTopic(
2279
+ callback,
2280
+ VrpcClient._identity({
2281
+ agent,
2282
+ className,
2283
+ instance,
2284
+ isCallAll,
2285
+ selection,
2286
+ key: this._subscriptionKey(twin, args, position)
2287
+ }),
2288
+ { readOnly: true }
2289
+ )
2290
+ let subs = exact && this._cachedSubscriptions[exact]
2291
+ ? [this._cachedSubscriptions[exact]]
2292
+ : []
2293
+ if (subs.length === 0) {
2294
+ const ofCallback = Object.values(this._cachedSubscriptions).filter(
2295
+ sub =>
2296
+ sub.callback === callback &&
2297
+ sub.agent === agent &&
2298
+ sub.className === className &&
2299
+ sub.instance === instance &&
2300
+ sub.callAll === !!isCallAll &&
2301
+ JSON.stringify(sub.selection) === JSON.stringify(selection)
2302
+ )
2303
+ const ofTwin = ofCallback.filter(
2304
+ sub =>
2305
+ (sub.functionName === 'addListener' ? 'on' : sub.functionName) ===
2306
+ twin
2307
+ )
2308
+ subs = ofTwin.length > 0 ? ofTwin : ofCallback
2309
+ }
2310
+ subs.forEach(sub => this._dropSubscription(sub))
2311
+ return subs
2312
+ }
1548
2313
 
1549
- // Only remove from the local event emitter if NO references remain
1550
- if (sub.listeners.length === 0) {
1551
- delete this._cachedSubscriptions[topic]
2314
+ /**
2315
+ * Ends a subscription locally: no event reaches its callback any more. A
2316
+ * withdrawn or ended subscription also gives up its id (declared anew, it
2317
+ * gets a fresh one); a lost one keeps it (`forget: false`), so declaring
2318
+ * it again matches what an agent that went away and came back still
2319
+ * holds.
2320
+ */
2321
+ _dropSubscription (sub, { forget = true } = {}) {
2322
+ this._eventEmitter.removeListener(sub.id, sub.handler)
2323
+ const topic = sub.id.slice(5)
2324
+ if (this._cachedSubscriptions[topic] === sub) {
2325
+ delete this._cachedSubscriptions[topic]
2326
+ }
2327
+ if (forget) {
2328
+ const topics = this._subscriptionTopics.get(sub.callback)
2329
+ if (topics && topics.get(sub.identity) === topic) {
2330
+ topics.delete(sub.identity)
2331
+ }
2332
+ }
2333
+ }
1552
2334
 
1553
- // Cleanly wipe all listeners for this specific id
1554
- this._eventEmitter.removeAllListeners(id)
1555
- }
1556
- foundId = id
2335
+ /**
2336
+ * Remembers the call that declared a subscription (to declare it again
2337
+ * after a reconnect) and sends the subscriber labels along.
2338
+ */
2339
+ _declare (json, topic) {
2340
+ const labels = {}
2341
+ let labelled = false
2342
+ for (const arg of json.a) {
2343
+ if (typeof arg !== 'string' || !arg.startsWith('__e__')) continue
2344
+ const sub = this._cachedSubscriptions[arg.slice(5)]
2345
+ if (!sub) continue
2346
+ sub.declaration = { topic, c: json.c, f: json.f, a: json.a }
2347
+ // a declaration names what is wanted now: earlier withdrawals at
2348
+ // single instances are over
2349
+ sub.withdrawn.clear()
2350
+ const label = this._subscriberLabels.get(sub.callback)
2351
+ if (label !== undefined) {
2352
+ labels[arg] = label
2353
+ labelled = true
1557
2354
  }
1558
- })
1559
- return foundId
2355
+ }
2356
+ if (labelled) {
2357
+ json.l = labels
2358
+ for (const id of Object.keys(labels)) {
2359
+ const sub = this._cachedSubscriptions[id.slice(5)]
2360
+ if (sub && sub.declaration) sub.declaration.l = labels
2361
+ }
2362
+ }
1560
2363
  }
1561
2364
 
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
- )
2365
+ // marks the subscriptions among a declaring call's arguments as held
2366
+ _confirmSubscriptions (args) {
2367
+ if (!Array.isArray(args)) return
2368
+ for (const arg of args) {
2369
+ const sub =
2370
+ typeof arg === 'string' &&
2371
+ arg.startsWith('__e__') &&
2372
+ this._cachedSubscriptions[arg.slice(5)]
2373
+ if (sub) sub.confirmed = true
2374
+ }
2375
+ }
2376
+
2377
+ // declares every subscription held towards an agent again (protocol 4)
2378
+ _redeclareTo (agent) {
2379
+ if (!this._inboxReady || !this._redeclare.has(agent)) return
2380
+ const info = this._agents[agent]
2381
+ if (!info || info.status !== 'online') return
2382
+ this._redeclare.delete(agent)
2383
+ if (!(info.v >= 4)) return // an older agent learns nothing new
2384
+ const declarations = new Map()
2385
+ for (const sub of Object.values(this._cachedSubscriptions)) {
2386
+ if (sub.agent !== agent || !sub.declaration) continue
2387
+ const { topic, a } = sub.declaration
2388
+ const key = `${topic}\u0000${JSON.stringify(a)}`
2389
+ if (!declarations.has(key)) {
2390
+ declarations.set(key, { declaration: sub.declaration, subs: [] })
1575
2391
  }
1576
- )
2392
+ declarations.get(key).subs.push(sub)
2393
+ }
2394
+ for (const { declaration, subs } of declarations.values()) {
2395
+ const { topic, c, f, a, l } = declaration
2396
+ const json = {
2397
+ c,
2398
+ f,
2399
+ a,
2400
+ i: `${this._instance}-${this._invokeId++ % Number.MAX_SAFE_INTEGER}`,
2401
+ s: this._vrpcConnectionId,
2402
+ v: VRPC_PROTOCOL_VERSION
2403
+ }
2404
+ if (l) json.l = l
2405
+ this._mqttPublish(topic, JSON.stringify(json))
2406
+ this._handleAgentAnswer(json, agent).then(
2407
+ () => {
2408
+ const entries = []
2409
+ for (const sub of subs) {
2410
+ if (this._cachedSubscriptions[sub.id.slice(5)] !== sub) continue
2411
+ // a lost subscription is back
2412
+ if (sub.lost) {
2413
+ sub.lost = false
2414
+ this.emit('healed', sub.id, this._subscriptionInfo(sub))
2415
+ }
2416
+ // what was withdrawn at single instances stays withdrawn there
2417
+ for (const context of sub.withdrawn) entries.push({ sub, context })
2418
+ }
2419
+ if (entries.length > 0) return this._sendUnsubscribe(agent, entries)
2420
+ },
2421
+ err => {
2422
+ for (const sub of subs) {
2423
+ if (this._cachedSubscriptions[sub.id.slice(5)] !== sub) continue
2424
+ this._dropSubscription(sub, { forget: false })
2425
+ this.emit('lost', sub.id, err.message, this._subscriptionInfo(sub))
2426
+ }
2427
+ }
2428
+ )
2429
+ }
2430
+ }
1577
2431
 
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]
2432
+ _subscriptionInfo (sub) {
2433
+ return {
2434
+ agent: sub.agent,
2435
+ className: sub.className,
2436
+ instance: sub.instance,
2437
+ functionName: sub.functionName,
2438
+ event: sub.event,
2439
+ callAll: sub.callAll,
2440
+ ...(sub.selection === undefined ? {} : { instances: sub.selection })
2441
+ }
2442
+ }
1582
2443
 
1583
- sub.listeners.forEach(({ handler }) => {
1584
- this._eventEmitter.removeListener(id, handler)
1585
- })
2444
+ // the agent ended a registration this subscription was part of
2445
+ _handleEnded (topic, end) {
2446
+ const sub = this._cachedSubscriptions[topic]
2447
+ if (!sub) return
2448
+ const info = {
2449
+ ...this._subscriptionInfo(sub),
2450
+ context: end.context,
2451
+ failed: !!end.failed
2452
+ }
2453
+ // a callAll subscription lives on at the other instances
2454
+ if (!sub.callAll) this._dropSubscription(sub)
2455
+ this.emit('ended', sub.id, end.reason, end.by, info)
2456
+ }
1586
2457
 
1587
- delete this._cachedSubscriptions[topic]
2458
+ _clearCachedSubscriptions ({ lostAgent, lostInstance, agent, className }) {
2459
+ const obsolete = Object.values(this._cachedSubscriptions).filter(sub => {
2460
+ if (lostAgent !== undefined) return sub.agent === lostAgent
2461
+ // Instance ids are unique per agent only: a twin of the same id on
2462
+ // another agent (a deployed app next to its builder) keeps its
2463
+ // subscriptions when this one goes
2464
+ return (
2465
+ sub.instance === lostInstance &&
2466
+ sub.agent === agent &&
2467
+ sub.className === className
2468
+ )
2469
+ })
2470
+ const reason = lostAgent !== undefined ? 'agentOffline' : 'instanceGone'
2471
+ obsolete.forEach(sub => {
2472
+ this._log.debug(`Clearing subscription for obsolete topic: ${sub.id}`)
2473
+ this._dropSubscription(sub, { forget: false })
2474
+ this.emit('lost', sub.id, reason, this._subscriptionInfo(sub))
1588
2475
  })
1589
2476
  }
1590
2477
 
@@ -1615,6 +2502,8 @@ class VrpcClient extends EventEmitter {
1615
2502
  }
1616
2503
 
1617
2504
  async _getInstanceData (instance, options) {
2505
+ const own = this._findOwnIsolated(instance, options)
2506
+ if (own) return own
1618
2507
  const instanceData = this._getInstanceFromCache(instance, options)
1619
2508
  if (!instanceData) {
1620
2509
  if (options.noWait) {
@@ -1625,6 +2514,17 @@ class VrpcClient extends EventEmitter {
1625
2514
  return instanceData
1626
2515
  }
1627
2516
 
2517
+ // an isolated instance this connection created, when it is the one match
2518
+ _findOwnIsolated (instance, { agent, className } = {}) {
2519
+ const hits = Array.from(this._ownIsolated.values()).filter(
2520
+ x =>
2521
+ x.instance === instance &&
2522
+ (!agent || agent === '*' || x.agent === agent) &&
2523
+ (!className || x.className === className)
2524
+ )
2525
+ return hits.length === 1 ? { agent: hits[0].agent, className: hits[0].className } : null
2526
+ }
2527
+
1628
2528
  _cachedProxy (instance, { agent, className } = {}) {
1629
2529
  // Instance ids are unique per agent only: a cached proxy is served
1630
2530
  // solely when it is the one match for the given agent and class - twins
@@ -1740,6 +2640,72 @@ class VrpcClient extends EventEmitter {
1740
2640
  * @param {String} info.className - Class name
1741
2641
  */
1742
2642
 
2643
+ /**
2644
+ * Event 'ended'
2645
+ *
2646
+ * Emitted when a registration a subscription held ended for a reason the
2647
+ * subscriber did not cause: its source ended it, or a member did
2648
+ * (`endRegistration`). The subscription is gone; it never reaches the
2649
+ * callback. A callAll subscription lives on at its other instances.
2650
+ *
2651
+ * @event VrpcClient#ended
2652
+ *
2653
+ * @param {String} id - The subscription id
2654
+ * @param {String} reason - Why it ended (an error message when it failed)
2655
+ * @param {String|null} by - Who ended it (`'source'`, or what the member said)
2656
+ * @param {Object} info - The subscription (agent, className, instance,
2657
+ * functionName, event, callAll), `context` (the instance or class) and
2658
+ * `failed`
2659
+ */
2660
+
2661
+ /**
2662
+ * Event 'undelivered'
2663
+ *
2664
+ * Emitted instead of an event, or a one-shot callback, whose arguments the
2665
+ * agent could not encode as JSON (a BigInt, say). The callback is not
2666
+ * called for it; a subscription stays and receives the next event. Agents
2667
+ * before protocol 4 drop such events without telling.
2668
+ *
2669
+ * @event VrpcClient#undelivered
2670
+ *
2671
+ * @param {String} id - The subscription id, or the one-shot callback's id
2672
+ * @param {Object} error - What the agent says, `{ message }`
2673
+ * @param {Object} info - The subscription (agent, className, instance,
2674
+ * functionName, event, callAll), or for a one-shot callback its agent,
2675
+ * className, instance and functionName
2676
+ */
2677
+
2678
+ /**
2679
+ * Event 'lost'
2680
+ *
2681
+ * Emitted when a subscription is lost: its agent went offline
2682
+ * (`'agentOffline'`), its instance went away (`'instanceGone'`), or a
2683
+ * declaration again failed (the error's message). A subscription on an agent
2684
+ * of protocol 4 that went offline is declared again once the agent is back
2685
+ * ('healed'); every other lost subscription is gone, and declaring it again
2686
+ * is the consumer's.
2687
+ *
2688
+ * @event VrpcClient#lost
2689
+ *
2690
+ * @param {String} id - The subscription id
2691
+ * @param {String} reason - `'agentOffline'`, `'instanceGone'` or an error message
2692
+ * @param {Object} info - The subscription (agent, className, instance,
2693
+ * functionName, event, callAll)
2694
+ */
2695
+
2696
+ /**
2697
+ * Event 'healed'
2698
+ *
2699
+ * Emitted when a subscription lost with its agent is back: the agent serves
2700
+ * again and holds it.
2701
+ *
2702
+ * @event VrpcClient#healed
2703
+ *
2704
+ * @param {String} id - The subscription id
2705
+ * @param {Object} info - The subscription (agent, className, instance,
2706
+ * functionName, event, callAll)
2707
+ */
2708
+
1743
2709
  /**
1744
2710
  * Event 'connect'
1745
2711
  *