vrpc 3.3.2 → 3.5.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.
@@ -38,7 +38,6 @@ SOFTWARE.
38
38
  */
39
39
 
40
40
  const os = require('os')
41
- const crypto = require('crypto')
42
41
  const { nanoid } = require('nanoid')
43
42
  const mqtt = require('mqtt')
44
43
  const EventEmitter = require('events')
@@ -64,7 +63,7 @@ class VrpcClient extends EventEmitter {
64
63
  * @param {String} [options.token] Access token
65
64
  * @param {String} options.domain Sets the domain
66
65
  * @param {String} [options.agent="*"] Sets default agent
67
- * @param {String} [options.broker="mqtts://vrpc.io:8883"] Broker url in form: `<scheme>://<host>:<port>`
66
+ * @param {String} [options.broker="mqtts://broker.hivemq.com:8883"] Broker url in form: `<scheme>://<host>:<port>`
68
67
  * @param {Number} [options.timeout=12000] Maximum time in ms to wait for a RPC answer
69
68
  * @param {Object} [options.log=console] Log object (must support debug, info, warn, and error level)
70
69
  * @param {Boolean} [options.bestEffort=true] If true, message will be sent with best effort, i.e. no caching if offline
@@ -84,7 +83,7 @@ class VrpcClient extends EventEmitter {
84
83
  username,
85
84
  domain,
86
85
  agent = '*',
87
- broker = 'mqtts://vrpc.io:8883',
86
+ broker = 'mqtts://broker.hivemq.com:8883',
88
87
  timeout = 12 * 1000,
89
88
  log = 'console',
90
89
  bestEffort = true,
@@ -135,6 +134,7 @@ class VrpcClient extends EventEmitter {
135
134
  this._client = null
136
135
  this._cachedSubscriptions = {}
137
136
  this._proxies = {}
137
+ this._callbackIds = new WeakMap()
138
138
  if (log === 'console') {
139
139
  this._log = console
140
140
  this._log.debug = () => {}
@@ -189,6 +189,19 @@ class VrpcClient extends EventEmitter {
189
189
  if (password === undefined) delete options.password
190
190
  this._client = mqtt.connect(this._broker, options)
191
191
 
192
+ // Persistent lifecycle handlers
193
+ this._client.on('offline', () => {
194
+ this._log.warn('MQTT client went offline.')
195
+ // Clear the cache to ensure a fresh state upon reconnection
196
+ this._agents = {}
197
+ this.emit('offline')
198
+ })
199
+
200
+ this._client.on('reconnect', () => {
201
+ this._log.info('MQTT client is attempting to reconnect...')
202
+ this.emit('reconnect')
203
+ })
204
+
192
205
  this._client.on('error', err => {
193
206
  this.emit('error', err)
194
207
  })
@@ -214,75 +227,105 @@ class VrpcClient extends EventEmitter {
214
227
  }
215
228
  // RPC responses
216
229
  this._mqttSubscribe(this._vrpcClientId)
230
+
231
+ // Re-subscribe to all cached event topics
232
+ this._log.info('Restoring event subscriptions after reconnect...')
233
+ for (const topic of Object.keys(this._cachedSubscriptions)) {
234
+ this._mqttSubscribe(topic)
235
+ }
236
+
217
237
  this.emit('connect')
218
238
  })
219
239
 
220
240
  this._client.on('message', (topic, message) => {
221
241
  if (message.length === 0) return
222
- const tokens = topic.split('/')
223
- const [domain, agent, klass, instance] = tokens
224
- if (domain !== this._domain) {
225
- this._log.warn(`Received message from foreign domain: ${domain}`)
226
- return
227
- }
228
- // AgentInfo message
229
- if (klass === '__agentInfo__') {
230
- const { status, hostname, version } = JSON.parse(message.toString())
231
- this._createIfNotExist(agent)
232
- this._agents[agent].status = status
233
- this._agents[agent].hostname = hostname
234
- this._agents[agent].version = version
235
- if (status === 'offline') {
236
- this._clearCachedSubscriptions({ lostAgent: agent })
242
+ try {
243
+ const tokens = topic.split('/')
244
+ const [domain, agent, klass, instance] = tokens
245
+ if (domain !== this._domain) {
246
+ this._log.warn(`Received message from foreign domain: ${domain}`)
247
+ return
237
248
  }
238
- this.emit('agent', { domain, agent, status, hostname, version })
239
- // ClassInfo message
240
- } else if (
241
- instance === '__classInfo__' ||
242
- instance === '__classInfoConcise__'
243
- ) {
244
- // Json properties: { className, instances, memberFunctions, staticFunctions }
245
- const json = JSON.parse(message.toString())
246
- this._createIfNotExist(agent)
247
- const oldClassInfo = this._agents[agent].classes[klass]
248
- const newInstances = json.instances || []
249
- const oldInstances = oldClassInfo ? oldClassInfo.instances : []
250
- const removed = oldInstances.filter(x => !newInstances.includes(x))
251
- const added = newInstances.filter(x => !oldInstances.includes(x))
252
- this._agents[agent].classes[klass] = json
253
- const { className, instances, memberFunctions, staticFunctions, meta } =
254
- json
255
- if (removed.length !== 0) {
256
- this.emit('instanceGone', removed, { domain, agent, className })
257
- removed.forEach(lostInstance =>
258
- this._clearCachedSubscriptions({ lostInstance })
259
- )
260
- }
261
- if (added.length !== 0) {
262
- this.emit('instanceNew', added, { domain, agent, className })
249
+ // AgentInfo message
250
+ if (klass === '__agentInfo__') {
251
+ const { status, hostname, version } = JSON.parse(message.toString())
252
+ this._createIfNotExist(agent)
253
+ this._agents[agent].status = status
254
+ this._agents[agent].hostname = hostname
255
+ this._agents[agent].version = version
256
+ if (status === 'offline') {
257
+ this._clearCachedSubscriptions({ lostAgent: agent })
258
+ }
259
+ this.emit('agent', { domain, agent, status, hostname, version })
260
+ // ClassInfo message
261
+ } else if (
262
+ instance === '__classInfo__' ||
263
+ instance === '__classInfoConcise__'
264
+ ) {
265
+ // Json properties: { className, instances, memberFunctions, staticFunctions }
266
+ const json = JSON.parse(message.toString())
267
+ this._createIfNotExist(agent)
268
+ const oldClassInfo = this._agents[agent].classes[klass]
269
+ const newInstances = json.instances || []
270
+ const oldInstances = oldClassInfo ? oldClassInfo.instances : []
271
+ const removed = oldInstances.filter(x => !newInstances.includes(x))
272
+ const added = newInstances.filter(x => !oldInstances.includes(x))
273
+ this._agents[agent].classes[klass] = json
274
+ const {
275
+ className,
276
+ instances,
277
+ memberFunctions,
278
+ staticFunctions,
279
+ meta
280
+ } = json
281
+ if (removed.length !== 0) {
282
+ this.emit('instanceGone', removed, { domain, agent, className })
283
+ removed.forEach(lostInstance =>
284
+ this._clearCachedSubscriptions({ lostInstance })
285
+ )
286
+ }
287
+ if (added.length !== 0) {
288
+ this.emit('instanceNew', added, { domain, agent, className })
289
+ }
290
+ this.emit('class', {
291
+ domain,
292
+ agent,
293
+ className,
294
+ instances,
295
+ memberFunctions,
296
+ staticFunctions,
297
+ meta: meta || {}
298
+ })
299
+ // RPC message
300
+ } else {
301
+ const { i, a, e, r } = JSON.parse(message.toString())
302
+ this._eventEmitter.emit(i, { a, e, r })
263
303
  }
264
- this.emit('class', {
265
- domain,
266
- agent,
267
- className,
268
- instances,
269
- memberFunctions,
270
- staticFunctions,
271
- meta: meta || {}
272
- })
273
- // RPC message
274
- } else {
275
- const { i, a, e, r } = JSON.parse(message.toString())
276
- this._eventEmitter.emit(i, { a, e, r })
304
+ } catch (err) {
305
+ this._log.error(
306
+ `Could not parse MQTT message on topic "${topic}": ${err.message}`
307
+ )
277
308
  }
278
309
  })
310
+
279
311
  return new Promise((resolve, reject) => {
280
- this._client.once('offline', () => {
281
- this._client.end()
282
- reject(new Error(`Connection trial timed out (> ${this._timeout} ms)`))
312
+ const timeout = setTimeout(() => {
313
+ // This will run only if the 'connect' event isn't fired in time.
314
+ // We explicitly end the client to stop it from retrying in the background.
315
+ this._client.end(true, () => {
316
+ reject(
317
+ new Error(`Connection trial timed out (> ${this._timeout} ms)`)
318
+ )
319
+ })
320
+ }, this._timeout)
321
+
322
+ this._client.once('connect', () => {
323
+ // If we connect successfully, clear the timeout and resolve the promise.
324
+ clearTimeout(timeout)
325
+ resolve()
283
326
  })
284
- this._client.once('connect', resolve)
285
327
  })
328
+ // --------------------------------------------------------------------------------
286
329
  }
287
330
 
288
331
  /**
@@ -343,6 +386,9 @@ class VrpcClient extends EventEmitter {
343
386
  cacheProxy = false,
344
387
  isIsolated = false
345
388
  } = {}) {
389
+ if (!this._isConnected()) {
390
+ throw new Error('Client is not connected')
391
+ }
346
392
  if (agent === '*') throw new Error('Agent must be specified')
347
393
  const json = {
348
394
  c: className,
@@ -372,6 +418,9 @@ class VrpcClient extends EventEmitter {
372
418
  * @returns {Promise<Proxy>} Proxy object reflecting the remotely existing instance
373
419
  */
374
420
  async getInstance (instance, options = {}) {
421
+ if (!this._isConnected()) {
422
+ throw new Error('Client is not connected')
423
+ }
375
424
  if (this._proxies[instance]) return this._proxies[instance]
376
425
  const { agent, className } = await this._getInstanceData(instance, options)
377
426
  return this._createProxy(agent, className, instance)
@@ -390,6 +439,9 @@ class VrpcClient extends EventEmitter {
390
439
  * @returns {Promise<Boolean>} true if successful, false otherwise
391
440
  */
392
441
  async delete (instance, options = {}) {
442
+ if (!this._isConnected()) {
443
+ throw new Error('Client is not connected')
444
+ }
393
445
  const { agent, className } = await this._getInstanceData(instance, options)
394
446
  const json = {
395
447
  c: className,
@@ -402,7 +454,25 @@ class VrpcClient extends EventEmitter {
402
454
  const topic = `${this._domain}/${agent}/${className}/__static__/__delete__`
403
455
  this._mqttPublish(topic, JSON.stringify(json))
404
456
  if (this._proxies[instance]) delete this._proxies[instance]
405
- return this._handleAgentAnswer(json, agent)
457
+ const result = await this._handleAgentAnswer(json, agent)
458
+ // Now that deletion is confirmed, proactively update all local caches
459
+ if (result) {
460
+ // 1. Remove from the proxy cache
461
+ if (this._proxies[instance]) {
462
+ delete this._proxies[instance]
463
+ }
464
+ // 2. Remove from the main agent/instance cache
465
+ if (this._agents[agent] && this._agents[agent].classes) {
466
+ const classInfo = this._agents[agent].classes[className]
467
+ if (classInfo && classInfo.instances) {
468
+ const index = classInfo.instances.indexOf(instance)
469
+ if (index > -1) {
470
+ classInfo.instances.splice(index, 1)
471
+ }
472
+ }
473
+ }
474
+ }
475
+ return result
406
476
  }
407
477
 
408
478
  /**
@@ -421,6 +491,9 @@ class VrpcClient extends EventEmitter {
421
491
  args = [],
422
492
  agent = this._agent
423
493
  } = {}) {
494
+ if (!this._isConnected()) {
495
+ throw new Error('Client is not connected')
496
+ }
424
497
  const wrapped = this._wrapArguments({
425
498
  agent,
426
499
  className,
@@ -468,6 +541,9 @@ class VrpcClient extends EventEmitter {
468
541
  args = [],
469
542
  agent = this._agent
470
543
  } = {}) {
544
+ if (!this._isConnected()) {
545
+ throw new Error('Client is not connected')
546
+ }
471
547
  const json = {
472
548
  c: className,
473
549
  f: functionName,
@@ -644,10 +720,10 @@ class VrpcClient extends EventEmitter {
644
720
  async reconnectWithToken (token, { agent = this._agent } = {}) {
645
721
  this._token = token
646
722
  this._agent = agent
647
- this._client.end(() => this.connect())
648
- return new Promise(resolve => {
649
- this._client.once('connect', resolve)
650
- })
723
+ if (this._client) {
724
+ await new Promise(resolve => this._client.end(false, {}, resolve))
725
+ }
726
+ return this.connect()
651
727
  }
652
728
 
653
729
  /**
@@ -678,11 +754,49 @@ class VrpcClient extends EventEmitter {
678
754
  * @returns {Promise} Resolves when ended
679
755
  */
680
756
  async end () {
757
+ if (!this._client) return
681
758
  this._mqttPublish(
682
759
  `${this._vrpcClientId}/__clientInfo__`,
683
760
  JSON.stringify({ status: 'offline', v: VRPC_PROTOCOL_VERSION })
684
761
  )
685
- return new Promise(resolve => this._client.end(false, {}, resolve))
762
+ this._eventEmitter.removeAllListeners()
763
+ this.removeAllListeners()
764
+ const client = this._client
765
+ this._client = null // Prevent race conditions during shutdown
766
+ return new Promise(resolve => client.end(false, {}, resolve))
767
+ }
768
+
769
+ // Private functions
770
+
771
+ static _createHash (str, length = 20) {
772
+ // Extended DJB2 hash with two accumulators
773
+ let hash1 = 5381
774
+ let hash2 = 52711 // different seed
775
+
776
+ for (let i = 0; i < str.length; i++) {
777
+ const char = str.charCodeAt(i)
778
+ hash1 = (hash1 * 33) ^ char
779
+ hash2 = (hash2 * 33) ^ char
780
+ }
781
+
782
+ // Combine into longer hex string
783
+ const token =
784
+ (hash1 >>> 0).toString(16).padStart(8, '0') +
785
+ (hash2 >>> 0).toString(16).padStart(8, '0')
786
+
787
+ return token.slice(0, length)
788
+ }
789
+
790
+ _isConnected () {
791
+ return this._client && this._client.connected
792
+ }
793
+
794
+ _getCallbackId (callback, { readOnly = false } = {}) {
795
+ if (!this._callbackIds.has(callback)) {
796
+ if (readOnly) return null
797
+ this._callbackIds.set(callback, nanoid(8))
798
+ }
799
+ return this._callbackIds.get(callback)
686
800
  }
687
801
 
688
802
  _generateUserName () {
@@ -700,12 +814,8 @@ class VrpcClient extends EventEmitter {
700
814
  os.totalmem() +
701
815
  os.type()
702
816
  // console.log('ClientInfo:', clientInfo)
703
- const md5 = crypto
704
- .createHash('md5')
705
- .update(clientInfo)
706
- .digest('hex')
707
- .substring(0, 12)
708
- return `vc3${this._instance}${md5}` // 3 + 8 + 12 = 23 (max clientId)
817
+ const hash = VrpcClient._createHash(clientInfo, 12)
818
+ return `vc3${this._instance}${hash}` // 3 + 8 + 12 = 23 (max clientId)
709
819
  }
710
820
 
711
821
  _createVrpcClientId () {
@@ -845,7 +955,7 @@ class VrpcClient extends EventEmitter {
845
955
  this.removeListener('class', checkClass)
846
956
  reject(
847
957
  new Error(
848
- `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
958
+ `Connection to class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
849
959
  )
850
960
  )
851
961
  }, this._timeout)
@@ -1081,8 +1191,6 @@ class VrpcClient extends EventEmitter {
1081
1191
  event,
1082
1192
  callback
1083
1193
  })
1084
- // indicate that no network call is required
1085
- if (id === null) return null
1086
1194
  wrapped.push(id)
1087
1195
  continue
1088
1196
  } else if (
@@ -1098,8 +1206,6 @@ class VrpcClient extends EventEmitter {
1098
1206
  event,
1099
1207
  callback
1100
1208
  })
1101
- // indicate that no network call is required
1102
- if (id === null) return null
1103
1209
  wrapped.push(id)
1104
1210
  continue
1105
1211
  } else {
@@ -1123,8 +1229,6 @@ class VrpcClient extends EventEmitter {
1123
1229
  event,
1124
1230
  callback: (...args) => emitter.emit(event, ...args)
1125
1231
  })
1126
- // indicate that no network call is required
1127
- if (id === null) return null
1128
1232
  wrapped.push(id)
1129
1233
  continue
1130
1234
  }
@@ -1135,16 +1239,24 @@ class VrpcClient extends EventEmitter {
1135
1239
  }
1136
1240
 
1137
1241
  _onRemoteEvent ({ agent, className, instance, event, callback }) {
1242
+ const callbackId = this._getCallbackId(callback)
1243
+ const eventCallback = `${event}-${callbackId}`
1244
+
1138
1245
  // the topic on which any remote event will be published to
1139
1246
  const topic = instance
1140
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1141
- : `${this._domain}/${agent}/${className}/${event}`
1247
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1248
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1249
+
1142
1250
  // prepare a special id for the agent to know that events should be
1143
- // published to the provided topic after the prefix
1251
+ // published to the provided topic after the prefix and before the suffix
1252
+
1144
1253
  const id = `__e__${topic}`
1254
+
1145
1255
  // call our proxy callback when remote events were received
1146
1256
  const handler = ({ a }) => callback.apply(null, a)
1147
- this._eventEmitter.on(id, handler)
1257
+ if (this._eventEmitter.listenerCount(id) === 0) {
1258
+ this._eventEmitter.on(id, handler)
1259
+ }
1148
1260
  if (!this._cachedSubscriptions[topic]) {
1149
1261
  // when not yet subscribed to this topic do it now
1150
1262
  this._mqttSubscribe(topic)
@@ -1153,15 +1265,20 @@ class VrpcClient extends EventEmitter {
1153
1265
  }
1154
1266
  // otherwise just add the new callback-handler pair
1155
1267
  this._cachedSubscriptions[topic].push({ callback, handler })
1156
- // indicate that no network call is required
1157
- return null
1268
+ return id
1158
1269
  }
1159
1270
 
1160
1271
  _offRemoteEvent ({ agent, className, instance, event, callback }) {
1272
+ const callbackId = this._getCallbackId(callback, {
1273
+ readOnly: true
1274
+ })
1275
+ const eventCallback = `${event}-${callbackId}`
1161
1276
  const topic = instance
1162
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1163
- : `${this._domain}/${agent}/${className}/${event}`
1277
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1278
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1279
+
1164
1280
  const id = `__e__${topic}`
1281
+
1165
1282
  if (this._cachedSubscriptions[topic]) {
1166
1283
  const index = this._cachedSubscriptions[topic].findIndex(
1167
1284
  x => x.callback === callback
@@ -1171,14 +1288,13 @@ class VrpcClient extends EventEmitter {
1171
1288
  return null
1172
1289
  }
1173
1290
  const [{ handler }] = this._cachedSubscriptions[topic].splice(index, 1)
1174
- this._eventEmitter.removeListener(id, handler)
1175
1291
  if (this._cachedSubscriptions[topic].length === 0) {
1292
+ this._eventEmitter.removeListener(id, handler)
1176
1293
  this._mqttUnsubscribe(topic)
1177
1294
  delete this._cachedSubscriptions[topic]
1178
1295
  return id
1179
1296
  }
1180
- // indicate that no network call is required
1181
- return null
1297
+ return id
1182
1298
  } else {
1183
1299
  this._log.warn(`Can not unsubscribe from non-existing event: ${event}`)
1184
1300
  }
@@ -1225,7 +1341,8 @@ class VrpcClient extends EventEmitter {
1225
1341
 
1226
1342
  _isEmitter (variable) {
1227
1343
  return (
1228
- variable && typeof variable === 'object' &&
1344
+ variable &&
1345
+ typeof variable === 'object' &&
1229
1346
  variable.hasOwnProperty('emitter') &&
1230
1347
  variable.hasOwnProperty('event') &&
1231
1348
  typeof variable.emitter === 'object' &&
@@ -1248,14 +1365,14 @@ class VrpcClient extends EventEmitter {
1248
1365
  // when no agent is specified, but an explicit class default exists give
1249
1366
  // such instances priority in being found
1250
1367
  if (!options.agent && this._agent !== '*') {
1251
- const { classes, status } = this._agents[this._agent]
1368
+ const { classes, status } = this._agents[this._agent] || {}
1252
1369
  if (!classes || status === 'offline') {
1253
1370
  for (const className in classes) {
1254
1371
  if (options.className && className !== options.className) continue
1255
1372
  const { instances } = classes[className]
1256
1373
  if (!instances) continue
1257
1374
  if (instances.includes(instance)) {
1258
- return { agent, className, instance }
1375
+ return { agent: this._agent, className, instance }
1259
1376
  }
1260
1377
  }
1261
1378
  }
@@ -54,7 +54,7 @@ parser.add_argument(
54
54
  {
55
55
  help: 'The broker to use',
56
56
  type: 'string',
57
- default: 'mqtts://vrpc.io:8883'
57
+ default: 'mqtts://broker.hivemq.com:8883'
58
58
  }
59
59
  )
60
60
  parser.add_argument(