vrpc 3.4.0 → 3.5.1

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,8 @@ class VrpcClient extends EventEmitter {
135
134
  this._client = null
136
135
  this._cachedSubscriptions = {}
137
136
  this._proxies = {}
137
+ this._callbackIds = new WeakMap()
138
+ this._emitterListener = new WeakMap()
138
139
  if (log === 'console') {
139
140
  this._log = console
140
141
  this._log.debug = () => {}
@@ -189,6 +190,19 @@ class VrpcClient extends EventEmitter {
189
190
  if (password === undefined) delete options.password
190
191
  this._client = mqtt.connect(this._broker, options)
191
192
 
193
+ // Persistent lifecycle handlers
194
+ this._client.on('offline', () => {
195
+ this._log.warn('MQTT client went offline.')
196
+ // Clear the cache to ensure a fresh state upon reconnection
197
+ this._agents = {}
198
+ this.emit('offline')
199
+ })
200
+
201
+ this._client.on('reconnect', () => {
202
+ this._log.info('MQTT client is attempting to reconnect...')
203
+ this.emit('reconnect')
204
+ })
205
+
192
206
  this._client.on('error', err => {
193
207
  this.emit('error', err)
194
208
  })
@@ -214,75 +228,105 @@ class VrpcClient extends EventEmitter {
214
228
  }
215
229
  // RPC responses
216
230
  this._mqttSubscribe(this._vrpcClientId)
231
+
232
+ // Re-subscribe to all cached event topics
233
+ this._log.info('Restoring event subscriptions after reconnect...')
234
+ for (const topic of Object.keys(this._cachedSubscriptions)) {
235
+ this._mqttSubscribe(topic)
236
+ }
237
+
217
238
  this.emit('connect')
218
239
  })
219
240
 
220
241
  this._client.on('message', (topic, message) => {
221
242
  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 })
243
+ try {
244
+ const tokens = topic.split('/')
245
+ const [domain, agent, klass, instance] = tokens
246
+ if (domain !== this._domain) {
247
+ this._log.warn(`Received message from foreign domain: ${domain}`)
248
+ return
237
249
  }
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 })
250
+ // AgentInfo message
251
+ if (klass === '__agentInfo__') {
252
+ const { status, hostname, version } = JSON.parse(message.toString())
253
+ this._createIfNotExist(agent)
254
+ this._agents[agent].status = status
255
+ this._agents[agent].hostname = hostname
256
+ this._agents[agent].version = version
257
+ if (status === 'offline') {
258
+ this._clearCachedSubscriptions({ lostAgent: agent })
259
+ }
260
+ this.emit('agent', { domain, agent, status, hostname, version })
261
+ // ClassInfo message
262
+ } else if (
263
+ instance === '__classInfo__' ||
264
+ instance === '__classInfoConcise__'
265
+ ) {
266
+ // Json properties: { className, instances, memberFunctions, staticFunctions }
267
+ const json = JSON.parse(message.toString())
268
+ this._createIfNotExist(agent)
269
+ const oldClassInfo = this._agents[agent].classes[klass]
270
+ const newInstances = json.instances || []
271
+ const oldInstances = oldClassInfo ? oldClassInfo.instances : []
272
+ const removed = oldInstances.filter(x => !newInstances.includes(x))
273
+ const added = newInstances.filter(x => !oldInstances.includes(x))
274
+ this._agents[agent].classes[klass] = json
275
+ const {
276
+ className,
277
+ instances,
278
+ memberFunctions,
279
+ staticFunctions,
280
+ meta
281
+ } = json
282
+ if (removed.length !== 0) {
283
+ this.emit('instanceGone', removed, { domain, agent, className })
284
+ removed.forEach(lostInstance =>
285
+ this._clearCachedSubscriptions({ lostInstance })
286
+ )
287
+ }
288
+ if (added.length !== 0) {
289
+ this.emit('instanceNew', added, { domain, agent, className })
290
+ }
291
+ this.emit('class', {
292
+ domain,
293
+ agent,
294
+ className,
295
+ instances,
296
+ memberFunctions,
297
+ staticFunctions,
298
+ meta: meta || {}
299
+ })
300
+ // RPC message
301
+ } else {
302
+ const { i, a, e, r } = JSON.parse(message.toString())
303
+ this._eventEmitter.emit(i, { a, e, r })
263
304
  }
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 })
305
+ } catch (err) {
306
+ this._log.error(
307
+ `Could not parse MQTT message on topic "${topic}": ${err.message}`
308
+ )
277
309
  }
