vrpc 3.13.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
@@ -40,8 +40,17 @@ const { nanoid } = require('nanoid')
40
40
  const mqtt = require('mqtt')
41
41
  const EventEmitter = require('events')
42
42
  const SubscribeRetrier = require('./SubscribeRetrier')
43
+ const ReconnectBackoff = require('./ReconnectBackoff')
44
+ const NamePattern = require('./namePattern')
43
45
 
44
- 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*>?$/
50
+
51
+ // mqtt clients this client ended on purpose: the 'client disconnecting'
52
+ // workaround must never revive one of them
53
+ const ENDED = new WeakSet()
45
54
 
46
55
  /**
47
56
  * Client capable of creating proxy objects and remotely calling
@@ -154,21 +163,36 @@ class VrpcClient extends EventEmitter {
154
163
  this._client = null
155
164
  this._cachedSubscriptions = {}
156
165
  this._proxies = {}
157
- // refused subscriptions (SUBACK qos=128) are retried until granted
158
- this._subscribeRetrier = new SubscribeRetrier({
159
- subscribe: (topics, options) => this._mqttSubscribe(topics, options).catch(() => {}),
160
- log: this._log
161
- })
162
- 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
163
172
  this._emitterListener = new WeakMap()
164
173
  this._pendingSubscriptions = new Map()
165
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()
166
185
  if (log === 'console') {
167
186
  this._log = console
168
187
  this._log.debug = () => {}
169
188
  } else {
170
189
  this._log = log
171
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
+ })
172
196
  }
173
197
 
174
198
  /**
@@ -220,7 +244,19 @@ class VrpcClient extends EventEmitter {
220
244
  /**
221
245
  * Actually connects to the MQTT broker.
222
246
  *
247
+ * A refused connection is retried with a growing wait (doubling up to
248
+ * 30 s, back to the start once connected), and every refusal is emitted
249
+ * as 'error', so a caller can renew its credentials (see
250
+ * updateCredentials).
251
+ *
223
252
  * @emits connected
253
+ * @param {Object} [options]
254
+ * @param {Boolean} [options.keepTrying=false] If true, a connection that
255
+ * is not made within [timeout] still rejects the returned promise, but
256
+ * keeps trying until end() and emits 'connect' once it stands, and a
257
+ * further connect({ keepTrying: true }) while it is trying waits for it
258
+ * instead of opening a second connection. If false (the default), nothing
259
+ * changes: the client gives up at the timeout and must be connected again.
224
260
  * @returns {Promise} Resolves once connected within [timeout], rejects otherwise
225
261
  * @example
226
262
  * try {
@@ -229,14 +265,14 @@ class VrpcClient extends EventEmitter {
229
265
  * console.error(`Could not connect because: ${err.message}`)
230
266
  * }
231
267
  */
