vrpc 3.4.0 → 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)
@@ -1129,16 +1239,24 @@ class VrpcClient extends EventEmitter {
1129
1239
  }
1130
1240
 
1131
1241
  _onRemoteEvent ({ agent, className, instance, event, callback }) {
1242
+ const callbackId = this._getCallbackId(callback)
1243
+ const eventCallback = `${event}-${callbackId}`
1244
+
1132
1245
  // the topic on which any remote event will be published to
1133
1246
  const topic = instance
1134
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1135
- : `${this._domain}/${agent}/${className}/${event}`
1247
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1248
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1249
+
1136
1250
  // prepare a special id for the agent to know that events should be
1137
- // published to the provided topic after the prefix
1251
+ // published to the provided topic after the prefix and before the suffix
1252
+
1138
1253
  const id = `__e__${topic}`
1254
+
1139
1255
  // call our proxy callback when remote events were received
1140
1256
  const handler = ({ a }) => callback.apply(null, a)
1141
- this._eventEmitter.on(id, handler)
1257
+ if (this._eventEmitter.listenerCount(id) === 0) {
1258
+ this._eventEmitter.on(id, handler)
1259
+ }
1142
1260
  if (!this._cachedSubscriptions[topic]) {
1143
1261
  // when not yet subscribed to this topic do it now
1144
1262
  this._mqttSubscribe(topic)
@@ -1151,10 +1269,16 @@ class VrpcClient extends EventEmitter {
1151
1269
  }
1152
1270
 
1153
1271
  _offRemoteEvent ({ agent, className, instance, event, callback }) {
1272
+ const callbackId = this._getCallbackId(callback, {
1273
+ readOnly: true
1274
+ })
1275
+ const eventCallback = `${event}-${callbackId}`
1154
1276
  const topic = instance
1155
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1156
- : `${this._domain}/${agent}/${className}/${event}`
1277
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1278
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1279
+
1157
1280
  const id = `__e__${topic}`
1281
+
1158
1282
  if (this._cachedSubscriptions[topic]) {
1159
1283
  const index = this._cachedSubscriptions[topic].findIndex(
1160
1284
  x => x.callback === callback
@@ -1164,8 +1288,8 @@ class VrpcClient extends EventEmitter {
1164
1288
  return null
1165
1289
  }
1166
1290
  const [{ handler }] = this._cachedSubscriptions[topic].splice(index, 1)
1167
- this._eventEmitter.removeListener(id, handler)
1168
1291
  if (this._cachedSubscriptions[topic].length === 0) {
1292
+ this._eventEmitter.removeListener(id, handler)
1169
1293
  this._mqttUnsubscribe(topic)
1170
1294
  delete this._cachedSubscriptions[topic]
1171
1295
  return id
@@ -1241,14 +1365,14 @@ class VrpcClient extends EventEmitter {
1241
1365
  // when no agent is specified, but an explicit class default exists give
1242
1366
  // such instances priority in being found
1243
1367
  if (!options.agent && this._agent !== '*') {
1244
- const { classes, status } = this._agents[this._agent]
1368
+ const { classes, status } = this._agents[this._agent] || {}
1245
1369
  if (!classes || status === 'offline') {
1246
1370
  for (const className in classes) {
1247
1371
  if (options.className && className !== options.className) continue
1248
1372
  const { instances } = classes[className]
1249
1373
  if (!instances) continue
1250
1374
  if (instances.includes(instance)) {
1251
- return { agent, className, instance }
1375
+ return { agent: this._agent, className, instance }
1252
1376
  }
1253
1377
  }
1254
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(