278
310
  })
311
+
279
312
  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)`))
313
+ const timeout = setTimeout(() => {
314
+ // This will run only if the 'connect' event isn't fired in time.
315
+ // We explicitly end the client to stop it from retrying in the background.
316
+ this._client.end(true, () => {
317
+ reject(
318
+ new Error(`Connection trial timed out (> ${this._timeout} ms)`)
319
+ )
320
+ })
321
+ }, this._timeout)
322
+
323
+ this._client.once('connect', () => {
324
+ // If we connect successfully, clear the timeout and resolve the promise.
325
+ clearTimeout(timeout)
326
+ resolve()
283
327
  })
284
- this._client.once('connect', resolve)
285
328
  })
329
+ // --------------------------------------------------------------------------------
286
330
  }
287
331
 
288
332
  /**
@@ -343,6 +387,9 @@ class VrpcClient extends EventEmitter {
343
387
  cacheProxy = false,
344
388
  isIsolated = false
345
389
  } = {}) {
390
+ if (!this._isConnected()) {
391
+ throw new Error('Client is not connected')
392
+ }
346
393
  if (agent === '*') throw new Error('Agent must be specified')
347
394
  const json = {
348
395
  c: className,
@@ -372,6 +419,9 @@ class VrpcClient extends EventEmitter {
372
419
  * @returns {Promise<Proxy>} Proxy object reflecting the remotely existing instance
373
420
  */
374
421
  async getInstance (instance, options = {}) {
422
+ if (!this._isConnected()) {
423
+ throw new Error('Client is not connected')
424
+ }
375
425
  if (this._proxies[instance]) return this._proxies[instance]
376
426
  const { agent, className } = await this._getInstanceData(instance, options)
377
427
  return this._createProxy(agent, className, instance)
@@ -390,6 +440,9 @@ class VrpcClient extends EventEmitter {
390
440
  * @returns {Promise<Boolean>} true if successful, false otherwise
391
441
  */
392
442
  async delete (instance, options = {}) {
443
+ if (!this._isConnected()) {
444
+ throw new Error('Client is not connected')
445
+ }
393
446
  const { agent, className } = await this._getInstanceData(instance, options)
394
447
  const json = {
395
448
  c: className,
@@ -402,7 +455,25 @@ class VrpcClient extends EventEmitter {
402
455
  const topic = `${this._domain}/${agent}/${className}/__static__/__delete__`
403
456
  this._mqttPublish(topic, JSON.stringify(json))
404
457
  if (this._proxies[instance]) delete this._proxies[instance]
405
- return this._handleAgentAnswer(json, agent)
458
+ const result = await this._handleAgentAnswer(json, agent)
459
+ // Now that deletion is confirmed, proactively update all local caches
460
+ if (result) {
461
+ // 1. Remove from the proxy cache
462
+ if (this._proxies[instance]) {
463
+ delete this._proxies[instance]
464
+ }
465
+ // 2. Remove from the main agent/instance cache
466
+ if (this._agents[agent] && this._agents[agent].classes) {
467
+ const classInfo = this._agents[agent].classes[className]
468
+ if (classInfo && classInfo.instances) {
469
+ const index = classInfo.instances.indexOf(instance)
470
+ if (index > -1) {
471
+ classInfo.instances.splice(index, 1)
472
+ }
473
+ }
474
+ }
475
+ }
476
+ return result
406
477
  }
407
478
 
408
479
  /**
@@ -421,6 +492,9 @@ class VrpcClient extends EventEmitter {
421
492
  args = [],
422
493
  agent = this._agent
423
494
  } = {}) {
495
+ if (!this._isConnected()) {
496
+ throw new Error('Client is not connected')
497
+ }
424
498
  const wrapped = this._wrapArguments({
425
499
  agent,
426
500
  className,
@@ -468,6 +542,9 @@ class VrpcClient extends EventEmitter {
468
542
  args = [],
469
543
  agent = this._agent
470
544
  } = {}) {
545
+ if (!this._isConnected()) {
546
+ throw new Error('Client is not connected')
547
+ }
471
548
  const json = {
472
549
  c: className,
473
550
  f: functionName,
@@ -644,10 +721,10 @@ class VrpcClient extends EventEmitter {
644
721
  async reconnectWithToken (token, { agent = this._agent } = {}) {
645
722
  this._token = token
646
723
  this._agent = agent
647
- this._client.end(() => this.connect())
648
- return new Promise(resolve => {
649
- this._client.once('connect', resolve)
650
- })
724
+ if (this._client) {
725
+ await new Promise(resolve => this._client.end(false, {}, resolve))
726
+ }
727
+ return this.connect()
651
728
  }
652
729
 
653
730
  /**
@@ -678,11 +755,49 @@ class VrpcClient extends EventEmitter {
678
755
  * @returns {Promise} Resolves when ended
679
756
  */
680
757
  async end () {
758
+ if (!this._client) return
681
759
  this._mqttPublish(
682
760
  `${this._vrpcClientId}/__clientInfo__`,
683
761
  JSON.stringify({ status: 'offline', v: VRPC_PROTOCOL_VERSION })
684
762
  )
685
- return new Promise(resolve => this._client.end(false, {}, resolve))
763
+ this._eventEmitter.removeAllListeners()
764
+ this.removeAllListeners()
765
+ const client = this._client
766
+ this._client = null // Prevent race conditions during shutdown
767
+ return new Promise(resolve => client.end(false, {}, resolve))
768
+ }
769
+
770
+ // Private functions
771
+
772
+ static _createHash (str, length = 20) {
773
+ // Extended DJB2 hash with two accumulators
774
+ let hash1 = 5381
775
+ let hash2 = 52711 // different seed
776
+
777
+ for (let i = 0; i < str.length; i++) {
778
+ const char = str.charCodeAt(i)
779
+ hash1 = (hash1 * 33) ^ char
780
+ hash2 = (hash2 * 33) ^ char
781
+ }
782
+
783
+ // Combine into longer hex string
784
+ const token =
785
+ (hash1 >>> 0).toString(16).padStart(8, '0') +
786
+ (hash2 >>> 0).toString(16).padStart(8, '0')
787
+
788
+ return token.slice(0, length)
789
+ }
790
+
791
+ _isConnected () {
792
+ return this._client && this._client.connected
793
+ }
794
+
795
+ _getCallbackId (callback, { readOnly = false } = {}) {
796
+ if (!this._callbackIds.has(callback)) {
797
+ if (readOnly) return null
798
+ this._callbackIds.set(callback, nanoid(8))
799
+ }
800
+ return this._callbackIds.get(callback)
686
801
  }
687
802
 
688
803
  _generateUserName () {
@@ -700,12 +815,8 @@ class VrpcClient extends EventEmitter {
700
815
  os.totalmem() +
701
816
  os.type()
702
817
  // 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)
818
+ const hash = VrpcClient._createHash(clientInfo, 12)
819
+ return `vc3${this._instance}${hash}` // 3 + 8 + 12 = 23 (max clientId)
709
820
  }
710
821
 
711
822
  _createVrpcClientId () {
@@ -845,7 +956,7 @@ class VrpcClient extends EventEmitter {
845
956
  this.removeListener('class', checkClass)
846
957
  reject(
847
958
  new Error(
848
- `Proxy creation for class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
959
+ `Connection to class "${className}" on agent "${agent}" and domain "${this._domain}" timed out (> ${this._timeout} ms)`
849
960
  )