232
- async connect () {
268
+ async connect ({ keepTrying = false } = {}) {
233
269
  if (this._client && this._client.connected) return
234
- let username = this._username
235
- let password = this._password
236
- if (this._token) {
237
- username = username || this._generateUserName()
238
- password = this._token
270
+ // keepTrying: a connection that is still trying is waited for, never
271
+ // doubled (without it, connect() behaves as it always did)
272
+ if (keepTrying && this._client && !ENDED.has(this._client)) {
273
+ return this._awaitConnect(this._client, { keepTrying })
239
274
  }
275
+ const { username, password } = this._credentials()
240
276
  const options = {
241
277
  username,
242
278
  password,
@@ -264,6 +300,7 @@ class VrpcClient extends EventEmitter {
264
300
  })
265
301
 
266
302
  this._client = mqtt.connect(this._broker, options)
303
+ this._backoff = new ReconnectBackoff(this._client)
267
304
 
268
305
  // Persistent lifecycle handlers
269
306
  this._client.on('offline', () => {
@@ -271,6 +308,9 @@ class VrpcClient extends EventEmitter {
271
308
  // Clear the cache to ensure a fresh state upon reconnection
272
309
  this._agents = {}
273
310
  this._proxies = {}
311
+ // the agents delete this connection's isolated instances once its
312
+ // will is out
313
+ this._ownIsolated.clear()
274
314
  this.emit('offline')
275
315
  })
276
316
 
@@ -288,6 +328,14 @@ class VrpcClient extends EventEmitter {
288
328
  })
289
329
 
290
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
+ }
291
339
  // forward the good news
292
340
  this.emit('connect')
293
341
  // everything is subscribed afresh below: pending retries are moot
@@ -307,6 +355,13 @@ class VrpcClient extends EventEmitter {
307
355
  }
308
356
  // Single wildcard catches BOTH RPC responses (length 3) AND events (length 4)
309
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
+ }
310
365
 
311
366
  // Re-subscribe to all cached event topics
312
367
  this._log.debug('Restoring event subscriptions after reconnect...')
@@ -333,13 +388,20 @@ class VrpcClient extends EventEmitter {
333
388
  }
334
389
  // AgentInfo message
335
390
  if (klass === '__agentInfo__') {
336
- const { status, hostname, version } = JSON.parse(message.toString())
391
+ const { status, hostname, version, v } = JSON.parse(
392
+ message.toString()
393
+ )
337
394
  this._createIfNotExist(agent)
338
395
  this._agents[agent].status = status
339
396
  this._agents[agent].hostname = hostname
340
397
  this._agents[agent].version = version
398
+ // the agent's protocol version (undefined before 3)
399
+ this._agents[agent].v = v
341
400
  if (status === 'offline') {
342
- this._clearCachedSubscriptions({ lostAgent: agent })
401
+ this._loseAgent(agent, v)
402
+ } else {
403
+ this._redeclareTo(agent)
404
+ this._flushWithdrawals(agent)
343
405
  }
344
406
  this.emit('agent', { domain, agent, status, hostname, version })
345
407
  // ClassInfo message
@@ -383,8 +445,8 @@ class VrpcClient extends EventEmitter {
383
445
  })
384
446
  // RPC message
385
447
  } else {
386
- const { i, a, e, r } = JSON.parse(message.toString())
387
- 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 })
388
450
  }
389
451
  } catch (err) {
390
452
  this._log.error(
@@ -393,25 +455,78 @@ class VrpcClient extends EventEmitter {
393
455
  }
394
456
  })
395
457
 
458
+ return this._awaitConnect(this._client, { keepTrying })
459
+ }
460
+
461
+ _awaitConnect (client, { keepTrying }) {
396
462
  return new Promise((resolve, reject) => {
397
463
  const timeout = setTimeout(() => {
398
464
  // This will run only if the 'connect' event isn't fired in time.
465
+ const error = new Error(
466
+ `Connection trial timed out (> ${this._timeout} ms)`
467
+ )
468
+ if (keepTrying) {
469
+ // the client keeps trying in the background, until end()
470
+ client.removeListener('connect', onConnect)
471
+ reject(error)
472
+ return
473
+ }
399
474
  // We explicitly end the client to stop it from retrying in the background.
400
- this._client.end(true, () => {
401
- reject(
402
- new Error(`Connection trial timed out (> ${this._timeout} ms)`)
403
- )
404
- })
475
+ VrpcClient._endMqtt(client, true, {}, () => reject(error))
405
476
  }, this._timeout)
406
477
 
407
- this._client.once('connect', () => {
478
+ const onConnect = () => {
408
479
  // If we connect successfully, clear the timeout and resolve the promise.
409
480
  clearTimeout(timeout)
410
481
  resolve()
411
- })
482
+ }
483
+ client.once('connect', onConnect)
412
484
  })
413
485
  }
414
486
 
