vrpc 3.1.1 → 3.2.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.
@@ -0,0 +1,675 @@
1
+ const os = require('os')
2
+ const mqtt = require('mqtt')
3
+ const crypto = require('crypto')
4
+ const VrpcAdapter = require('./VrpcBrowserAdapter')
5
+ const EventEmitter = require('events')
6
+ const { nanoid } = require('nanoid')
7
+
8
+ const VRPC_PROTOCOL_VERSION = 3
9
+
10
+ /**
11
+ * Agent capable of making existing code available to remote control by clients.
12
+ *
13
+ * @extends EventEmitter
14
+ */
15
+ class VrpcAgent extends EventEmitter {
16
+
17
+ /**
18
+ * Constructs an agent instance
19
+ *
20
+ * @constructor
21
+ * @param {Object} obj
22
+ * @param {String} [obj.username] MQTT username
23
+ * @param {String} [obj.password] MQTT password (if no token is provided)
24
+ * @param {String} [obj.token] Access token
25
+ * @param {String} [obj.domain='vrpc'] The domain under which the agent-provided code is reachable
26
+ * @param {String} [obj.agent='<user>-<pathId>@<hostname>-<platform>-js'] This agent's name
27
+ * @param {String} [obj.broker='mqtts://vrpc.io:8883'] Broker url in form: `<scheme>://<host>:<port>`
28
+ * @param {Object} [obj.log=console] Log object (must support debug, info, warn, and error level)
29
+ * @param {String} [obj.bestEffort=false] If true, message will be sent with best effort, i.e. no caching if offline
30
+ * @param {String} [obj.version=''] The (user-defined) version of this agent
31
+ * @param {String} [obj.mqttClientId='<generated()>'] Explicitly set the mqtt client id.
32
+ *
33
+ * @example
34
+ * const agent = new Agent({
35
+ * domain: 'vrpc'
36
+ * agent: 'myAgent'
37
+ * })
38
+ */
39
+ constructor ({
40
+ username,
41
+ password,
42
+ token,
43
+ domain = 'vrpc',
44
+ agent = nanoid(8),
45
+ broker = 'mqtts://vrpc.io:8883',
46
+ log = 'console',
47
+ bestEffort = false,
48
+ version = '',
49
+ mqttClientId = null
50
+ } = {}) {
51
+ super()
52
+ this._validateDomain(domain)
53
+ this._validateAgent(agent)
54
+ this._username = username
55
+ this._password = password
56
+ this._token = token
57
+ this._agent = agent
58
+ this._domain = domain
59
+ this._broker = broker
60
+ this._qos = bestEffort ? 0 : 1
61
+ this._version = version
62
+ this._mqttClientId =
63
+ mqttClientId ||
64
+ `va3${crypto
65
+ .createHash('md5')
66
+ .update(this._domain + this._agent)
67
+ .digest('hex')
68
+ .substring(0, 20)}`
69
+ if (log === 'console') {
70
+ this._log = console
71
+ this._log.debug = () => {}
72
+ } else {
73
+ this._log = log
74
+ }
75
+ this._baseTopic = `${this._domain}/${this._agent}`
76
+ VrpcAdapter.onCallback(this._handleVrpcCallback.bind(this))
77
+ // maps clientId to instanceId
78
+ this._isolatedInstances = new Map()
79
+ this._sharedInstances = new Map()
80
+
81
+ // Handle the internal error event in case the user forgot to implement it
82
+ this.on('error', err => {
83
+ this._log.debug(`Encountered an error: ${err.message}`)
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Starts the agent
89
+ *
90
+ * The returned promise will only resolve once the agent is connected to the
91
+ * broker. If the connection can't be established it will try connecting
92
+ * forever. You may want to listen to the 'offline' (initial connect attempt
93
+ * failed) or 'reconnect' (any further fail) event and call `agent.end()` to
94
+ * stop trying to connect and resolve the returned promise.
95
+ *
96
+ * If the connection could not be established because of authorization
97
+ * failure, the 'error' event will be emitted.
98
+ *
99
+ * @return {Promise} Resolves once connected or explicitly ended, never
100
+ * rejects
101
+ */
102
+ async serve () {
103
+ let username = this._username
104
+ let password = this._password
105
+ if (this._token) {
106
+ username = `${this._domain}/${this._agent}`
107
+ password = this._token
108
+ } else if (!this._password) {
109
+ username = `${this._domain}/${this._agent}`
110
+ password = nanoid()
111
+ }
112
+ this._options = {
113
+ username,
114
+ password,
115
+ keepalive: 30,
116
+ connectTimeout: 10 * 1000,
117
+ clientId: this._mqttClientId,
118
+ rejectUnauthorized: false,
119
+ will: {
120
+ topic: `${this._baseTopic}/__agentInfo__`,
121
+ payload: this._createAgentInfoPayload({ status: 'offline' }),
122
+ qos: this._qos,
123
+ retain: true
124
+ }
125
+ }
126
+ this._log.info(`Domain : ${this._domain}`)
127
+ this._log.info(`Agent : ${this._agent}`)
128
+ this._log.info(`Broker : ${this._broker}`)
129
+ this._log.info('Connecting to MQTT server...')
130
+ this._client = mqtt.connect(this._broker, this._options)
131
+ this._client.on('connect', this._handleConnect.bind(this))
132
+ this._client.on('reconnect', this._handleReconnect.bind(this))
133
+ this._client.on('error', this._handleError.bind(this))
134
+ this._client.on('message', this._handleMessage.bind(this))
135
+ this._client.on('close', this._handleClose.bind(this))
136
+ this._client.on('offline', this._handleOffline.bind(this))
137
+ this._client.on('end', this._handleEnd.bind(this))
138
+ return this._ensureConnected()
139
+ }
140
+
141
+ /**
142
+ * Stops the agent
143
+ *
144
+ * @param {Object} [obj]
145
+ * @param {Boolean} [unregister=false] If true, fully un-registers agent from broker
146
+ * @returns {Promise} Resolves when disconnected and ended
147
+ */
148
+ async end ({ unregister = false } = {}) {
149
+ try {
150
+ if (!this._client || !this._client.connected) {
151
+ this.emit('end')
152
+ return
153
+ }
154
+ const agentTopic = `${this._baseTopic}/__agentInfo__`
155
+ this._mqttPublish(
156
+ agentTopic,
157
+ this._createAgentInfoPayload({ status: 'offline' }),
158
+ { retain: true }
159
+ )
160
+ if (unregister) {
161
+ this._mqttPublish(agentTopic, null, { retain: true })
162
+ const classes = this._getClasses()
163
+ for (const className of classes) {
164
+ const infoTopic = `${this._baseTopic}/${className}/__classInfo__`
165
+ this._mqttPublish(infoTopic, null, { retain: true })
166
+ }
167
+ }
168
+ await new Promise(resolve => this._client.end(false, {}, resolve))
169
+ } catch (err) {
170
+ this._log.error(err, `Problem during disconnecting agent: ${err.message}`)
171
+ }
172
+ }
173
+
174
+ _validateDomain (domain) {
175
+ if (!domain) throw new Error('The domain must be specified')
176
+ if (domain.match(/[+/#*]/)) {
177
+ throw new Error(
178
+ 'The domain must NOT contain any of those characters: "+", "/", "#", "*"'
179
+ )
180
+ }
181
+ }
182
+
183
+ _validateAgent (agent) {
184
+ if (!agent) throw new Error('The agent must be specified')
185
+ if (agent.match(/[+/#*]/)) {
186
+ throw new Error(
187
+ 'The agent must NOT contain any of those characters: "+", "/", "#", "*"'
188
+ )
189
+ }
190
+ }
191
+
192
+ _createAgentInfoPayload ({ status }) {
193
+ return JSON.stringify({
194
+ status,
195
+ hostname: os.hostname(),
196
+ version: this._version,
197
+ v: VRPC_PROTOCOL_VERSION
198
+ })
199
+ }
200
+
201
+ _mqttPublish (topic, message, options) {
202
+ this._client.publish(
203
+ topic,
204
+ message,
205
+ { qos: this._qos, ...options },
206
+ err => {
207
+ if (err) {
208
+ this._log.warn(
209
+ `Could not publish MQTT message because: ${err.message}`
210
+ )
211
+ }
212
+ }
213
+ )
214
+ }
215
+
216
+ _mqttSubscribe (topic, options) {
217
+ this._client.subscribe(
218
+ topic,
219
+ { qos: this._qos, ...options },
220
+ (err, granted) => {
221
+ if (err) {
222
+ this._log.warn(
223
+ `Could not subscribe to topic(s) '${topic}', because: ${err.message}`
224
+ )
225
+ } else {
226
+ const topicArray = Array.isArray(topic) ? topic : [topic]
227
+ const erroneousGranted = granted
228
+ .filter(x => x.qos === 128)
229
+ .map(x => x.topic)
230
+ if (erroneousGranted.length > 0) {
231
+ err = new Error(
232
+ `Could not subscribe all ${topicArray.length} topic(s) but got error qos=128 on following ${erroneousGranted.length} topic(s): ${erroneousGranted}`
233
+ )
234
+ err.code = 'SUBSCRIBE_FAILED'
235
+ err.subscribeOptions = options
236
+ this._log.error(err)
237
+ this.emit('error', err)
238
+ }
239
+ const reducedQos = granted.filter(x => x.qos < this._qos)
240
+ if (reducedQos.length > 0) {
241
+ err = new Error(
242
+ `Could not subscribe all ${
243
+ topicArray.length
244
+ } topic(s) at desired qos=${
245
+ this._qos
246
+ } but got reduced qos on following ${
247
+ reducedQos.length
248
+ } topic(s): ${JSON.stringify(reducedQos)}`
249
+ )
250
+ err.code = 'SUBSCRIBE_REDUCED_QOS'
251
+ err.subscribeOptions = options
252
+ this._log.warn(err)
253
+ this.emit('error', err)
254
+ }
255
+ if (granted.length === 0) {
256
+ this._log.debug(`Already subscribed to topic '${topic}'`)
257
+ }
258
+ }
259
+ }
260
+ )
261
+ }
262
+
263
+ _mqttUnsubscribe (topic, options) {
264
+ this._client.unsubscribe(topic, options, err => {
265
+ if (err) {
266
+ this._log.warn(
267
+ `Could not unsubscribe from topic: ${topic} because: ${err.message}`
268
+ )
269
+ }
270
+ })
271
+ }
272
+
273
+ _getClasses () {
274
+ return VrpcAdapter._getClassesArray()
275
+ }
276
+
277
+ _getInstances (className) {
278
+ return VrpcAdapter._getInstancesArray(className)
279
+ }
280
+
281
+ _getMemberFunctions (className) {
282
+ return VrpcAdapter._getMemberFunctionsArray(className)
283
+ }
284
+
285
+ _getStaticFunctions (className) {
286
+ return VrpcAdapter._getStaticFunctionsArray(className)
287
+ }
288
+
289
+ _getMetaData (className) {
290
+ return VrpcAdapter._getMetaData(className)
291
+ }
292
+
293
+ async _ensureConnected () {
294
+ return new Promise(resolve => {
295
+ if (this._client.connected) {
296
+ resolve()
297
+ } else {
298
+ this._client.once('connect', resolve)
299
+ // Will be triggered if the user called 'agent.end()'
300
+ this.once('end', resolve)
301
+ }
302
+ })
303
+ }
304
+
305
+ _handleVrpcCallback (json) {
306
+ const { s, i, r, e, a } = json
307
+ const topic = i.startsWith('__e__') ? i.slice(5) : s
308
+ try {
309
+ this._log.debug(`Forwarding callback to: ${topic} with payload:`, json)
310
+ this._mqttPublish(
311
+ topic,
312
+ VrpcAgent._stringifySafely({ a, r, e, i, v: VRPC_PROTOCOL_VERSION })
313
+ )
314
+ } catch (err) {
315
+ this._log.warn(
316
+ err,
317
+ `Problem publishing vrpc callback to ${topic} because of: ${err.message}`
318
+ )
319
+ }
320
+ }
321
+
322
+ static _stringifySafely (json) {
323
+ let str
324
+ const { c, f } = json
325
+ try {
326
+ str = JSON.stringify(json)
327
+ } catch (err) {
328
+ this._log.debug(
329
+ `Failed serialization of return value for: ${c}::${f}, because: ${err.message}`
330
+ )
331
+ json.r = '__vrpc::not-serializable__'
332
+ str = JSON.stringify(json)
333
+ }
334
+ return str
335
+ }
336
+
337
+ _handleConnect () {
338
+ this._log.info('[OK]')
339
+ try {
340
+ const topics = this._generateTopics()
341
+ if (topics.length > 0) this._mqttSubscribe(topics)
342
+ } catch (err) {
343
+ this._log.error(
344
+ err,
345
+ `Problem during initial topic subscription: ${err.message}`
346
+ )
347
+ }
348
+ // Publish agent online
349
+ this._publishAgentInfoMessage()
350
+
351
+ // Publish class information
352
+ const classes = this._getClasses()
353
+ classes.forEach(className => {
354
+ this._publishClassInfoMessage(className)
355
+ })
356
+ // Register all pre-existing instances
357
+ for (const [
358
+ instanceId,
359
+ { className }
360
+ ] of VrpcAdapter._instances.entries()) {
361
+ this._subscribeToMethodsOfNewInstance(className, instanceId)
362
+ }
363
+ this.emit('connect')
364
+ }
365
+
366
+ _publishAgentInfoMessage () {
367
+ this._mqttPublish(
368
+ `${this._baseTopic}/__agentInfo__`,
369
+ this._createAgentInfoPayload({ status: 'online' }),
370
+ { retain: true }
371
+ )
372
+ }
373
+
374
+ _publishClassInfoMessage (className) {
375
+ const json = {
376
+ className: className,
377
+ instances: this._getInstances(className),
378
+ memberFunctions: this._getMemberFunctions(className),
379
+ staticFunctions: this._getStaticFunctions(className),
380
+ meta: this._getMetaData(className),
381
+ v: VRPC_PROTOCOL_VERSION
382
+ }
383
+ try {
384
+ this._mqttPublish(
385
+ `${this._baseTopic}/${className}/__classInfo__`,
386
+ JSON.stringify(json),
387
+ { retain: true }
388
+ )
389
+ } catch (err) {
390
+ this._log.error(
391
+ err,
392
+ `Problem during publishing class info: ${err.message}`
393
+ )
394
+ }
395
+ }
396
+
397
+ _generateTopics () {
398
+ const topics = []
399
+ const classes = this._getClasses()
400
+ if (classes.length > 0) {
401
+ this._log.info(`Registering classes: ${classes.join(', ')}`)
402
+ } else {
403
+ this._log.warn('No classes are registered')
404
+ }
405
+ classes.forEach(className => {
406
+ const staticFunctions = this._getStaticFunctions(className)
407
+ staticFunctions.forEach(func => {
408
+ topics.push(`${this._baseTopic}/${className}/__static__/${func}`)
409
+ })
410
+ })
411
+ return topics
412
+ }
413
+
414
+ _handleMessage (topic, data) {
415
+ try {
416
+ const json = JSON.parse(data.toString())
417
+ this._log.debug(`Message arrived with topic: ${topic} and payload:`, json)
418
+ const tokens = topic.split('/')
419
+ const [, , className, instance, method] = tokens
420
+
421
+ // Special case: clientInfo message
422
+ if (tokens.length === 4 && tokens[3] === '__clientInfo__') {
423
+ this._handleClientInfoMessage(topic, json)
424
+ return
425
+ }
426
+
427
+ // Anything else must follow RPC protocol
428
+ if (tokens.length !== 5) {
429
+ this._log.warn(`Ignoring message with invalid topic: ${topic}`)
430
+ return
431
+ }
432
+
433
+ // Prepare RPC json
434
+ // SECURITY: take as much as possible params from topic structure as this
435
+ // can be authorized by the broker. Content can't be checked...
436
+ json.c = instance === '__static__' ? className : instance
437
+ json.f = method
438
+
439
+ // Mutates json and adds return value
440
+ const mustTrack = VrpcAdapter._call(json)
441
+
442
+ if (mustTrack) {
443
+ this._mqttSubscribe(`${json.s}/__clientInfo__`)
444
+ }
445
+
446
+ // Intersecting life-cycle functions
447
+ switch (method) {
448
+ case '__createIsolated__': {
449
+ // TODO handle instantiation errors
450
+ const instanceId = json.r
451
+ // TODO await this
452
+ this._subscribeToMethodsOfNewInstance(className, instanceId)
453
+ this._registerIsolatedInstance(instanceId, json.s)
454
+ break
455
+ }
456
+ case '__createShared__': {
457
+ // TODO handle instantiation errors
458
+ const instanceId = json.r
459
+ if (!this._hasSharedInstance(instanceId)) {
460
+ this._subscribeToMethodsOfNewInstance(className, instanceId)
461
+ this._publishClassInfoMessage(className)
462
+ }
463
+ this._registerSharedInstance(instanceId, json.s)
464
+ break
465
+ }
466
+ case '__delete__': {
467
+ this._unsubscribeMethodsOfDeletedInstance(className, instance)
468
+ const wasShared = this._unregisterInstance(json.a[0], json.s)
469
+ if (wasShared) this._publishClassInfoMessage(className)
470
+ break
471
+ }
472
+ }
473
+ const { a, r, e, i, v } = json
474
+ const res = e ? { a, r, e, i, v } : { a, r, i, v }
475
+ this._mqttPublish(json.s, VrpcAgent._stringifySafely(res))
476
+ } catch (err) {
477
+ this._log.error(
478
+ err,
479
+ `Problem while handling incoming message: ${err.message}`
480
+ )
481
+ }
482
+ }
483
+
484
+ _handleClientInfoMessage (topic, json) {
485
+ // Client went offline
486
+ const clientId = topic.slice(0, -15) // /__clientInfo__ = 15
487
+ if (json.status === 'offline') {
488
+ VrpcAdapter._unregisterClient(clientId)
489
+ const entry = this._isolatedInstances.get(clientId)
490
+ if (entry) {
491
+ entry.forEach(instanceId => {
492
+ const json = { f: '__delete__', a: [instanceId], r: null }
493
+ VrpcAdapter._call(json)
494
+ if (json.r) {
495
+ this._log.debug(`Auto-deleted isolated instance: ${instanceId}`)
496
+ }
497
+ })
498
+ }
499
+ this._mqttUnsubscribe(`${clientId}/__clientInfo__`)
500
+ this.emit('clientGone', clientId)
501
+ }
502
+ }
503
+
504
+ _registerIsolatedInstance (instanceId, clientId) {
505
+ const entry = this._isolatedInstances.get(clientId)
506
+ if (entry) {
507
+ // already seen
508
+ entry.add(instanceId)
509
+ } else {
510
+ // new instance
511
+ this._isolatedInstances.set(clientId, new Set([instanceId]))
512
+ if (!this._sharedInstances.has(clientId)) {
513
+ this._mqttSubscribe(`${clientId}/__clientInfo__`)
514
+ }
515
+ this._log.info(`Tracking lifetime of client: ${clientId}`)
516
+ }
517
+ }
518
+
519
+ _registerSharedInstance (instanceId, clientId) {
520
+ const entry = this._sharedInstances.get(clientId)
521
+ if (entry) {
522
+ // already seen
523
+ entry.add(instanceId)
524
+ } else {
525
+ // new instance
526
+ this._sharedInstances.set(clientId, new Set([instanceId]))
527
+ if (!this._isolatedInstances.has(clientId)) {
528
+ this._mqttSubscribe(`${clientId}/__clientInfo__`)
529
+ }
530
+ this._log.debug(`Tracking lifetime of client: ${clientId}`)
531
+ }
532
+ }
533
+
534
+ _hasSharedInstance (instanceId) {
535
+ for (const [, instances] of this._sharedInstances) {
536
+ if (instances.has(instanceId)) return true
537
+ }
538
+ return false
539
+ }
540
+
541
+ _unregisterInstance (instanceId, clientId) {
542
+ const entryIsolated = this._isolatedInstances.get(clientId)
543
+ if (entryIsolated && entryIsolated.has(instanceId)) {
544
+ entryIsolated.delete(instanceId)
545
+ if (entryIsolated.length === 0) {
546
+ this._isolatedInstances.delete(clientId)
547
+ this._mqttUnsubscribe(`${clientId}/__clientInfo__`)
548
+ this._log.debug(`Stopped tracking lifetime of client: ${clientId}`)
549
+ }
550
+ return false
551
+ }
552
+ let found = false
553
+ this._sharedInstances.forEach(async v => {
554
+ if (v.has(instanceId)) {
555
+ found = true
556
+ v.delete(instanceId)
557
+ if (v.length === 0) {
558
+ this._sharedInstances.delete(clientId)
559
+ this._mqttUnsubscribe(`${clientId}/__clientInfo__`)
560
+ this._log.debug(`Stopped tracking lifetime of client: ${clientId}`)
561
+ }
562
+ }
563
+ })
564
+ if (!found) {
565
+ this._log.warn(
566
+ `Failed un-registering not registered instance: ${instanceId}`
567
+ )
568
+ return false
569
+ }
570
+ return true
571
+ }
572
+
573
+ _subscribeToMethodsOfNewInstance (className, instance) {
574
+ const topic = `${this._baseTopic}/${className}/${instance}/+`
575
+ this._mqttSubscribe(topic)
576
+ this._log.debug(`Subscribed to new topic after instantiation: ${topic}`)
577
+ }
578
+
579
+ _unsubscribeMethodsOfDeletedInstance (className, instance) {
580
+ const topic = `${this._baseTopic}/${className}/${instance}/+`
581
+ this._mqttUnsubscribe(topic)
582
+ this._log.debug(`Unsubscribed from topic after deletion: ${topic}`)
583
+ }
584
+
585
+ _handleReconnect () {
586
+ this._log.warn(`Reconnecting to ${this._broker}`)
587
+ this.emit('reconnect')
588
+ }
589
+
590
+ _handleError (err) {
591
+ this.emit('error', err)
592
+ }
593
+
594
+ _handleClose () {
595
+ this.emit('close')
596
+ }
597
+
598
+ _handleOffline () {
599
+ this.emit('offline')
600
+ }
601
+
602
+ _handleEnd () {
603
+ this.emit('end')
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Event 'connect'
609
+ *
610
+ * Emitted on successful (re)connection (i.e. connack rc=0).
611
+ *
612
+ * @event VrpcAgent#connect
613
+ * @type {Object}
614
+ * @property {Boolean} sessionPresent - A session from a previous connection is already present
615
+ */
616
+
617
+ /**
618
+ * Event 'reconnect'
619
+ *
620
+ * Emitted when a reconnect starts.
621
+ *
622
+ * @event VrpcAgent#reconnect
623
+ */
624
+
625
+ /**
626
+ * Event 'close'
627
+ *
628
+ * Emitted after a disconnection.
629
+ *
630
+ * @event VrpcAgent#close
631
+ */
632
+
633
+ /**
634
+ * Event 'offline'
635
+ *
636
+ * Emitted when the client goes offline.
637
+ *
638
+ * @event VrpcAgent#offline
639
+ */
640
+
641
+ /**
642
+ * Event 'error'
643
+ *
644
+ * Emitted when the client cannot connect (i.e. connack rc != 0) or when a
645
+ * parsing error occurs. The following TLS errors will be emitted as an error
646
+ * event:
647
+ *
648
+ * - ECONNREFUSED
649
+ * - ECONNRESET
650
+ * - EADDRINUSE
651
+ * - ENOTFOUND
652
+ *
653
+ * @event VrpcAgent#error
654
+ * @type {Object} Error
655
+ */
656
+
657
+ /**
658
+ * Event 'end'
659
+ *
660
+ * Emitted when mqtt.Client#end() is called. If a callback was passed to
661
+ * mqtt.Client#end(), this event is emitted once the callback returns.
662
+ *
663
+ * @event VrpcAgent#end
664
+ */
665
+
666
+ /**
667
+ * Event 'clientGone'
668
+ *
669
+ * Emitted when a tracked VRPC client exited.
670
+ *
671
+ * @event VrpcAgent#clientGone
672
+ * @type {String} clientId
673
+ */
674
+
675
+ module.exports = VrpcAgent