850
961
  )
851
962
  }, this._timeout)
@@ -1112,12 +1223,24 @@ class VrpcClient extends EventEmitter {
1112
1223
  // special case of an EventEmitter provided as argument
1113
1224
  if (this._isEmitter(x)) {
1114
1225
  const { emitter, event } = x
1226
+ let callback = (...args) => emitter.emit(event, ...args)
1227
+ if (this._emitterListener.has(emitter)) {
1228
+ const eventMap = this._emitterListener.get(emitter)
1229
+ if (eventMap.has(event)) {
1230
+ callback = eventMap.get(event)
1231
+ } else {
1232
+ eventMap.set(event, callback)
1233
+ }
1234
+ } else {
1235
+ this._emitterListener.set(emitter, new Map([[event, callback]]))
1236
+ }
1237
+
1115
1238
  const id = this._onRemoteEvent({
1116
1239
  agent,
1117
1240
  className,
1118
1241
  instance,
1119
1242
  event,
1120
- callback: (...args) => emitter.emit(event, ...args)
1243
+ callback
1121
1244
  })
1122
1245
  wrapped.push(id)
1123
1246
  continue
@@ -1129,16 +1252,24 @@ class VrpcClient extends EventEmitter {
1129
1252
  }
1130
1253
 
1131
1254
  _onRemoteEvent ({ agent, className, instance, event, callback }) {
1255
+ const callbackId = this._getCallbackId(callback)
1256
+ const eventCallback = `${event}-${callbackId}`
1257
+
1132
1258
  // the topic on which any remote event will be published to
1133
1259
  const topic = instance
1134
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1135
- : `${this._domain}/${agent}/${className}/${event}`
1260
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1261
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1262
+
1136
1263
  // prepare a special id for the agent to know that events should be
1137
- // published to the provided topic after the prefix
1264
+ // published to the provided topic after the prefix and before the suffix
1265
+
1138
1266
  const id = `__e__${topic}`
1267
+
1139
1268
  // call our proxy callback when remote events were received
1140
1269
  const handler = ({ a }) => callback.apply(null, a)
1141
- this._eventEmitter.on(id, handler)
1270
+ if (this._eventEmitter.listenerCount(id) === 0) {
1271
+ this._eventEmitter.on(id, handler)
1272
+ }
1142
1273
  if (!this._cachedSubscriptions[topic]) {
1143
1274
  // when not yet subscribed to this topic do it now
1144
1275
  this._mqttSubscribe(topic)
@@ -1151,10 +1282,16 @@ class VrpcClient extends EventEmitter {
1151
1282
  }
1152
1283
 
1153
1284
  _offRemoteEvent ({ agent, className, instance, event, callback }) {
1285
+ const callbackId = this._getCallbackId(callback, {
1286
+ readOnly: true
1287
+ })
1288
+ const eventCallback = `${event}-${callbackId}`
1154
1289
  const topic = instance
1155
- ? `${this._domain}/${agent}/${className}/${instance}:${event}`
1156
- : `${this._domain}/${agent}/${className}/${event}`
1290
+ ? `${this._domain}/${agent}/${className}/${instance}:${eventCallback}`
1291
+ : `${this._domain}/${agent}/${className}/${eventCallback}`
1292
+
1157
1293
  const id = `__e__${topic}`
1294
+
1158
1295
  if (this._cachedSubscriptions[topic]) {
1159
1296
  const index = this._cachedSubscriptions[topic].findIndex(
1160
1297
  x => x.callback === callback
@@ -1164,8 +1301,8 @@ class VrpcClient extends EventEmitter {
1164
1301
  return null
1165
1302
  }
1166
1303
  const [{ handler }] = this._cachedSubscriptions[topic].splice(index, 1)
1167
- this._eventEmitter.removeListener(id, handler)
1168
1304
  if (this._cachedSubscriptions[topic].length === 0) {
1305
+ this._eventEmitter.removeListener(id, handler)
1169
1306
  this._mqttUnsubscribe(topic)
1170
1307
  delete this._cachedSubscriptions[topic]
1171
1308
  return id
@@ -1241,14 +1378,14 @@ class VrpcClient extends EventEmitter {
1241
1378
  // when no agent is specified, but an explicit class default exists give
1242
1379
  // such instances priority in being found
1243
1380
  if (!options.agent && this._agent !== '*') {
1244
- const { classes, status } = this._agents[this._agent]
1381
+ const { classes, status } = this._agents[this._agent] || {}
1245
1382
  if (!classes || status === 'offline') {
1246
1383
  for (const className in classes) {
1247
1384
  if (options.className && className !== options.className) continue
1248
1385
  const { instances } = classes[className]
1249
1386
  if (!instances) continue
1250
1387
  if (instances.includes(instance)) {
1251
- return { agent, className, instance }
1388
+ return { agent: this._agent, className, instance }
1252
1389
  }
1253
1390
  }
1254
1391
  }
@@ -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(