487
+ /**
488
+ * Replaces the credentials the connection logs in with
489
+ *
490
+ * The broker checks credentials when a connection is made: a live
491
+ * connection carries on undisturbed, and the next one - after a network
492
+ * loss, a broker restart - logs in with these. A connection that is
493
+ * being refused right now tries again with them within a second,
494
+ * instead of waiting out its back-off.
495
+ *
496
+ * @param {Object} credentials Only the given fields change
497
+ * @param {String} [credentials.username] MQTT username
498
+ * @param {String} [credentials.password] MQTT password (if no token is used)
499
+ * @param {String} [credentials.token] Access token
500
+ */
501
+ updateCredentials ({ username, password, token } = {}) {
502
+ if (username !== undefined) this._username = username
503
+ if (password !== undefined) this._password = password
504
+ if (token !== undefined) this._token = token
505
+ if (!this._client || ENDED.has(this._client)) return
506
+ const credentials = this._credentials()
507
+ for (const key of ['username', 'password']) {
508
+ if (credentials[key] === undefined) delete this._client.options[key]
509
+ else this._client.options[key] = credentials[key]
510
+ }
511
+ this._backoff.hurry()
512
+ }
513
+
514
+ // what the broker sees: a token rides as the password
515
+ _credentials () {
516
+ let username = this._username
517
+ let password = this._password
518
+ if (this._token) {
519
+ username = username || this._generateUserName()
520
+ password = this._token
521
+ }
522
+ return { username, password }
523
+ }
524
+
525
+ static _endMqtt (client, force, options, callback) {
526
+ ENDED.add(client)
527
+ client.end(force, options, callback)
528
+ }
529
+
415
530
  /**
416
531
  * Creates a new remote instance and provides a proxy to it.
417
532
  *
@@ -483,6 +598,14 @@ class VrpcClient extends EventEmitter {
483
598
  v: VRPC_PROTOCOL_VERSION
484
599
  }
485
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
+ }
486
609
  if (instance && cacheProxy) {
487
610
  this._proxies[`${agent}/${className}/${instance}`] = {
488
611
  agent,
@@ -547,6 +670,9 @@ class VrpcClient extends EventEmitter {
547
670
  this._mqttPublish(topic, JSON.stringify(json))
548
671
  delete this._proxies[`${agent}/${className}/${instance}`]
549
672
  const deleted = await this._handleAgentAnswer(json, agent)
673
+ if (deleted === true) {
674
+ this._ownIsolated.delete(`${agent}/${className}/${instance}`)
675
+ }
550
676
  // the answer and the class info that no longer lists the instance
551
677
  // travel on different topics, and a broker need not keep their order:
552
678
  // what this client deleted is gone for it at once
@@ -604,6 +730,7 @@ class VrpcClient extends EventEmitter {
604
730
  v: VRPC_PROTOCOL_VERSION
605
731
  }
606
732
  const topic = `${this._domain}/${agent}/${className}/__static__/${functionName}`
733
+ this._declare(json, topic)
607
734
  await this._waitUntilClassIsOnline(agent, className)
608
735
  this._mqttPublish(topic, JSON.stringify(json))
609
736
  return this._handleAgentAnswer(json, agent)
@@ -615,29 +742,61 @@ class VrpcClient extends EventEmitter {
615
742
  * registrations. In this case the instanceId of the emitter is injected as
616
743
  * first argument of any event callback.
617
744
  *
618
- * NOTE: When no agent was specified as class default and no agent is
619
- * specified when calling this function, callAll will act on the requested
620
- * class across all available agents. The same is true when explicitly using a
621
- * 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.
622
758
  *
623
759
  * @param {Object} options
624
- * @param {String} options.className Name of the static function's class
625
- * @param {Array} [options.args] Positional arguments of the static function
626
- * call
627
- * @param {String} [options.agent] Agent name. If not provided class default
628
- * is used
629
- * @returns {Promise<Object[]>} An array of objects `{ id, val, err }`
630
- * 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
631
770
  */
