vrpc 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vrpc",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -136,6 +136,8 @@ class VrpcClient extends EventEmitter {
136
136
  this._proxies = {}
137
137
  this._callbackIds = new WeakMap()
138
138
  this._emitterListener = new WeakMap()
139
+ this._pendingSubscriptions = new Map()
140
+ this._pendingUnsubscriptions = new Map()
139
141
  if (log === 'console') {
140
142
  this._log = console
141
143
  this._log.debug = () => {}
@@ -200,6 +202,7 @@ class VrpcClient extends EventEmitter {
200
202
  this._log.warn('MQTT client went offline.')
201
203
  // Clear the cache to ensure a fresh state upon reconnection
202
204
  this._agents = {}
205
+ this._proxies = {}
203
206
  this.emit('offline')
204
207
  })
205
208
 
@@ -216,27 +219,40 @@ class VrpcClient extends EventEmitter {
216
219
  this.emit('error', err)
217
220
  })
218
221
 
219
- this._client.on('connect', () => {
220
- // This will give us an overview of all remotely available classes
221
- const agent = this._agent === '*' ? '+' : this._agent
222
- // Agent info
223
- this._mqttSubscribe(`${this._domain}/${agent}/__agentInfo__`)
224
- // Class info
225
- if (this._requiresSchema) {
226
- this._mqttSubscribe(`${this._domain}/${agent}/+/__classInfo__`)
227
- } else {
228
- this._mqttSubscribe(`${this._domain}/${agent}/+/__classInfoConcise__`)
229
- }
230
- // RPC responses
231
- this._mqttSubscribe(this._vrpcClientId)
222
+ this._client.on('connect', async () => {
223
+ // forward the good news
224
+ this.emit('connect')
225
+ try {
226
+ // This will give us an overview of all remotely available classes
227
+ const agent = this._agent === '*' ? '+' : this._agent
228
+ // Agent info
229
+ await this._mqttSubscribe(`${this._domain}/${agent}/__agentInfo__`)
230
+ // Class info
231
+ if (this._requiresSchema) {
232
+ await this._mqttSubscribe(`${this._domain}/${agent}/+/__classInfo__`)
233
+ } else {
234
+ await this._mqttSubscribe(
235
+ `${this._domain}/${agent}/+/__classInfoConcise__`
236
+ )
237
+ }
238
+ // RPC responses
239
+ await this._mqttSubscribe(this._vrpcClientId)
232
240
 
233
- // Re-subscribe to all cached event topics
234
- this._log.info('Restoring event subscriptions after reconnect...')
235
- for (const topic of Object.keys(this._cachedSubscriptions)) {
236
- this._mqttSubscribe(topic)
237
- }
241
+ // Single wildcard subscription for ALL continuous remote events
242
+ await this._mqttSubscribe(`${this._vrpcClientId}/__e__/#`)
238
243
 
239
- this.emit('connect')
244
+ // Re-subscribe to all cached event topics
245
+ this._log.debug('Restoring event subscriptions after reconnect...')
246
+ const promises = []
247
+ for (const topic of Object.keys(this._cachedSubscriptions)) {
248
+ promises.push(this._mqttSubscribe(topic))
249
+ }
250
+ await Promise.allSettled(promises)
251
+ } catch (err) {
252
+ this._log.error(
253
+ `Encountered issue while setting up subscriptions after connect: ${err.message}`
254
+ )
255
+ }
240
256
  })
241
257
 
242
258
  this._client.on('message', (topic, message) => {
@@ -327,7 +343,6 @@ class VrpcClient extends EventEmitter {
327
343
  resolve()
328
344
  })
329
345
  })
330
- // --------------------------------------------------------------------------------
331
346
  }
332
347
 
