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/browser/vrpc.js +5 -5
- package/package.json +4 -2
- package/vrpc/ReconnectBackoff.js +79 -0
- package/vrpc/VrpcAgent.js +12 -1
- package/vrpc/VrpcClient.js +116 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vrpc",
|
|
3
|
-
"version": "3.
|
|
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
|
-
|
|
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__`
|
package/vrpc/VrpcClient.js
CHANGED
|
@@ -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
|
-
|
|
235
|
-
|
|
236
|
-
if (this.
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
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 (
|
|
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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
972
|
-
|
|
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]
|