632
771
  async callAll ({
633
772
  className,
634
773
  functionName,
635
774
  args = [],
636
- agent = this._agent
775
+ agent = this._agent,
776
+ instances
637
777
  } = {}) {
638
778
  if (!this._isConnected()) {
639
779
  throw new Error('Client is not connected')
640
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
+ }
641
800
  const json = {
642
801
  c: className,
643
802
  f: functionName,
@@ -646,40 +805,418 @@ class VrpcClient extends EventEmitter {
646
805
  s: this._vrpcConnectionId,
647
806
  v: VRPC_PROTOCOL_VERSION
648
807
  }
649
- if (agent === '*') {
650
- const result = []
651
- const agents = this.getAvailableAgents()
652
- for (const agent of agents) {
653
- const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
654
- 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({
655
813
  agent,
656
814
  className,
657
815
  functionName,
658
- args
816
+ args,
817
+ selection
659
818
  })
660
- if (!wrapped) continue // using cache
661
- json.a = [functionName, ...wrapped]
662
- await this._waitUntilClassIsOnline(agent, className)
663
- this._mqttPublish(topic, JSON.stringify(json))
664
- const tmp = await this._handleAgentAnswer(json, agent)
665
- result.push(...tmp)
666
819
  }
667
- return result
820
+ special = '__callMatching__'
668
821
  }
669
- const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
822
+ const topic = `${this._domain}/${agent}/${className}/__static__/${special}`
670
823
  const wrapped = await this._wrapArguments({
671
824
  agent,
672
825
  className,
673
826
  functionName,
674
- args
827
+ args,
828
+ isCallAll: true,
829
+ selection
675
830
  })
676
831
  if (!wrapped) return // using cache
677
- json.a = [functionName, ...wrapped]
832
+ json.a =
833
+ selection === undefined
834
+ ? [functionName, ...wrapped]
835
+ : [selection, functionName, ...wrapped]
836
+ this._declare(json, topic)
678
837
  await this._waitUntilClassIsOnline(agent, className)
679
838
  this._mqttPublish(topic, JSON.stringify(json))
680
839
  return this._handleAgentAnswer(json, agent)
681
840
  }
682
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')
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}`
1157
+ this._mqttPublish(topic, JSON.stringify(json))
1158
+ return this._handleAgentAnswer(json, agent)
1159
+ }
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
+
683
1220
  /**
684
1221
  * Retrieves all information about the currently available components.
685
1222
  *
@@ -815,7 +1352,10 @@ class VrpcClient extends EventEmitter {
815
1352
  this._token = token
816
1353
  this._agent = agent
817
1354
  if (this._client) {
818
- await new Promise(resolve => this._client.end(false, {}, resolve))
1355
+ const client = this._client
1356
+ await new Promise(resolve =>
1357
+ VrpcClient._endMqtt(client, false, {}, resolve)
1358
+ )
819
1359
  }
820
1360
  return this.connect()
821
1361
  }
@@ -832,8 +1372,10 @@ class VrpcClient extends EventEmitter {
832
1372
  this._mqttPublish(agentTopic, null, { retain: true })
833
1373
  const classes = this.getAvailableClasses({ agent, mustBeOnline: false })
834
1374
  for (const className of classes) {
835
- const classTopic = `${this._domain}/${agent}/${className}/__classInfo__`
836
- 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
+ }
837
1379
  }
838
1380
  // Synchronize local cache
839
1381
  delete this._agents[agent]
@@ -860,9 +1402,16 @@ class VrpcClient extends EventEmitter {
860
1402
  )
861
1403
  this._eventEmitter.removeAllListeners()
862
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()
863
1410
  const client = this._client
864
1411
  this._client = null // Prevent race conditions during shutdown
865
- return new Promise(resolve => client.end(false, {}, resolve))
1412
+ return new Promise(resolve =>
1413
+ VrpcClient._endMqtt(client, false, {}, resolve)
1414
+ )
866
1415
  }
867
1416
 
868
1417
  // Private functions
@@ -890,13 +1439,6 @@ class VrpcClient extends EventEmitter {
890
1439
  return this._client && this._client.connected
891
1440
  }
892
1441
 
893
- _getCallbackId (callback, { readOnly = false } = {}) {
894
- if (!this._callbackIds.has(callback)) {
895
- if (readOnly) return null
896
- this._callbackIds.set(callback, nanoid(8))
897
- }
898
- return this._callbackIds.get(callback)
899
- }
900
1442
 
901
1443
  _generateUserName () {
902
1444
  return `${this._domain}:client@${os.hostname()}-${os.platform()}-js`
@@ -929,8 +1471,16 @@ class VrpcClient extends EventEmitter {
929
1471
  return `${this._domain}/${this._mqttClientId}/${this._connectionSecret}`
930
1472
  }
931
1473
 
1474
+ // The 'client disconnecting' workaround below revives a client mqtt.js
1475
+ // left half-closed, never one that was ended on purpose (a connect
1476
+ // timeout, end(), reconnectWithToken) or has been replaced since
1477
+ _isRevivable (client) {
1478
+ return client === this._client && !ENDED.has(client)
1479
+ }
1480
+
932
1481
  _mqttPublish (topic, message, options) {
933
- this._client.publish(
1482
+ const client = this._client
1483
+ client.publish(
934
1484
  topic,
935
1485
  message,
936
1486
  { qos: this._qos, ...options },
@@ -939,13 +1489,16 @@ class VrpcClient extends EventEmitter {
939
1489
  this._log.warn(
940
1490
  `Could not publish MQTT message because: ${err.message}`
941
1491
  )
942
- if (err.message === 'client disconnecting') {
1492
+ if (
1493
+ err.message === 'client disconnecting' &&
1494
+ this._isRevivable(client)
1495
+ ) {
943
1496
  // This most certainly relates to a bug in MQTT.js (#1284)
944
1497
  this._log.error(`Failed to publish, because: ${err.message}`)
945
1498
  this.emit('error', err)
946
1499
  this._log.info('Forcing reconnect now...')
947
- this._client.disconnecting = false
948
- this._client.reconnect()
1500
+ client.disconnecting = false
1501
+ client.reconnect()
949
1502
  }
950
1503
  }
951
1504
  }
@@ -953,30 +1506,41 @@ class VrpcClient extends EventEmitter {
953
1506
  }
954
1507
 
955
1508
  async _mqttSubscribe (topic, options) {
1509
+ const client = this._client
956
1510
  return new Promise((resolve, reject) => {
957
- this._client.subscribe(
1511
+ client.subscribe(
958
1512
  topic,
959
1513
  { qos: this._qos, ...options },
960
- (err, granted) => {
961
- if (err) {
1514
+ (subscribeError, subs) => {
1515
+ const { refused, granted, error } = SubscribeRetrier.outcome(
1516
+ subscribeError,
1517
+ subs
1518
+ )
1519
+ if (error) {
962
1520
  this._log.warn(
963
- `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
1521
+ `Could not subscribe to topic(s): '${topic}', because: ${error.message}`
964
1522
  )
