vrpc 3.13.0 → 3.14.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.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Variadic Remote Procedure Calls",
5
5
  "main": "index.js",
6
6
  "directories": {
@@ -12,9 +12,10 @@
12
12
  "build:addon": "ln -sf binding.gyp.no-auto binding.gyp && node-gyp rebuild && rm binding.gyp",
13
13
  "build:browser": "./node_modules/.bin/webpack --stats-error-details",
14
14
  "build:doc": "./node_modules/.bin/jsdoc2md -f vrpc/VrpcAdapter.js vrpc/VrpcAgent.js vrpc/VrpcClient.js vrpc/VrpcNative.js --param-list-format=list --separators > docs/api.md",
15
- "test": "npm run test:adapter && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:lifecycle && npm run test:legacy-runtime && npm run test:production",
15
+ "test": "npm run test:adapter && npm run test:reconnect && npm run test:agent && npm run test:client && npm run test:persistor && npm run test:lifecycle && npm run test:legacy-runtime && npm run test:production",
16
16
  "test:adapter": "./node_modules/.bin/mocha tests/adapter/*.js --exit",
17
17
  "test:native": "./node_modules/.bin/mocha tests/native/*.js --exit",
18
+ "test:reconnect": "./node_modules/.bin/mocha tests/reconnect/*.spec.js --exit",
18
19
  "test:agent": "tests/agent/test.sh",
19
20
  "test:client": "tests/client/test.sh",
20
21
  "test:performance": "tests/performance/test.sh",
@@ -57,6 +58,7 @@
57
58
  "fs-extra": "^11.3.1",
58
59
  "jsdoc-to-markdown": "^7.0.1",
59
60
  "mocha": "^9.2.2",
61
+ "mqtt-packet": "^9.0.2",
60
62
  "os-browserify": "^0.3.0",
61
63
  "sinon": "^21.0.0",
62
64
  "stream-browserify": "^3.0.0",
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Paces the reconnects of an mqtt.js client after refused CONNACKs.
3
+ *
4
+ * mqtt.js retries at a fixed `reconnectPeriod`. A connection the broker
5
+ * refuses - a stale credential, an authorization service that is away -
6
+ * would knock once per period for as long as it lives, and an
7
+ * authorization service may block a client id that knocks too often. So
8
+ * every refusal doubles the wait up to `max`, and a successful connect
9
+ * restores the base. The refusal itself stays visible as the client's
10
+ * 'error' event, so a host can renew its credentials and `hurry()`.
11
+ *
12
+ * mqtt.js reads `reconnectPeriod` only when it arms its reconnect
13
+ * interval, once per outage; re-arming it takes the internal
14
+ * `_clearReconnect` / `_setupReconnect`. Without them (a future mqtt.js)
15
+ * a new period applies from the next outage on.
16
+ */
17
+
18
+ const REFUSED = /^Connection refused/
19
+
20
+ class ReconnectBackoff {
21
+ /**
22
+ * @param {Object} client an mqtt.js client created with
23
+ * `reconnectOnConnackError: true`
24
+ * @param {Object} [options]
25
+ * @param {Number} [options.max=30000] longest wait in ms between attempts
26
+ */
27
+ constructor (client, { max = 30000 } = {}) {
28
+ this._client = client
29
+ this._base = client.options.reconnectPeriod
30
+ this._max = Math.max(max, this._base)
31
+ this._next = this._base
32
+ client.on('error', err => {
33
+ if (ReconnectBackoff.isRefusal(err)) this._slowDown()
34
+ })
35
+ client.on('connect', () => this._restore())
36
+ }
37
+
38
+ /** Whether an mqtt.js error is a refused CONNACK. */
39
+ static isRefusal (err) {
40
+ return (
41
+ !!err && typeof err.code === 'number' && REFUSED.test(err.message || '')
42
+ )
43
+ }
44
+
45
+ /** The wait in ms before the next attempt. */
46
+ get period () {
47
+ return this._client.options.reconnectPeriod
48
+ }
49
+
50
+ /**
51
+ * New credentials: the next attempt follows within the base period
52
+ * instead of waiting out the back-off. A live connection stays as it is.
53
+ */
54
+ hurry () {
55
+ this._restore()
56
+ const client = this._client
57
+ if (client.connected || client.disconnecting || !client.reconnecting) return
58
+ if (typeof client._clearReconnect !== 'function') return
59
+ client._clearReconnect()
60
+ client._setupReconnect()
61
+ }
62
+
63
+ // Runs inside the CONNACK handler's 'error' emit: the handler re-arms
64
+ // the reconnect interval right after (reconnectOnConnackError), with the
65
+ // period set here
66
+ _slowDown () {
67
+ const client = this._client
68
+ client.options.reconnectPeriod = this._next
69
+ this._next = Math.min(this._next * 2, this._max)
70
+ if (typeof client._clearReconnect === 'function') client._clearReconnect()
71
+ }
72
+
73
+ _restore () {
74
+ this._next = this._base
75
+ this._client.options.reconnectPeriod = this._base
76
+ }
77
+ }
78
+
79
+ module.exports = ReconnectBackoff
package/vrpc/VrpcAgent.js CHANGED
@@ -7,6 +7,7 @@ const EventEmitter = require('events')
7
7
  const jsonStringifySafe = require('json-stringify-safe')
8
8
  const VrpcAdapter = require('./VrpcAdapter')
9
9
  const SubscribeRetrier = require('./SubscribeRetrier')
10
+ const ReconnectBackoff = require('./ReconnectBackoff')
10
11
 
11
12
  const VRPC_PROTOCOL_VERSION = 3
12
13
 
@@ -221,6 +222,8 @@ class VrpcAgent extends EventEmitter {
221
222
  this._log.info(`Broker : ${this._broker}`)
222
223
  this._log.info('Connecting to MQTT server...')
223
224
  this._client = mqtt.connect(this._broker, this._options)
225
+ // a refused agent knocks less and less often, not once a second
226
+ this._backoff = new ReconnectBackoff(this._client)
224
227
  this._client.on('connect', this._handleConnect.bind(this))
225
228
  this._client.on('reconnect', this._handleReconnect.bind(this))
226
229
  this._client.on('error', this._handleError.bind(this))
@@ -243,7 +246,15 @@ class VrpcAgent extends EventEmitter {
243
246
  this._subscribeRetrier.cancel()
244
247
  try {
245
248
  if (!this._client || !this._client.connected) {
246
- this.emit('end')
249
+ // a client that is still trying (refused, broker away) stops
250
+ // trying: end() is how a refusal becomes final
251
+ // (its 'end' is forwarded as the agent's)
252
+ if (this._client) {
253
+ const client = this._client
254
+ await new Promise(resolve => client.end(true, {}, resolve))
255
+ } else {
256
+ this.emit('end')
257
+ }
247
258
  return
248
259
  }
249
260
  const agentTopic = `${this._baseTopic}/__agentInfo__`
@@ -40,9 +40,14 @@ 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')
43
44
 
44
45
  const VRPC_PROTOCOL_VERSION = 3
45
46
 
47
+ // mqtt clients this client ended on purpose: the 'client disconnecting'
48
+ // workaround must never revive one of them
49
+ const ENDED = new WeakSet()
50
+
46
51
  /**
47
52
  * Client capable of creating proxy objects and remotely calling
48
53
  * functions as provided through one or more (distributed) agents.
@@ -220,7 +225,19 @@ class VrpcClient extends EventEmitter {
220
225
  /**
221
226
  * Actually connects to the MQTT broker.
222
227
  *
228
+ * A refused connection is retried with a growing wait (doubling up to
229
+ * 30 s, back to the start once connected), and every refusal is emitted
230
+ * as 'error', so a caller can renew its credentials (see
231
+ * updateCredentials).
232
+ *
223
233
  * @emits connected
234
+ * @param {Object} [options]
235
+ * @param {Boolean} [options.keepTrying=false] If true, a connection that
236
+ * is not made within [timeout] still rejects the returned promise, but
237
+ * keeps trying until end() and emits 'connect' once it stands, and a
238
+ * further connect({ keepTrying: true }) while it is trying waits for it
239
+ * instead of opening a second connection. If false (the default), nothing
240
+ * changes: the client gives up at the timeout and must be connected again.
224
241
  * @returns {Promise} Resolves once connected within [timeout], rejects otherwise
225
242
  * @example
226
243
  * try {
@@ -229,14 +246,14 @@ class VrpcClient extends EventEmitter {
229
246
  * console.error(`Could not connect because: ${err.message}`)
230
247
  * }
231
248
  */
232
- async connect () {
249
+ async connect ({ keepTrying = false } = {}) {
233
250
  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
251
+ // keepTrying: a connection that is still trying is waited for, never
252
+ // doubled (without it, connect() behaves as it always did)
253
+ if (keepTrying && this._client && !ENDED.has(this._client)) {
254
+ return this._awaitConnect(this._client, { keepTrying })
239
255
  }
256
+ const { username, password } = this._credentials()
240
257
  const options = {
241
258
  username,
242
259
  password,
@@ -264,6 +281,7 @@ class VrpcClient extends EventEmitter {
264
281
  })
265
282
 
266
283
  this._client = mqtt.connect(this._broker, options)
284
+ this._backoff = new ReconnectBackoff(this._client)
267
285
 
268
286
  // Persistent lifecycle handlers
269
287
  this._client.on('offline', () => {
@@ -393,25 +411,78 @@ class VrpcClient extends EventEmitter {
393
411
  }
394
412
  })
395
413
 
414
+ return this._awaitConnect(this._client, { keepTrying })
415
+ }
416
+
417
+ _awaitConnect (client, { keepTrying }) {
396
418
  return new Promise((resolve, reject) => {
397
419
  const timeout = setTimeout(() => {
398
420
  // This will run only if the 'connect' event isn't fired in time.
421
+ const error = new Error(
422
+ `Connection trial timed out (> ${this._timeout} ms)`
423
+ )
424
+ if (keepTrying) {
425
+ // the client keeps trying in the background, until end()
426
+ client.removeListener('connect', onConnect)
427
+ reject(error)
428
+ return
429
+ }
399
430
  // 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
- })
431
+ VrpcClient._endMqtt(client, true, {}, () => reject(error))
405
432
  }, this._timeout)
406
433
 
407
- this._client.once('connect', () => {
434
+ const onConnect = () => {
408
435
  // If we connect successfully, clear the timeout and resolve the promise.
409
436
  clearTimeout(timeout)
410
437
  resolve()
411
- })
438
+ }
439
+ client.once('connect', onConnect)
412
440
  })
413
441
  }
414
442
 
443
+ /**
444
+ * Replaces the credentials the connection logs in with
445
+ *
446
+ * The broker checks credentials when a connection is made: a live
447
+ * connection carries on undisturbed, and the next one - after a network
448
+ * loss, a broker restart - logs in with these. A connection that is
449
+ * being refused right now tries again with them within a second,
450
+ * instead of waiting out its back-off.
451
+ *
452
+ * @param {Object} credentials Only the given fields change
453
+ * @param {String} [credentials.username] MQTT username
454
+ * @param {String} [credentials.password] MQTT password (if no token is used)
455
+ * @param {String} [credentials.token] Access token
456
+ */
457
+ updateCredentials ({ username, password, token } = {}) {
458
+ if (username !== undefined) this._username = username
459
+ if (password !== undefined) this._password = password
460
+ if (token !== undefined) this._token = token
461
+ if (!this._client || ENDED.has(this._client)) return
462
+ const credentials = this._credentials()
463
+ for (const key of ['username', 'password']) {
464
+ if (credentials[key] === undefined) delete this._client.options[key]
465
+ else this._client.options[key] = credentials[key]
466
+ }
467
+ this._backoff.hurry()
468
+ }
469
+
470
+ // what the broker sees: a token rides as the password
471
+ _credentials () {
472
+ let username = this._username
473
+ let password = this._password
474
+ if (this._token) {
475
+ username = username || this._generateUserName()
476
+ password = this._token
477
+ }
478
+ return { username, password }
479
+ }
480
+
481
+ static _endMqtt (client, force, options, callback) {
482
+ ENDED.add(client)
483
+ client.end(force, options, callback)
484
+ }
485
+
415
486
  /**
416
487
  * Creates a new remote instance and provides a proxy to it.
417
488
  *
@@ -815,7 +886,10 @@ class VrpcClient extends EventEmitter {
815
886
  this._token = token
816
887
  this._agent = agent
817
888
  if (this._client) {
818
- await new Promise(resolve => this._client.end(false, {}, resolve))
889
+ const client = this._client
890
+ await new Promise(resolve =>
891
+ VrpcClient._endMqtt(client, false, {}, resolve)
892
+ )
819
893
  }
820
894
  return this.connect()
821
895
  }
@@ -862,7 +936,9 @@ class VrpcClient extends EventEmitter {
862
936
  this.removeAllListeners()
863
937
  const client = this._client
864
938
  this._client = null // Prevent race conditions during shutdown
865
- return new Promise(resolve => client.end(false, {}, resolve))
939
+ return new Promise(resolve =>
940
+ VrpcClient._endMqtt(client, false, {}, resolve)
941
+ )
866
942
  }
867
943
 
868
944
  // Private functions
@@ -929,8 +1005,16 @@ class VrpcClient extends EventEmitter {
929
1005
  return `${this._domain}/${this._mqttClientId}/${this._connectionSecret}`
930
1006
  }
931
1007
 
1008
+ // The 'client disconnecting' workaround below revives a client mqtt.js
1009
+ // left half-closed, never one that was ended on purpose (a connect
1010
+ // timeout, end(), reconnectWithToken) or has been replaced since
1011
+ _isRevivable (client) {
1012
+ return client === this._client && !ENDED.has(client)
1013
+ }
1014
+
932
1015
  _mqttPublish (topic, message, options) {
933
- this._client.publish(
1016
+ const client = this._client
1017
+ client.publish(
934
1018
  topic,
935
1019
  message,
936
1020
  { qos: this._qos, ...options },
@@ -939,13 +1023,16 @@ class VrpcClient extends EventEmitter {
939
1023
  this._log.warn(
940
1024
  `Could not publish MQTT message because: ${err.message}`
941
1025
  )
942
- if (err.message === 'client disconnecting') {
1026
+ if (
1027
+ err.message === 'client disconnecting' &&
1028
+ this._isRevivable(client)
1029
+ ) {
943
1030
  // This most certainly relates to a bug in MQTT.js (#1284)
944
1031
  this._log.error(`Failed to publish, because: ${err.message}`)
945
1032
  this.emit('error', err)
946
1033
  this._log.info('Forcing reconnect now...')
947
- this._client.disconnecting = false
948
- this._client.reconnect()
1034
+ client.disconnecting = false
1035
+ client.reconnect()
949
1036
  }
950
1037
  }
951
1038
  }
@@ -953,8 +1040,9 @@ class VrpcClient extends EventEmitter {
953
1040
  }
954
1041
 
955
1042
  async _mqttSubscribe (topic, options) {
1043
+ const client = this._client
956
1044
  return new Promise((resolve, reject) => {
957
- this._client.subscribe(
1045
+ client.subscribe(
958
1046
  topic,
959
1047
  { qos: this._qos, ...options },
960
1048
  (err, granted) => {
@@ -962,15 +1050,20 @@ class VrpcClient extends EventEmitter {
962
1050
  this._log.warn(
963
1051
  `Could not subscribe to topic(s): '${topic}', because: ${err.message}`
964
1052
  )
965
- if (err.message === 'client disconnecting') {
1053
+ if (
1054
+ err.message === 'client disconnecting' &&
1055
+ this._isRevivable(client)
1056
+ ) {
966
1057
  // This most certainly relates to a bug in MQTT.js (#1284)
967
1058
  const msg = `Failed to subscribe, because: ${err.message}`
968
1059
  this._log.error(msg)
969
1060
  this.emit('error', err)
970
1061
  this._log.info('Forcing reconnect now...')
971
- this._client.disconnecting = false
972
- this._client.reconnect()
1062
+ client.disconnecting = false
1063
+ client.reconnect()
973
1064
  reject(new Error(msg))
1065
+ } else if (err.message === 'client disconnecting') {
1066
+ reject(err)
974
1067
  }
975
1068
  } else {
976
1069
  const topicArray = Array.isArray(topic) ? topic : [topic]