333
348
  /**
@@ -456,25 +471,7 @@ class VrpcClient extends EventEmitter {
456
471
  const topic = `${this._domain}/${agent}/${className}/__static__/__delete__`
457
472
  this._mqttPublish(topic, JSON.stringify(json))
458
473
  if (this._proxies[instance]) delete this._proxies[instance]
459
- const result = await this._handleAgentAnswer(json, agent)
460
- // Now that deletion is confirmed, proactively update all local caches
461
- if (result) {
462
- // 1. Remove from the proxy cache
463
- if (this._proxies[instance]) {
464
- delete this._proxies[instance]
465
- }
466
- // 2. Remove from the main agent/instance cache
467
- if (this._agents[agent] && this._agents[agent].classes) {
468
- const classInfo = this._agents[agent].classes[className]
469
- if (classInfo && classInfo.instances) {
470
- const index = classInfo.instances.indexOf(instance)
471
- if (index > -1) {
472
- classInfo.instances.splice(index, 1)
473
- }
474
- }
475
- }
476
- }
477
- return result
474
+ return this._handleAgentAnswer(json, agent)
478
475
  }
479
476
 
480
477
  /**
@@ -496,7 +493,7 @@ class VrpcClient extends EventEmitter {
496
493
  if (!this._isConnected()) {
497
494
  throw new Error('Client is not connected')
498
495
  }
499
- const wrapped = this._wrapArguments({
496
+ const wrapped = await this._wrapArguments({
500
497
  agent,
501
498
  className,
502
499
  functionName,
@@ -559,7 +556,7 @@ class VrpcClient extends EventEmitter {
559
556
  const agents = this.getAvailableAgents()
560
557
  for (const agent of agents) {
561
558
  const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
562
- const wrapped = this._wrapArguments({
559
+ const wrapped = await this._wrapArguments({
563
560
  agent,
564
561
  className,
565
562
  functionName,
@@ -575,7 +572,7 @@ class VrpcClient extends EventEmitter {
575
572
  return result
576
573
  }
577
574
  const topic = `${this._domain}/${agent}/${className}/__static__/__callAll__`
578
- const wrapped = this._wrapArguments({
575
+ const wrapped = await this._wrapArguments({
579
576
  agent,
580
577
  className,
581
578
  functionName,
@@ -848,68 +845,77 @@ class VrpcClient extends EventEmitter {
848
845
  )
849
846
  }
850
847
 
851
- _mqttSubscribe (topic, options) {
852
- this._client.subscribe(
853
- topic,
854
- { qos: this._qos, ...options },
855
- (err, granted) => {
856
- if (err) {
857
- this._log.warn(
858
- `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
859
- )
860
- if (err.message === 'client disconnecting') {
861
- // This most certainly relates to a bug in MQTT.js (#1284)
862
- this._log.error(`Failed to subscribe, because: ${err.message}`)
863
- this.emit('error', err)
864
- this._log.info('Forcing reconnect now...')
865
- this._client.disconnecting = false
866
- this._client.reconnect()
867
- }
868
- } else {
869
- const topicArray = Array.isArray(topic) ? topic : [topic]
870
- const erroneousGranted = granted
871
- .filter(x => x.qos === 128)
872
- .map(x => x.topic)
873
- if (erroneousGranted.length > 0) {
874
- err = new Error(
875
- `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
876
- )
877
- err.code = 'SUBSCRIBE_FAILED'
878
- err.subscribeOptions = options
879
- this._log.error(err)
880
- this.emit('error', err)
881
- }
882
- const reducedQos = granted.filter(x => x.qos < this._qos)
883
- if (reducedQos.length > 0) {
884
- err = new Error(
885
- `Could not subscribe all ${
886
- topicArray.length
887
- } topic(s) at desired qos=${
888
- this._qos
889
- } but got reduced qos on following ${
890
- reducedQos.length
891
- } topic(s): ${JSON.stringify(reducedQos)}`
848
+ async _mqttSubscribe (topic, options) {
849
+ return new Promise((resolve, reject) => {
850
+ this._client.subscribe(
851
+ topic,
852
+ { qos: this._qos, ...options },
853
+ (err, granted) => {
854
+ if (err) {
855
+ this._log.warn(
856
+ `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
892
857
  )
893
- err.code = 'SUBSCRIBE_REDUCED_QOS'
894
- err.subscribeOptions = options
895
- this._log.warn(err)
896
- this.emit('error', err)
897
- }
898
- if (granted.length === 0) {
899
- this._log.debug(`Already subscribed to: ${topic}`)
858
+ if (err.message === 'client disconnecting') {
859
+ // This most certainly relates to a bug in MQTT.js (#1284)
860
+ const msg = `Failed to subscribe, because: ${err.message}`
861
+ this._log.error(msg)
862
+ this.emit('error', err)
863
+ this._log.info('Forcing reconnect now...')
864
+ this._client.disconnecting = false
865
+ this._client.reconnect()
866
+ reject(new Error(msg))
867
+ }
868
+ } else {
869
+ const topicArray = Array.isArray(topic) ? topic : [topic]
870
+ const erroneousGranted = granted
871
+ .filter(x => x.qos === 128)
872
+ .map(x => x.topic)
873
+ if (erroneousGranted.length > 0) {
874
+ err = new Error(
875
+ `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
876
+ )
877
+ err.code = 'SUBSCRIBE_FAILED'
878
+ err.subscribeOptions = options
879
+ this._log.error(err.message)
880
+ this.emit('error', err)
881
+ }
882
+ const reducedQos = granted.filter(x => x.qos < this._qos)
883
+ if (reducedQos.length > 0) {
884
+ err = new Error(
885
+ `Could not subscribe all ${
886
+ topicArray.length
887
+ } topic(s) at desired qos=${
888
+ this._qos
889
+ } but got reduced qos on following ${
890
+ reducedQos.length
891
+ } topic(s): ${JSON.stringify(reducedQos)}`
892
+ )
893
+ err.code = 'SUBSCRIBE_REDUCED_QOS'
894
+ err.subscribeOptions = options
895
+ this._log.warn(err.message)
896
+ this.emit('error', err)
897
+ }
898
+ if (granted.length === 0) {
899
+ this._log.debug(`Already subscribed to: ${topic}`)
900
+ }
901
+ resolve()
900
902
  }
901
903
  }
902
- }
903
- )
904
+ )
905
+ })
904
906
  }
905
907
 
906
- _mqttUnsubscribe (topic, options) {
907
- this._client.unsubscribe(topic, options, err => {
908
- if (err) {
909
- this._log.warn(
910
- `Could not unsubscribe from topic: ${topic} because: ${err.message}`
911
- )
912
- }
908
+ async _mqttUnsubscribe (topic, options) {
909
+ return new Promise((resolve, reject) => {
910
+ this._client.unsubscribe(topic, options, err => {
911
+ if (err) {
912
+ this._log.warn(
913
+ `Could not unsubscribe from topic: ${topic} because: ${err.message}`
914
+ )
915
+ }
916
+ // we resolve in any case to let the upstream logic flow
917
+ resolve()
918
+ })
913
919
  })
914
920
  }
915
921
 
@@ -998,13 +1004,32 @@ class VrpcClient extends EventEmitter {
998
1004
  if (!eventName) {
999
1005
  throw new Error('VRPC does not support removing all listeners')
1000
1006
  }
1001
- const topic = `${this._domain}/${agent}/${className}/${instance}:${eventName}`
1002
- const id = `__e__${topic}`
1003
- if (this._cachedSubscriptions[topic]) {
1004
- this._eventEmitter.removeAllListeners(id)
1005
- this._mqttUnsubscribe(topic)
1007
+
1008
+ const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1009
+ topic => {
1010
+ const tokens = topic.split('/')
1011
+ if (tokens[3] === '__e__') {
1012
+ const topicInstance = tokens[6]
1013
+ const topicEvent = tokens[7]
1014
+ return topicInstance === instance && topicEvent === eventName
1015
+ }
1016
+ // Legacy fallback
1017
+ const expectedOld = `${this._domain}/${agent}/${className}/${instance}:${eventName}`
1018
+ return topic.startsWith(expectedOld)
1019
+ }
1020
+ )
1021
+
1022
+ obsoleteTopics.forEach(topic => {
1023
+ const id = `__e__${topic}`
1024
+ const subscriptions = this._cachedSubscriptions[topic]
1025
+ subscriptions.forEach(({ handler }) => {
1026
+ this._eventEmitter.removeListener(id, handler)
1027
+ })
1028
+ if (!topic.includes('__e__')) {
1029
+ this._mqttUnsubscribe(topic).catch(() => {})
1030
+ }
1006
1031
  delete this._cachedSubscriptions[topic]
1007
- }
1032
+ })
1008
1033
  try {
1009
1034
  const json = {
1010
1035
  c: instance,
@@ -1030,7 +1055,7 @@ class VrpcClient extends EventEmitter {
1030
1055
  return
1031
1056
  }
1032
1057
  proxy[functionName] = async (...args) => {
1033
- const wrapped = this._wrapArguments({
1058
+ const wrapped = await this._wrapArguments({
1034
1059
  agent,
1035
1060
  className,
1036
1061
  instance,
@@ -1065,7 +1090,7 @@ class VrpcClient extends EventEmitter {
1065
1090
  if (!uniqueFuncs.has('vrpcOn') && !uniqueFuncs.has('vrpcOff')) {
1066
1091
  proxy.vrpcOn = async (functionName, ...args) => {
1067
1092
  if (!uniqueFuncs.has(functionName)) throw new Error('Bad magic')
1068
- const wrapped = this._wrapArguments({
1093
+ const wrapped = await this._wrapArguments({
1069
1094
  agent,
1070
1095
  className,
1071
1096
  instance,
@@ -1170,23 +1195,32 @@ class VrpcClient extends EventEmitter {
1170
1195
  })
1171
1196
  }
1172
1197
 
1173
- _wrapArguments ({ agent, className, instance, proxyId, functionName, args }) {
1198
+ async _wrapArguments ({
1199
+ agent,
1200
+ className,
1201
+ instance,
1202
+ proxyId,
1203
+ functionName,
1204
+ args
1205
+ }) {
1174
1206
  const wrapped = []
1207
+
1208
+ // Heuristic for continuous event emitters and their removal
1209
+ const isContinuousEmitter = /^(on|notify|monitor|signal)/.test(functionName)
1210
+ const isContinuousRemover = /^(off|remove|unmonitor|unsignal)/.test(
1211
+ functionName
1212
+ )
1213
+
1175
1214
  for (const [i, x] of args.entries()) {
1176
- // Check whether provided argument is a function
1177
1215
  if (this._isFunction(x)) {
1178
1216
  const callback = x
1179
1217
  if (
1180
1218
  functionName === 'vrpcOn' ||
1181
- (functionName === 'on' && i === 1 && typeof args[0] === 'string')
1219
+ (functionName === 'on' && i === 1 && typeof args[0] === 'string') ||
1220
+ isContinuousEmitter
1182
1221
  ) {
1183
- // special case of an (remote-)event emitter registration
1184
- // we test three conditions:
1185
- // 1) functionName must be "on"
1186
- // 2) callback is second argument
1187
- // 3) first argument was string
1188
- const event = args[0]
1189
- const id = this._onRemoteEvent({
1222
+ const event = typeof args[0] === 'string' ? args[0] : functionName
1223
+ const id = await this._onRemoteEvent({
1190
1224
  agent,
1191
1225
  className,
1192
1226
  instance,
@@ -1196,19 +1230,13 @@ class VrpcClient extends EventEmitter {
1196
1230
  wrapped.push(id)
1197
1231
  continue
1198
1232
  } else if (
1199
- (functionName === 'off' || functionName === 'removeListener') &&
1200
- i === 1 &&
1201
- typeof args[0] === 'string'
1233
+ ((functionName === 'off' || functionName === 'removeListener') &&
1234
+ i === 1 &&
1235
+ typeof args[0] === 'string') ||
1236
+ isContinuousRemover
1202
1237
  ) {
1203
- const event = args[0]
1204
- const id = this._offRemoteEvent({
1205
- agent,
1206
- className,
1207
- instance,
1208
- event,
1209
- callback
1210
- })
1211
- wrapped.push(id)
1238
+ const id = await this._offRemoteEvent({ callback })
1239
+ wrapped.push(id || null)
1212
1240
  continue
1213
1241
  } else {
1214
1242
  // Regular function callback (can be static or member function)
@@ -1221,6 +1249,7 @@ class VrpcClient extends EventEmitter {
1221
1249
  continue
1222
1250
  }
1223
1251
  }
1252
+
1224
1253
  // special case of an EventEmitter provided as argument
1225
1254
  if (this._isEmitter(x)) {
1226
1255
  const { emitter, event } = x
@@ -1236,7 +1265,7 @@ class VrpcClient extends EventEmitter {
1236
1265
  this._emitterListener.set(emitter, new Map([[event, callback]]))
1237
1266
  }
1238
1267
 
1239
- const id = this._onRemoteEvent({
1268
+ const id = await this._onRemoteEvent({
1240
1269
  agent,
1241
1270
  className,
1242
1271
  instance,
@@ -1252,87 +1281,92 @@ class VrpcClient extends EventEmitter {
1252
1281
  return wrapped
1253
1282
  }
1254
1283
 
1255
- _onRemoteEvent ({ agent, className, instance, event, callback }) {
1284
+ async _onRemoteEvent ({ agent, className, instance, event, callback }) {
1256
1285
  const callbackId = this._getCallbackId(callback)
1257
- const eventCallback = `${event}-${callbackId}`
1258
-
1259
- // the topic on which any remote event will be published to
1260
- const topic = instance
1261
- ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1262
- : `${this._domain}/${agent}/${className}/${eventCallback}`
1263
-
1264
- // prepare a special id for the agent to know that events should be
1265
- // published to the provided topic after the prefix and before the suffix
1266
1286
 
1287
+ // Route directly to the client's single event inbox wildcard.
1288
+ const topic = `${this._vrpcClientId}/__e__/${agent}/${className}/${
1289
+ instance || '__static__'
1290
+ }/${event}/${callbackId}`
1267
1291
  const id = `__e__${topic}`
1268
1292
 
1269
- // call our proxy callback when remote events were received
1270
1293
  const handler = ({ a }) => callback.apply(null, a)
1294
+
1295
+ // Only bind to the actual emitter on the first registration
1271
1296
  if (this._eventEmitter.listenerCount(id) === 0) {
1272
1297
  this._eventEmitter.on(id, handler)
1273
1298
  }
1299
+
1274
1300
  if (!this._cachedSubscriptions[topic]) {
1275
- // when not yet subscribed to this topic do it now
1276
- this._mqttSubscribe(topic)
1277
1301
  this._cachedSubscriptions[topic] = [{ callback, handler }]
1278
- return id
1302
+ } else {
1303
+ // We MUST push duplicates to act as a reference counter!
1304
+ this._cachedSubscriptions[topic].push({ callback, handler })
1279
1305
  }
1280
- // otherwise just add the new callback-handler pair
1281
- this._cachedSubscriptions[topic].push({ callback, handler })
1306
+
1282
1307
  return id
1283
1308
  }
1284
1309
 
1285
- _offRemoteEvent ({ agent, className, instance, event, callback }) {
1286
- const callbackId = this._getCallbackId(callback, {
1287
- readOnly: true
1288
- })
1289
- const eventCallback = `${event}-${callbackId}`
1290
- const topic = instance
1291
- ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1292
- : `${this._domain}/${agent}/${className}/${eventCallback}`
1310
+ async _offRemoteEvent ({ callback }) {
1311
+ const callbackId = this._getCallbackId(callback, { readOnly: true })
1312
+ if (!callbackId) return null
1293
1313
 
1294
- const id = `__e__${topic}`
1314
+ let foundId = null
1315
+ const topics = Object.keys(this._cachedSubscriptions).filter(
1316
+ t => t.endsWith(`/${callbackId}`) || t.endsWith(`-${callbackId}`)
1317
+ )
1295
1318
 
1296
- if (this._cachedSubscriptions[topic]) {
1319
+ topics.forEach(topic => {
1320
+ const id = `__e__${topic}`
1297
1321
  const index = this._cachedSubscriptions[topic].findIndex(
1298
1322
  x => x.callback === callback
1299
1323
  )
1300
- if (index === -1) {
1301
- this._log.warn(`Failed removing event listener ${id}`)
1302
- return null
1303
- }
1304
- const [{ handler }] = this._cachedSubscriptions[topic].splice(index, 1)
1305
- if (this._cachedSubscriptions[topic].length === 0) {
1306
- this._eventEmitter.removeListener(id, handler)
1307
- this._mqttUnsubscribe(topic)
1308
- delete this._cachedSubscriptions[topic]
1309
- return id
1324
+
1325
+ if (index !== -1) {
1326
+ // Decrease the "reference count" by removing one instance
1327
+ this._cachedSubscriptions[topic].splice(index, 1)
1328
+
1329
+ // Only remove from the local event emitter if NO references remain
1330
+ if (this._cachedSubscriptions[topic].length === 0) {
1331
+ delete this._cachedSubscriptions[topic]
1332
+
1333
+ // Legacy cleanup just in case
1334
+ if (!topic.includes('__e__')) {
1335
+ this._mqttUnsubscribe(topic).catch(() => {})
1336
+ }
1337
+
1338
+ // Cleanly wipe all listeners for this specific id
1339
+ this._eventEmitter.removeAllListeners(id)
1340
+ }
1341
+ foundId = id
1310
1342
  }
1311
- return id
1312
- } else {
1313
- this._log.warn(`Can not unsubscribe from non-existing event: ${event}`)
1314
- }
1315
- return id
1343
+ })
1344
+ return foundId
1316
1345
  }
1317
1346
 
1318
1347
  _clearCachedSubscriptions ({ lostAgent, lostInstance }) {
1319
1348
  const obsoleteTopics = Object.keys(this._cachedSubscriptions).filter(
1320
1349
  topic => {
1321
- const [, agent, , instanceEvent] = topic.split('/')
1322
- const [instance] = instanceEvent.split(':')
1323
- if (agent === lostAgent || instance === lostInstance) {
1324
- return true
1350
+ const tokens = topic.split('/')
1351
+ if (tokens[3] === '__e__') {
1352
+ const topicAgent = tokens[4]
1353
+ const topicInstance = tokens[6]
1354
+ return topicAgent === lostAgent || topicInstance === lostInstance
1325
1355
  }
1326
- return false
1356
+ // Legacy fallback
1357
+ const [, agent, , instanceEvent] = topic.split('/')
1358
+ const [instance] = (instanceEvent || '').split(':')
1359
+ return agent === lostAgent || instance === lostInstance
1327
1360
  }
1328
1361
  )
1329
1362
  obsoleteTopics.forEach(topic => {
1330
1363
  const id = `__e__${topic}`
1331
- this._log.info(`Clearing subscriptions for obsolete topic: ${topic}`)
1364
+ this._log.debug(`Clearing subscriptions for obsolete topic: ${topic}`)
1332
1365
  const subscriptions = this._cachedSubscriptions[topic]
1333
1366
  subscriptions.forEach(({ handler }) => {
1334
1367
  this._eventEmitter.removeListener(id, handler)
1335
1368
  })
1369
+ // this async function will never reject
1336
1370
  this._mqttUnsubscribe(topic)
1337
1371
  delete this._cachedSubscriptions[topic]
1338
1372
  })