965
- if (err.message === 'client disconnecting') {
1523
+ if (
1524
+ error.message === 'client disconnecting' &&
1525
+ this._isRevivable(client)
1526
+ ) {
966
1527
  // This most certainly relates to a bug in MQTT.js (#1284)
967
- const msg = `Failed to subscribe, because: ${err.message}`
1528
+ const msg = `Failed to subscribe, because: ${error.message}`
968
1529
  this._log.error(msg)
969
- this.emit('error', err)
1530
+ this.emit('error', error)
970
1531
  this._log.info('Forcing reconnect now...')
971
- this._client.disconnecting = false
972
- this._client.reconnect()
1532
+ client.disconnecting = false
1533
+ client.reconnect()
973
1534
  reject(new Error(msg))
1535
+ } else {
1536
+ // the connection broke under the subscribe: the next connect
1537
+ // subscribes everything afresh
1538
+ reject(error)
974
1539
  }
975
1540
  } else {
1541
+ let err
976
1542
  const topicArray = Array.isArray(topic) ? topic : [topic]
977
- const erroneousGranted = granted
978
- .filter(x => x.qos === 128)
979
- .map(x => x.topic)
1543
+ const erroneousGranted = refused
980
1544
  if (erroneousGranted.length > 0) {
981
1545
  err = new Error(
982
1546
  `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
@@ -987,9 +1551,7 @@ class VrpcClient extends EventEmitter {
987
1551
  this.emit('error', err)
988
1552
  this._subscribeRetrier.schedule(erroneousGranted, options)
989
1553
  }
990
- this._subscribeRetrier.granted(
991
- granted.filter(x => x.qos !== 128).map(x => x.topic)
992
- )
1554
+ this._subscribeRetrier.granted(granted.map(x => x.topic))
993
1555
  const reducedQos = granted.filter(x => x.qos < this._qos)
994
1556
  if (reducedQos.length > 0) {
995
1557
  err = new Error(
@@ -1006,7 +1568,7 @@ class VrpcClient extends EventEmitter {
1006
1568
  this._log.warn(err.message)
1007
1569
  this.emit('error', err)
1008
1570
  }
1009
- if (granted.length === 0) {
1571
+ if (granted.length === 0 && refused.length === 0) {
1010
1572
  this._log.debug(`Already subscribed to: ${topic}`)
1011
1573
  }
1012
1574
  resolve()
@@ -1067,15 +1629,19 @@ class VrpcClient extends EventEmitter {
1067
1629
  if (this._hasClassOnline(agent, className)) return
1068
1630
  return new Promise((resolve, reject) => {
1069
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
1070
1634
  const checkClass = () => {
1071
1635
  if (this._hasClassOnline(agent, className)) {
1072
1636
  if (timer) clearTimeout(timer)
1073
1637
  this.removeListener('class', checkClass)
1638
+ this.removeListener('agent', checkClass)
1074
1639
  resolve()
1075
1640
  }
1076
1641
  }
1077
1642
  timer = setTimeout(() => {
1078
1643
  this.removeListener('class', checkClass)
1644
+ this.removeListener('agent', checkClass)
1079
1645
  reject(
1080
1646
  new Error(
1081
1647
  `Connection to class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
@@ -1083,6 +1649,7 @@ class VrpcClient extends EventEmitter {
1083
1649
  )
1084
1650
  }, this._timeout)
1085
1651
  this.on('class', checkClass)
1652
+ this.on('agent', checkClass)
1086
1653
  })
1087
1654
  }
1088
1655
 
@@ -1121,23 +1688,18 @@ class VrpcClient extends EventEmitter {
1121
1688
  throw new Error('VRPC does not support removing all listeners')
1122
1689
  }
1123
1690
 
1124
- const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1125
- topic => {
1126
- const sub = this._cachedSubscriptions[topic]
1127
- return sub.instance === instance && sub.event === eventName
1128
- }
1129
- )
1130
-
1131
- obsoleteTopics.forEach(topic => {
1132
- const id = `__e__${topic}`
1133
- const sub = this._cachedSubscriptions[topic]
1134
-
1135
- sub.listeners.forEach(({ handler }) => {
1136
- this._eventEmitter.removeListener(id, handler)
1137
- })
1138
-
1139
- delete this._cachedSubscriptions[topic]
1140
- })
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))
1141
1703
  try {
1142
1704
  const json = {
1143
1705
  c: instance,
@@ -1183,6 +1745,7 @@ class VrpcClient extends EventEmitter {
1183
1745
  s: this._vrpcConnectionId,
1184
1746
  v: VRPC_PROTOCOL_VERSION
1185
1747
  }
1748
+ this._declare(json, `${targetTopic}/${functionName}`)
1186
1749
  this._mqttPublish(
1187
1750
  `${targetTopic}/${functionName}`,
1188
1751
  JSON.stringify(json)
@@ -1218,6 +1781,7 @@ class VrpcClient extends EventEmitter {
1218
1781
  s: this._vrpcConnectionId,
1219
1782
  v: VRPC_PROTOCOL_VERSION
1220
1783
  }
1784
+ this._declare(json, `${targetTopic}/${functionName}`)
1221
1785
  this._mqttPublish(
1222
1786
  `${targetTopic}/${functionName}`,
1223
1787
  JSON.stringify(json)
@@ -1229,22 +1793,37 @@ class VrpcClient extends EventEmitter {
1229
1793
  )
1230
1794
  }
1231
1795
  }
1232
- proxy.vrpcOff = functionName => {
1233
- const topic = `${this._domain}/${agent}/${className}/${instance}-${functionName}`
1234
- const id = `__e__${topic}`
1235
- 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)
1236
1809
  }
1237
1810
  }
1238
1811
  return proxy
1239
1812
  }
1240
1813
 
1241
- async _handleAgentAnswer ({ i, c, f }, agent) {
1814
+ async _handleAgentAnswer ({ i, c, f, a }, agent) {
1242
1815
  return new Promise((resolve, reject) => {
1243
1816
  const msg = `Function call "${c}::${f}()" on agent "${agent}" timed out (> ${this._timeout} ms)`
1244
1817
  const timer = setTimeout(() => {
1245
1818
  this._eventEmitter.removeAllListeners(i)
1246
1819
  reject(new Error(msg))
1247
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
+ }
1248
1827
  this._eventEmitter.once(i, data => {
1249
1828
  clearTimeout(timer)
1250
1829
  if (data.e) {
@@ -1263,10 +1842,12 @@ class VrpcClient extends EventEmitter {
1263
1842
  new Error(`[vrpc ${agent}-${c}-${f}]: ${message}`, { cause })
1264
1843
  )
1265
1844
  } else {
1845
+ confirm()
1266
1846
  resolve(promiseData.r)
1267
1847
  }
1268
1848
  })
1269
1849
  } else {
1850
+ confirm()
1270
1851
  resolve(ret)
1271
1852
  }
1272
1853
  }
@@ -1303,51 +1884,114 @@ class VrpcClient extends EventEmitter {
1303
1884
  })
1304
1885
  }
1305
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
+
1306
1907
  async _wrapArguments ({
1307
1908
  agent,
1308
1909
  className,
1309
1910
  instance,
1310
1911
  proxyId,
1311
1912
  functionName,
1312
- args
1913
+ args,
1914
+ isCallAll = false,
1915
+ selection
1313
1916
  }) {
1314
1917
  const wrapped = []
1315
1918
 
1316
- // Heuristic for continuous event emitters and their removal
1317
- 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
1318
1925
  const isContinuousRemover = /^(off|remove|unmonitor|unsignal)/.test(
1319
1926
  functionName
1320
1927
  )
1928
+ const isEmitterRemover =
1929
+ (functionName === 'off' || functionName === 'removeListener') &&
1930
+ typeof args[0] === 'string'
1321
1931
 
1322
1932
  for (const [i, x] of args.entries()) {
1323
- if (this._isFunction(x)) {
1324
- 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
1325
1941
  if (
1326
- functionName === 'vrpcOn' ||
1327
- (functionName === 'on' && i === 1 && typeof args[0] === 'string') ||
1328
- isContinuousEmitter
1942
+ !removes &&
1943
+ (isEmitterContract ||
1944
+ functionName === 'vrpcOn' ||
1945
+ ((functionName === 'on' || functionName === 'addListener') &&
1946
+ i === 1 &&
1947
+ typeof args[0] === 'string') ||
1948
+ isEventFunction)
1329
1949
  ) {
1330
1950
  const event = typeof args[0] === 'string' ? args[0] : functionName
1951
+ // vrpcOn names the event function first
1952
+ const isVrpcOn = functionName === 'vrpcOn'
1331
1953
  const id = await this._onRemoteEvent({
1332
1954
  agent,
1333
1955
  className,
1334
1956
  instance,
1335
1957
  event,
1336
- 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
1337
1964
  })
1338
1965
  wrapped.push(id)
1339
1966
  continue
1340
- } else if (
1341
- ((functionName === 'off' || functionName === 'removeListener') &&
1342
- i === 1 &&
1343
- typeof args[0] === 'string') ||
1344
- isContinuousRemover
1345
- ) {
1346
- const id = await this._offRemoteEvent({ callback })
1347
- if (
1348
- !id &&
1349
- (functionName === 'off' || functionName === 'removeListener')
1350
- ) {
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') {
1351
1995
  // Nothing of ours to remove - the subscription was already
1352
1996
  // dropped (the instance or its agent went away and the cache
1353
1997
  // was cleared, or the listener was never registered). Sending
@@ -1355,43 +1999,28 @@ class VrpcClient extends EventEmitter {
1355
1999
  // its EventEmitter rejects. Handled locally: nothing to do.
1356
2000
  return null
1357
2001
  }
1358
- wrapped.push(id || null)
1359
- continue
1360
- } else {
1361
- // Regular function callback (can be static or member function)
1362
- const remoteId = proxyId || `${agent}-${className}`
1363
- const id = `__f__${remoteId}-${functionName}-${i}-${
1364
- this._invokeId++ % Number.MAX_SAFE_INTEGER
1365
- }`
1366
- wrapped.push(id)
1367
- this._eventEmitter.once(id, ({ a }) => x.apply(null, a))
1368
- continue
1369
- }
1370
- }
1371
-
1372
- // special case of an EventEmitter provided as argument
1373
- if (this._isEmitter(x)) {
1374
- const { emitter, event } = x
1375
- let callback = (...args) => emitter.emit(event, ...args)
1376
- if (this._emitterListener.has(emitter)) {
1377
- const eventMap = this._emitterListener.get(emitter)
1378
- if (eventMap.has(event)) {
1379
- callback = eventMap.get(event)
1380
- } else {
1381
- 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
1382
2008
  }
1383
- } else {
1384
- this._emitterListener.set(emitter, new Map([[event, callback]]))
1385
2009
  }
1386
-
1387
- const id = await this._onRemoteEvent({
1388
- agent,
1389
- className,
1390
- instance,
1391
- event,
1392
- callback
1393
- })
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
+ }`
1394
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
+ })
1395
2024
  continue
1396
2025
  }
1397
2026
  // default behavior is to not touch
@@ -1400,98 +2029,378 @@ class VrpcClient extends EventEmitter {
1400
2029
  return wrapped
1401
2030
  }
1402
2031
 
1403
- async _onRemoteEvent ({ agent, className, instance, event, callback }) {
1404
- 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
+ }
2045
+
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
+ }
1405
2062
 
1406
- // Generate a short, sanitized hash
1407
- const hashInput = `${agent}-${className}-${
1408
- instance || '__static__'
1409
- }-${event}-${callbackId}`
1410
- const topicId = VrpcClient._createHash(hashInput, 12)
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
+ }
1411
2076
 
1412
- // Route directly to the client's single event inbox wildcard
1413
- const topic = `${this._vrpcConnectionId}/${topicId}`
1414
- const id = `__e__${topic}`
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
+ }
1415
2085
 
1416
- const handler = ({ a }) => callback.apply(null, a)
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
+ }
1417
2092
 
1418
- // Only bind to the actual emitter on the first registration
1419
- if (this._eventEmitter.listenerCount(id) === 0) {
1420
- 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)
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)
1421
2114
  }
2115
+ return topic || null
2116
+ }
1422
2117
 
1423
- if (!this._cachedSubscriptions[topic]) {
1424
- // Store metadata explicitly
1425
- this._cachedSubscriptions[topic] = {
1426
- agent,
1427
- className,
1428
- instance,
1429
- event,
1430
- 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
1431
2173
  }
1432
- } else {
1433
- 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)
1434
2182
  }
1435
-
2183
+ this._eventEmitter.on(id, sub.handler)
2184
+ this._cachedSubscriptions[topic] = sub
1436
2185
  return id
1437
2186
  }
1438
2187
 
1439
- async _offRemoteEvent ({ callback }) {
1440
- const callbackId = this._getCallbackId(callback, { readOnly: true })
1441
- if (!callbackId) return null
1442
-
1443
- let foundId = null
1444
- const topics = Object.keys(this._cachedSubscriptions)
1445
-
1446
- topics.forEach(topic => {
1447
- const id = `__e__${topic}`
1448
- const sub = this._cachedSubscriptions[topic]
1449
-
1450
- const index = sub.listeners.findIndex(x => x.callback === callback)
1451
-
1452
- if (index !== -1) {
1453
- // Decrease the "reference count" by removing one instance
1454
- 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
+ }
1455
2242
 
1456
- // Only remove from the local event emitter if NO references remain
1457
- if (sub.listeners.length === 0) {
1458
- 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
+ }
1459
2263
 
1460
- // Cleanly wipe all listeners for this specific id
1461
- this._eventEmitter.removeAllListeners(id)
1462
- }
1463
- 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
1464
2283
  }
1465
- })
1466
- 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
+ }
1467
2292
  }
1468
2293
 
1469
- _clearCachedSubscriptions ({ lostAgent, lostInstance, agent, className }) {
1470
- const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1471
- topic => {
1472
- const sub = this._cachedSubscriptions[topic]
1473
- if (lostAgent !== undefined) return sub.agent === lostAgent
1474
- // Instance ids are unique per agent only: a twin of the same id on
1475
- // another agent (a deployed app next to its builder) keeps its
1476
- // subscriptions when this one goes
1477
- return (
1478
- sub.instance === lostInstance &&
1479
- sub.agent === agent &&
1480
- sub.className === className
1481
- )
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: [] })
1482
2320
  }
1483
- )
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
+ }
1484
2360
 
1485
- obsoleteTopics.forEach(topic => {
1486
- const id = `__e__${topic}`
1487
- this._log.debug(`Clearing subscriptions for obsolete topic: ${topic}`)
1488
- 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
+ }
1489
2372
 
1490
- sub.listeners.forEach(({ handler }) => {
1491
- this._eventEmitter.removeListener(id, handler)
1492
- })
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
+ }
1493
2386
 
1494
- 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))
1495
2404
  })
1496
2405
  }
1497
2406
 
@@ -1522,6 +2431,8 @@ class VrpcClient extends EventEmitter {
1522
2431
  }
1523
2432
 
1524
2433
  async _getInstanceData (instance, options) {
2434
+ const own = this._findOwnIsolated(instance, options)
2435
+ if (own) return own
1525
2436
  const instanceData = this._getInstanceFromCache(instance, options)
1526
2437
  if (!instanceData) {
1527
2438
  if (options.noWait) {
@@ -1532,6 +2443,17 @@ class VrpcClient extends EventEmitter {
1532
2443
  return instanceData
1533
2444
  }
1534
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
+
1535
2457
  _cachedProxy (instance, { agent, className } = {}) {
1536
2458
  // Instance ids are unique per agent only: a cached proxy is served
1537
2459
  // solely when it is the one match for the given agent and class - twins
@@ -1647,6 +2569,72 @@ class VrpcClient extends EventEmitter {
1647
2569
  * @param {String} info.className - Class name
1648
2570
  */
1649
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
+
1650
2638
  /**
1651
2639
  * Event 'connect'
1652
2640
  *