@flowfuse/nr-project-nodes 0.4.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,936 @@
1
+ module.exports = function (RED) {
2
+ 'use strict'
3
+
4
+ // Do not register nodes in runtime if settings not provided
5
+ if (!RED.settings.flowforge || !RED.settings.flowforge.projectID || !RED.settings.flowforge.teamID || !RED.settings.flowforge.projectLink) {
6
+ throw new Error('Project Link nodes cannot be loaded outside of an FlowFuse EE environment')
7
+ }
8
+
9
+ // Imports
10
+ const crypto = require('crypto')
11
+ const got = require('got')
12
+ const MQTT = require('mqtt')
13
+ const urlModule = require('url')
14
+
15
+ // Constants
16
+ const API_VERSION = 'v1'
17
+ const TOPIC_HEADER = 'ff'
18
+ const TOPIC_VERSION = 'v1'
19
+
20
+ // #region JSDoc
21
+
22
+ /**
23
+ * An event generated when a link call is executed
24
+ * @typedef {object} MessageEvent
25
+ * @property {string} eventId
26
+ * @property {string} node
27
+ * @property {string} project
28
+ * @property {string} topic
29
+ * @property {number} ts
30
+ */
31
+
32
+ /**
33
+ * An array of messageEvent for processing link calls
34
+ * @typedef {Object.<string, MessageEvent>} MessageEvents
35
+ */
36
+
37
+ // #endregion JSDoc
38
+
39
+ // #region Helpers
40
+
41
+ /**
42
+ * Opinionated test to check topic is valid for subscription...
43
+ * * May start with $share/<group>/
44
+ * * Must not contain `<space>` `+` `#` `$` `\` `\b` `\f` `\n` `\r` `\t` `\v`
45
+ * * Permits `+` character at index 4 (project name)
46
+ * * Must have at least 1 character between slashes
47
+ * * Must not start or end with a slash
48
+ * @param {string} topic
49
+ * @returns `true` if it is a valid sub topic
50
+ */
51
+ function isValidSubscriptionTopic (topic) {
52
+ return /^(?:\$share\/[^/$+#\b\f\n\r\t\v\0\s]+\/)?(?:[^/$+#\b\f\n\r\t\v\0\s]+\/){4}(?:\+|[^/$+#\b\f\n\r\t\v\0\s]+)(?:\/(?:[^/$+#\b\f\n\r\t\v\0\s]+?)){2,}$/.test(topic)
53
+ }
54
+
55
+ /**
56
+ * Opinionated test to check topic is valid for publishing...
57
+ * * Must not contain `<space>` `+` `#` `$` `\` `\b` `\f` `\n` `\r` `\t` `\v`
58
+ * * Must have at least 1 character between slashes
59
+ * * Must not start or end with a slash
60
+ * @param {string} topic
61
+ * @returns `true` if it is a valid sub topic
62
+ */
63
+ function isValidPublishTopic (topic) {
64
+ return /^(?:[^/$+#\b\f\n\r\t\v\0]+\/){4}(?:[^/$+#\b\f\n\r\t\v\0]+)(?:\/(?:[^/$+#\b\f\n\r\t\v\0]+?)){2,}$/.test(topic)
65
+ }
66
+
67
+ function jsonReplacer (_key, value) {
68
+ const wrapper = (type, data) => { return { type, data } }
69
+ if (typeof value === 'undefined') {
70
+ return wrapper('undefined', '')
71
+ } else if (typeof value === 'bigint') {
72
+ return wrapper('bigint', value.toString())
73
+ } else if (typeof value === 'function') {
74
+ return wrapper('function', value.toString())
75
+ } else if (typeof value === 'object' && value !== null) {
76
+ // NOTE: Map and Set objects that are built in a function VM do NOT
77
+ // evaluate to true when tested for instanceof Map or Set. Instead
78
+ // constructor.name and .entries/.keys properties are used to determine type
79
+ if (value instanceof Map || (value.constructor?.name === 'Map' && value.entries)) {
80
+ return wrapper('Map', [...value])
81
+ } else if (value instanceof Set || (value.constructor?.name === 'Set' && value.values)) {
82
+ return wrapper('Set', [...value])
83
+ } else if (Buffer.isBuffer(value) || (value.constructor?.name === 'Buffer')) {
84
+ return value.toJSON()
85
+ }
86
+ }
87
+ return value
88
+ }
89
+
90
+ function jsonReviver (_key, value) {
91
+ if (typeof value === 'object' && value !== null && value.data !== undefined) {
92
+ if (value.type === 'undefined') {
93
+ // return undefined //doesn't work - returning undefined delete the property
94
+ return null // side effect: undefined becomes null
95
+ } else if (value.type === 'Buffer') {
96
+ return Buffer.from(value.data)
97
+ } else if (value.type === 'bigint') {
98
+ return BigInt(value.data)
99
+ } else if (value.type === 'Map') {
100
+ return new Map(value.data)
101
+ } else if (value.type === 'Set') {
102
+ return new Set(value.data)
103
+ } else if (value.type === 'function') {
104
+ // eslint-disable-next-line no-new-func
105
+ return new Function('return ' + value.data)()
106
+ }
107
+ }
108
+ return value
109
+ }
110
+
111
+ function parseLinkTopic (topic) {
112
+ // 0 1 2 3 4 5 6..
113
+ // ff/v1/7N152GxG2p/p/23d79df8-183c-4104-aa97-8915e1897326/in/a/b pub proj→prog
114
+ // ff/v1/7N152GxG2p/p/ca65f5ed-aea0-4a10-ac9a-2086b6af6700/out/b1/b1 pub broadcast
115
+ // ff/v1/7N152GxG2p/p/23d79df8-183c-4104-aa97-8915e1897326/in/a/b sub proj→prog
116
+ // ff/v1/7N152GxG2p/p/+/out/b1/b1 sub broadcast
117
+ const topicParts = (topic || '').split('/')
118
+ const projectOrDevice = topicParts[3] ? (topicParts[3] === 'd' ? 'd' : 'p') : null
119
+ const isBroadcast = topicParts[5] ? topicParts[5] === 'out' : null
120
+ // eslint-disable-next-line no-unused-vars
121
+ const isDirectTarget = topicParts[5] ? topicParts[5] === 'in' : null
122
+ const isCallResponse = topicParts[5] ? topicParts[5].startsWith('res') : null
123
+ const result = {
124
+ topicHeader: topicParts[0],
125
+ topicVersion: topicParts[1],
126
+ teamID: topicParts[2],
127
+ type: projectOrDevice,
128
+ projectID: topicParts[4],
129
+ isBroadcast,
130
+ isCallResponse,
131
+ subTopic: topicParts.slice(6).join('/')
132
+ }
133
+ return result
134
+ }
135
+
136
+ function buildLinkTopic (node, project, subTopic, broadcast, responseTopic) {
137
+ const topicParts = [TOPIC_HEADER, TOPIC_VERSION, RED.settings.flowforge.teamID]
138
+ if (!node || node.type === 'project link call') {
139
+ topicParts.push('p')
140
+ topicParts.push(project)
141
+ if (responseTopic) {
142
+ topicParts.push(responseTopic)
143
+ } else {
144
+ topicParts.push('in')
145
+ }
146
+ } else if (node.type === 'project link in') {
147
+ topicParts.push('p')
148
+ if (broadcast && project === 'all') {
149
+ topicParts.push('+')
150
+ topicParts.push('out')
151
+ } else if (broadcast) {
152
+ topicParts.push(project)
153
+ topicParts.push('out')
154
+ } else { // self
155
+ topicParts.push(RED.settings.flowforge.projectID)
156
+ topicParts.push('in')
157
+ }
158
+ } else if (node.type === 'project link out') {
159
+ topicParts.push('p')
160
+ if (broadcast) {
161
+ topicParts.push(RED.settings.flowforge.projectID)
162
+ topicParts.push('out')
163
+ } else {
164
+ topicParts.push(project)
165
+ topicParts.push('in')
166
+ }
167
+ }
168
+ topicParts.push(subTopic)
169
+ const topic = topicParts.join('/')
170
+ return topic
171
+ }
172
+ // #endregion Helpers
173
+
174
+ // mqtt encapsulation
175
+ const mqtt = (function () {
176
+ const allNodes = new Set()
177
+ /** @type {MQTT.MqttClient} */
178
+ let client
179
+ let connected = false
180
+ let connecting = false
181
+ let closing = false
182
+
183
+ const connAck = {
184
+ /** @type {MQTT.IConnackPacket.properties} */ properties: {},
185
+ /** @type {MQTT.IConnackPacket.reasonCode} */ reasonCode: null,
186
+ /** @type {MQTT.IConnackPacket.returnCode} */ returnCode: null
187
+ }
188
+ /** @type {Map<string,Set<object>>} */
189
+ const topicCallbackMap = new Map()
190
+ // let callback_detail_map = new Map()
191
+ let clientListeners = []
192
+
193
+ /**
194
+ *
195
+ * @param {string} topic
196
+ * @param {Buffer} message
197
+ * @param {MQTT.IPublishPacket} packet
198
+ */
199
+ function onMessage (topic, message, packet) {
200
+ // console.log(`RECV ${topic}`)
201
+ const subID = packet.properties?.subscriptionIdentifier
202
+ let lookupTopic = topic
203
+ if (subID === 1) {
204
+ lookupTopic = '1:' + topic
205
+ }
206
+ const directCallbacks = topicCallbackMap.get(lookupTopic) // ff/v1/team-id/p/project-id/in/sub-topic
207
+
208
+ let broadcastCallbacks
209
+ let broadcastLookupTopic
210
+ if (subID === 2) {
211
+ const topicParts = (topic || '').split('/')
212
+ if (topicParts[5] === 'out') {
213
+ topicParts[4] = '+' // ff/v1/team-id/p/+/out/sub-topic all projects
214
+ broadcastLookupTopic = topicParts.join('/')
215
+ broadcastCallbacks = topicCallbackMap.get('2:' + broadcastLookupTopic)
216
+ }
217
+ }
218
+
219
+ if ((!directCallbacks || !directCallbacks.size) && (!broadcastCallbacks || !broadcastCallbacks.size)) {
220
+ return // no callbacks registered for this topic
221
+ }
222
+
223
+ // reconstitute the msg from the message
224
+ let err, msg
225
+ try {
226
+ msg = JSON.parse(message.toString(), jsonReviver)
227
+ msg.projectLink = {
228
+ ...msg.projectLink,
229
+ instanceId: packet.properties?.userProperties?._projectID,
230
+ projectId: packet.properties?.userProperties?._projectID,
231
+ topic: topic.split('/').slice(6).join('/')
232
+ }
233
+ if (packet.properties?.userProperties?._deviceId) {
234
+ msg.projectLink.deviceId = packet.properties?.userProperties?._deviceId
235
+ msg.projectLink.deviceName = packet.properties?.userProperties?._deviceName
236
+ msg.projectLink.deviceType = packet.properties?.userProperties?._deviceType
237
+ }
238
+ } catch (error) {
239
+ err = error
240
+ }
241
+
242
+ // call listeners
243
+ directCallbacks && directCallbacks.forEach(cb => {
244
+ cb && cb(err, topic, msg, packet)
245
+ })
246
+ broadcastCallbacks && broadcastCallbacks.forEach(cb => {
247
+ cb && cb(err, topic, msg, packet)
248
+ })
249
+ }
250
+ function onError (err) {
251
+ RED.log.trace(`Project Link nodes connection error: ${err.message}`)
252
+ allNodes.forEach(node => {
253
+ try {
254
+ node.status({ fill: 'red', shape: 'dot', text: 'error' })
255
+ } catch (err) { /* do nothing */ }
256
+ })
257
+ }
258
+ function onConnect (/** @type {MQTT.IConnackPacket} */ packet) {
259
+ connAck.properties = packet.properties
260
+ connAck.reasonCode = packet.reasonCode
261
+ connAck.returnCode = packet.returnCode
262
+ connected = true
263
+ connecting = false
264
+ closing = false
265
+ RED.log.info('Project Link nodes connected')
266
+ allNodes.forEach(node => {
267
+ try {
268
+ node.status({ fill: 'green', shape: 'dot', text: 'connected' })
269
+ } catch (error) { /* do nothing */ }
270
+ })
271
+ }
272
+ function onReconnect () {
273
+ RED.log.trace('Project Link nodes reconnecting')
274
+ allNodes.forEach(node => {
275
+ try {
276
+ node.status({ fill: 'yellow', shape: 'dot', text: 'reconnecting' })
277
+ } catch (error) { /* do nothing */ }
278
+ })
279
+ }
280
+ // Broker Disconnect - V5 event
281
+ function onDisconnect (packet) {
282
+ // Emitted after receiving disconnect packet from broker. MQTT 5.0 feature.
283
+ const rc = (packet && packet.properties && packet.reasonCode) || packet.reasonCode
284
+ const rs = (packet && packet.properties && packet.properties.reasonString) || ''
285
+ // eslint-disable-next-line no-unused-vars
286
+ const details = {
287
+ reasonCode: rc,
288
+ reasonString: rs
289
+ }
290
+ connected = false
291
+ connecting = false
292
+ closing = false
293
+ RED.log.warn('Project Link nodes disconnected')
294
+ allNodes.forEach(node => {
295
+ try {
296
+ node.status({ fill: 'red', shape: 'dot', text: 'disconnected' })
297
+ } catch (error) { /* do nothing */ }
298
+ })
299
+ }
300
+ // Register disconnect handlers
301
+ function onClose (err) {
302
+ if (err instanceof Error) {
303
+ RED.log.trace(`Project link connection closed: ${err.message}`)
304
+ allNodes.forEach(node => {
305
+ try {
306
+ node.status({ fill: 'red', shape: 'dot', text: 'error' })
307
+ } catch (error) { /* do nothing */ }
308
+ })
309
+ }
310
+ if (connected) {
311
+ connected = false
312
+ closing = false
313
+ if (err) {
314
+ return // status already updated to error above!
315
+ }
316
+ RED.log.info('Project Link nodes connection closed')
317
+ allNodes.forEach(node => {
318
+ try {
319
+ node.status({ fill: 'gray', shape: 'dot', text: 'closed' })
320
+ } catch (error) { /* do nothing */ }
321
+ })
322
+ } else if (connecting) {
323
+ connecting = false
324
+ if (err) {
325
+ return // status already updated to error above!
326
+ }
327
+ RED.log.trace('Project Link nodes connect failed')
328
+ allNodes.forEach(node => {
329
+ try {
330
+ node.status({ fill: 'red', shape: 'dot', text: 'connect failed' })
331
+ } catch (error) { /* do nothing */ }
332
+ })
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Add event handlers to the MQTT.js client and track them so that
338
+ * we do not remove any handlers that the MQTT client uses internally.
339
+ * Use `off` to remove handlers
340
+ * @param {string} event The name of the event
341
+ * @param {function} handler The handler for this event
342
+ */
343
+ const on = function (event, handler) {
344
+ clientListeners.push({ event, handler })
345
+ client.on(event, handler)
346
+ }
347
+
348
+ /**
349
+ * Remove event handlers from the MQTT.js client & only the events
350
+ * that we attached in `on`.
351
+ * * If `event` is omitted, then all events matching `handler` are removed
352
+ * * If `handler` is omitted, then all events named `event` are removed
353
+ * * If both parameters are omitted, then all events are removed
354
+ * @param {string} [event] The name of the event (optional)
355
+ * @param {function} [handler] The handler for this event (optional)
356
+ */
357
+ const off = function (event, handler) {
358
+ clientListeners = clientListeners.filter((l) => {
359
+ if (event && event !== l.event) { return true }
360
+ if (handler && handler !== l.handler) { return true }
361
+ client.removeListener(l.event, l.handler)
362
+ return false // found and removed, filter out this one
363
+ })
364
+ }
365
+ return { // public interface
366
+ subscribe (node, topic, options, callback) {
367
+ if (!isValidSubscriptionTopic(topic)) {
368
+ return Promise.reject(new Error('Invalid topic'))
369
+ }
370
+
371
+ // generate a lookup based on the subscriptionId + : + topic
372
+ let lookupTopic = topic
373
+ // Check for a shared subscription - in which case, need to strip
374
+ // off the $share/<id>/ as the received messages won't have that
375
+ // in their topic
376
+ if (lookupTopic.startsWith('$share')) {
377
+ lookupTopic = lookupTopic.split('/').slice(2).join('/')
378
+ }
379
+ const subID = [null, 1, 2][node.subscriptionIdentifier]
380
+ if (subID) {
381
+ lookupTopic = subID + ':' + lookupTopic
382
+ }
383
+
384
+ /** @type {Set} */
385
+ let callbacks = topicCallbackMap.get(lookupTopic)
386
+ const topicExists = !!callbacks
387
+ callbacks = callbacks || new Set()
388
+ topicCallbackMap.set(lookupTopic, callbacks)
389
+ callbacks.add(callback)
390
+ if (topicExists) {
391
+ return Promise.resolve()
392
+ }
393
+ /** @type {MQTT.IClientSubscribeOptions} */
394
+ const subOptions = Object.assign({}, options)
395
+ subOptions.qos = subOptions.qos == null ? 1 : subOptions.qos
396
+ subOptions.properties = Object.assign({}, options.properties)
397
+ subOptions.properties.userProperties = subOptions.properties.userProperties || {}
398
+ subOptions.properties.userProperties._projectID = RED.settings.flowforge.projectID
399
+ subOptions.properties.userProperties._nodeID = node.id
400
+ subOptions.properties.userProperties._ts = Date.now()
401
+ if (subID) {
402
+ subOptions.properties.subscriptionIdentifier = subID
403
+ }
404
+ const subscribePromise = function (topic, subOptions) {
405
+ return new Promise((resolve, reject) => {
406
+ if (!client) {
407
+ return reject(new Error('client not initialised')) // if the client is not initialised, cannot subscribe!
408
+ }
409
+ try {
410
+ // console.log(`SUB ${topic}`)
411
+ client.subscribe(topic, subOptions)
412
+ resolve(true)
413
+ } catch (error) {
414
+ reject(error)
415
+ }
416
+ })
417
+ }
418
+ return subscribePromise(topic, subOptions)
419
+ },
420
+ unsubscribe (node, topic, callback) {
421
+ // generate a lookup based on the subscriptionId + : + topic
422
+ let lookupTopic = topic
423
+ const subID = [null, 1, 2][node.subscriptionIdentifier]
424
+ if (subID) {
425
+ lookupTopic = subID + ':' + topic
426
+ }
427
+ /** @type {Set} */
428
+ const callbacks = topicCallbackMap.get(lookupTopic)
429
+ if (!callbacks) {
430
+ return Promise.resolve()
431
+ }
432
+ if (callback) {
433
+ callbacks.delete(callback) // delete 1
434
+ } else {
435
+ callbacks.clear() // delete all
436
+ }
437
+ if (callbacks.size === 0) {
438
+ topicCallbackMap.delete(lookupTopic)
439
+ } else {
440
+ return Promise.resolve() // callbacks still exist, don't unsubscribe
441
+ }
442
+ const unsubscribePromise = function (topic) {
443
+ return new Promise((resolve, reject) => {
444
+ if (!client) {
445
+ return resolve() // if the client is not initialised, there are no subscriptions!
446
+ }
447
+ try {
448
+ client.unsubscribe(topic)
449
+ resolve()
450
+ } catch (error) {
451
+ reject(error)
452
+ }
453
+ })
454
+ }
455
+ return unsubscribePromise(topic)
456
+ },
457
+ publish (node, topic, msg, options) {
458
+ options = options || {}
459
+ if (!isValidPublishTopic(topic)) {
460
+ throw new Error('Invalid topic')
461
+ }
462
+ /** @type {MQTT.IClientPublishOptions} */
463
+ const pubOptions = Object.assign({}, options)
464
+ pubOptions.qos = pubOptions.qos == null ? 1 : pubOptions.qos
465
+ pubOptions.properties = Object.assign({}, options.properties)
466
+ pubOptions.properties.userProperties = pubOptions.properties.userProperties || {}
467
+ pubOptions.properties.userProperties._projectID = RED.settings.flowforge.projectID
468
+ if (process.env.FF_DEVICE_ID) {
469
+ pubOptions.properties.userProperties._deviceId = process.env.FF_DEVICE_ID
470
+ pubOptions.properties.userProperties._deviceName = process.env.FF_DEVICE_NAME
471
+ pubOptions.properties.userProperties._deviceType = process.env.FF_DEVICE_TYPE
472
+ }
473
+ pubOptions.properties.userProperties._nodeID = node.id
474
+ pubOptions.properties.userProperties._publishTime = Date.now()
475
+ pubOptions.properties.contentType = 'application/json'
476
+ const publishPromise = function (topic, message, pubOptions) {
477
+ return new Promise((resolve, reject) => {
478
+ if (!client) {
479
+ return reject(new Error('client not initialised')) // if the client is not initialised, cannot publish!
480
+ }
481
+ try {
482
+ client.publish(topic, message, pubOptions, (err, packet) => {
483
+ if (err) {
484
+ reject(err)
485
+ } else {
486
+ resolve(packet)
487
+ }
488
+ })
489
+ } catch (error) {
490
+ reject(error)
491
+ }
492
+ })
493
+ }
494
+ const message = JSON.stringify(msg, jsonReplacer)
495
+ return publishPromise(topic, message, pubOptions)
496
+ },
497
+ connect (options) {
498
+ if (client && (connected || connecting)) {
499
+ return true
500
+ }
501
+ try {
502
+ connected = false
503
+ connecting = true
504
+ off() // close existing event handlers to be safe from duplicates (re-wired after connection issued)
505
+
506
+ /** @type {MQTT.IClientOptions} */
507
+ const defaultOptions = {
508
+ protocolVersion: 5,
509
+ reconnectPeriod: RED.settings.mqttReconnectTime || 5000,
510
+ properties: {
511
+ requestResponseInformation: true,
512
+ requestProblemInformation: true,
513
+ userProperties: {
514
+ project: RED.settings.flowforge.projectID || ''
515
+ }
516
+ }
517
+ }
518
+ options = Object.assign({}, defaultOptions, options)
519
+
520
+ // ensure keepalive is set (defaults to sub 60s to avoid timeout in load balancer)
521
+ options.keepalive = options.keepalive || 45
522
+
523
+ if (RED.settings.flowforge.projectLink.broker.clientId) {
524
+ options.clientId = RED.settings.flowforge.projectLink.broker.clientId
525
+ } else {
526
+ // If no clientId specified, use 'username' with ':n' appended.
527
+ // This ensures uniqueness between this and the launcher/device's own
528
+ // client connection.
529
+ options.clientId = RED.settings.flowforge.projectLink.broker.username + ':n'
530
+ }
531
+ if (RED.settings.flowforge.projectLink.broker.username) {
532
+ options.username = RED.settings.flowforge.projectLink.broker.username
533
+ }
534
+ if (RED.settings.flowforge.projectLink.broker.password) {
535
+ options.password = RED.settings.flowforge.projectLink.broker.password
536
+ }
537
+ connAck.properties = null
538
+ connAck.reasonCode = null
539
+ connAck.returnCode = null
540
+
541
+ connecting = true
542
+ // PROBLEM: ipv6 ws addresses cannot connect
543
+ // INFO: Calling mqtt.connect('http://[::1]:8883') fails with error ERR_INVALID_URL
544
+ // INFO: Calling mqtt.connect(new URL('http://[::1]:8883')) fails because `connect` only accepts a `string` or `url.parse` object
545
+ // INFO: Calling mqtt.connect(url.parse('http://[::1]:8883') fails because unlike new URL, url.parse drops the square brackets off hostname
546
+ // (mqtt.js disassembles and reassembles the url using hostname + port so `ws://[::1]:8883` becomes `ws://::1:8883`)
547
+ // INFO: WS src code uses `new URL` so when `mqtt.js` passes the reassembled IP `http://::1:8883`, it fails with error ERR_INVALID_URL
548
+ // SEE: https://github.com/mqttjs/MQTT.js/issues/1569
549
+ const brokerURL = RED.settings.flowforge.projectLink.broker.url || 'mqtt://localhost:1883'
550
+ // eslint-disable-next-line n/no-deprecated-api
551
+ const parsedURL = urlModule.parse(brokerURL)
552
+ const newURL = new URL(brokerURL)
553
+ parsedURL.hostname = newURL.hostname
554
+ client = MQTT.connect(parsedURL, options)
555
+ on('connect', onConnect)
556
+ on('error', onError)
557
+ on('close', onClose)
558
+ on('disconnect', onDisconnect)
559
+ on('reconnect', onReconnect)
560
+ on('message', onMessage)
561
+ return true
562
+ } catch (error) {
563
+ onClose(error)
564
+ }
565
+ },
566
+ disconnect (done) {
567
+ const closeMessage = null // FUTURE: Let broker/clients know of issue via close msg
568
+ const _callback = function (err) {
569
+ connecting = false
570
+ connected = false
571
+ closing = false
572
+ done && typeof done === 'function' && done(err)
573
+ }
574
+ if (!client) { return _callback() }
575
+
576
+ const waitEnd = (client, ms) => {
577
+ return new Promise((resolve, reject) => {
578
+ closing = true
579
+ if (!client || !connected) {
580
+ resolve()
581
+ } else {
582
+ const t = setTimeout(() => {
583
+ if (!connected) {
584
+ resolve()
585
+ } else {
586
+ // clean end() has exceeded WAIT_END, lets force end!
587
+ client && client.end(true)
588
+ reject(new Error('timeout'))
589
+ }
590
+ }, ms)
591
+ client.end(() => {
592
+ clearTimeout(t)
593
+ resolve()
594
+ })
595
+ }
596
+ })
597
+ }
598
+ if (connected && closeMessage) {
599
+ mqtt.publish(closeMessage, function (err) {
600
+ waitEnd(client, 2000).then(() => {
601
+ _callback(err)
602
+ }).catch((e) => {
603
+ _callback(e)
604
+ })
605
+ })
606
+ } else {
607
+ waitEnd(client, 2000).then(() => {
608
+ _callback()
609
+ }).catch((_e) => {
610
+ _callback()
611
+ })
612
+ }
613
+ },
614
+ close (done) {
615
+ topicCallbackMap.forEach(callbacks => {
616
+ callbacks.clear()
617
+ })
618
+ topicCallbackMap.clear()
619
+ allNodes.forEach(n => {
620
+ allNodes.delete(n)
621
+ })
622
+ mqtt.disconnect((err) => {
623
+ off()
624
+ client = null
625
+ done(err)
626
+ })
627
+ },
628
+ registerStatus (node) {
629
+ allNodes.add(node)
630
+ },
631
+ deregisterStatus (node) {
632
+ allNodes.delete(node)
633
+ },
634
+ get connected () {
635
+ return client ? connected : false
636
+ },
637
+ get closing () {
638
+ return closing
639
+ },
640
+ get hasSubscriptions () {
641
+ if (topicCallbackMap.size) {
642
+ for (const set of topicCallbackMap) {
643
+ if (set.length) {
644
+ return true
645
+ }
646
+ }
647
+ }
648
+ return false
649
+ }
650
+ }
651
+ })()
652
+
653
+ // Project Link In Node
654
+ function ProjectLinkInNode (n) {
655
+ RED.nodes.createNode(this, n)
656
+ const node = this
657
+ node.project = n.project
658
+ node.subscriptionIdentifier = (n.broadcast && n.project === 'all') ? 2 : 1
659
+ node.subTopic = n.topic
660
+ node.broadcast = n.broadcast === true || n.broadcast === 'true'
661
+ node.topic = buildLinkTopic(node, node.project, node.subTopic, node.broadcast)
662
+ mqtt.connect()
663
+ mqtt.registerStatus(node)
664
+
665
+ /** @type {MQTT.OnMessageCallback} */
666
+ const onSub = function (err, topic, msg, _packet) {
667
+ const t = parseLinkTopic(topic)
668
+ // ensure topic matches
669
+ if (node.subTopic !== t.subTopic) {
670
+ node.warn(`Expected topic ${node.subTopic}, received ${t.subTopic}`)
671
+ return
672
+ }
673
+ // check for error in processing the payload+packet → msg
674
+ if (err) {
675
+ node.error(err, msg)
676
+ return
677
+ }
678
+ node.receive(msg)
679
+ }
680
+ // to my inbox
681
+ // * this project in ff/v1/7N152GxG2p/p/ca65f5ed-aea0-4a10-ac9a-2086b6af6700/in/b1/b1
682
+ // broadcasts
683
+ // * specific project out ff/v1/7N152GxG2p/p/ca65f5ed-aea0-4a10-ac9a-2086b6af6700/out/b1/b1 sub broadcast
684
+ // * +any project+ out ff/v1/7N152GxG2p/p/+/out/b1/b1 sub broadcast
685
+ let subscribedTopic = node.topic
686
+ if (RED.settings.flowforge.useSharedSubscriptions) {
687
+ subscribedTopic = `$share/${RED.settings.flowforge.projectID}/${node.topic}`
688
+ }
689
+ mqtt.subscribe(node, subscribedTopic, { qos: 2 }, onSub)
690
+ .then(_result => {})
691
+ .catch(err => {
692
+ node.status({ fill: 'red', shape: 'dot', text: 'subscribe error' })
693
+ node.error(err)
694
+ })
695
+
696
+ this.on('input', function (msg, send, done) {
697
+ send(msg)
698
+ done()
699
+ })
700
+ node.on('close', function (done) {
701
+ mqtt.unsubscribe(node, subscribedTopic, onSub)
702
+ .then(() => {})
703
+ .catch(_err => {})
704
+ .finally(() => {
705
+ mqtt.deregisterStatus(node)
706
+ if (!mqtt.hasSubscriptions && !mqtt.closing) {
707
+ mqtt.close(done)
708
+ } else {
709
+ done()
710
+ }
711
+ })
712
+ })
713
+ }
714
+ RED.nodes.registerType('project link in', ProjectLinkInNode)
715
+
716
+ // Project Link Out Node
717
+ function ProjectLinkOutNode (n) {
718
+ RED.nodes.createNode(this, n)
719
+ const node = this
720
+ node.project = n.project
721
+ node.subTopic = n.topic
722
+ node.mode = n.mode || 'link'
723
+ node.broadcast = n.broadcast === true || n.broadcast === 'true'
724
+ mqtt.connect()
725
+ mqtt.registerStatus(node)
726
+ node.on('input', async function (msg, _send, done) {
727
+ try {
728
+ if (node.mode === 'return') {
729
+ if (msg.projectLink?.callStack?.length > 0) {
730
+ /** @type {MessageEvent} */
731
+ const messageEvent = msg.projectLink.callStack.pop()
732
+ if (messageEvent && messageEvent.project && messageEvent.topic && messageEvent.eventId) {
733
+ const responseTopic = buildLinkTopic(null, messageEvent.project, messageEvent.topic, node.broadcast, messageEvent.response || 'res')
734
+ const properties = {
735
+ correlationData: messageEvent.eventId
736
+ }
737
+ await mqtt.publish(node, responseTopic, msg, { properties })
738
+ } else {
739
+ node.warn('Project Link Source not valid')
740
+ }
741
+ } else {
742
+ node.warn('Project Link Source missing')
743
+ }
744
+ done()
745
+ } else if (node.mode === 'link') {
746
+ const topic = buildLinkTopic(node, node.project, node.subTopic, node.broadcast)
747
+ // console.log(`PUB ${topic}`)
748
+ await mqtt.publish(node, topic, msg)
749
+ done()
750
+ }
751
+ } catch (error) {
752
+ done(error)
753
+ }
754
+ })
755
+ node.on('close', function (done) {
756
+ try {
757
+ if (!mqtt.hasSubscriptions && !mqtt.closing) {
758
+ mqtt.close(done)
759
+ } else {
760
+ done()
761
+ }
762
+ } finally {
763
+ mqtt.deregisterStatus(node)
764
+ }
765
+ })
766
+ }
767
+ RED.nodes.registerType('project link out', ProjectLinkOutNode)
768
+
769
+ // Project Link Call Node
770
+ function ProjectLinkCallNode (n) {
771
+ RED.nodes.createNode(this, n)
772
+ const node = this
773
+ node.project = n.project
774
+ node.subTopic = n.topic
775
+ node.topic = buildLinkTopic(node, node.project, node.subTopic, false)
776
+ if (RED.settings.flowforge.useSharedSubscriptions) {
777
+ node.responseTopicPrefix = `res-${crypto.randomBytes(4).toString('hex')}`
778
+ } else {
779
+ node.responseTopicPrefix = 'res'
780
+ }
781
+ node.responseTopic = buildLinkTopic(node, RED.settings.flowforge.projectID, node.subTopic, false, node.responseTopicPrefix)
782
+ let timeout = parseFloat(n.timeout || '30') * 1000
783
+ if (isNaN(timeout)) {
784
+ timeout = 30000
785
+ }
786
+ /** @type {MessageEvents} */
787
+ const messageEvents = {}
788
+
789
+ function onSub (err, topic, msg, packet) {
790
+ const t = parseLinkTopic(topic)
791
+ // ensure topic matches
792
+ if (node.subTopic !== t.subTopic) {
793
+ return
794
+ }
795
+ // check for error in processing the payload+packet → msg
796
+ if (err) {
797
+ node.error(err, msg)
798
+ return
799
+ }
800
+ const eventId = packet.properties && packet.properties.correlationData.toString()
801
+ if (messageEvents[eventId]) {
802
+ node.returnLinkMessage(eventId, msg)
803
+ }
804
+ }
805
+
806
+ mqtt.connect()
807
+ mqtt.registerStatus(node)
808
+ mqtt.subscribe(node, node.responseTopic, { qos: 2 }, onSub)
809
+ .then(_result => {})
810
+ .catch(err => {
811
+ node.status({ fill: 'red', shape: 'dot', text: 'subscribe error' })
812
+ node.error(err)
813
+ })
814
+
815
+ node.on('input', async function (msg, send, done) {
816
+ try {
817
+ const eventId = crypto.randomBytes(14).toString('hex')
818
+ /** @type {MessageEvent} */
819
+ const messageEvent = {
820
+ eventId,
821
+ node: node.id,
822
+ project: RED.settings.flowforge.projectID,
823
+ topic: node.subTopic,
824
+ response: node.responseTopicPrefix,
825
+ ts: Date.now()
826
+ }
827
+ /** @type {MessageEvents} */
828
+ messageEvents[eventId] = {
829
+ ...messageEvent,
830
+ msg: RED.util.cloneMessage(msg),
831
+ topic: node.topic,
832
+ responseTopic: node.responseTopic,
833
+ send,
834
+ done,
835
+ timeout: setTimeout(function () {
836
+ timeoutMessage(eventId)
837
+ }, timeout)
838
+ }
839
+ if (!msg.projectLink) {
840
+ msg.projectLink = {
841
+ callStack: []
842
+ }
843
+ }
844
+ msg.projectLink.callStack = msg.projectLink.callStack || []
845
+ msg.projectLink.callStack.push(messageEvent)
846
+
847
+ if (msg.res?._res?.constructor?.name === 'ServerResponse' && msg.req?.constructor?.name === 'IncomingMessage') {
848
+ // this msg is a HTTP IncomingMessage object - strip out the circular references
849
+ delete msg.req
850
+ delete msg.res
851
+ msg.res = `RES:${eventId}` // this is a special temporary value that will be cross-checked and the original value restored in returnLinkMessage
852
+ msg.req = `REQ:${eventId}` // this is a special temporary value that will be cross-checked and the original value restored in returnLinkMessage
853
+ }
854
+
855
+ const options = {
856
+ properties: {
857
+ correlationData: eventId
858
+ }
859
+ }
860
+ await mqtt.publish(node, node.topic, msg, options)
861
+ } catch (error) {
862
+ done(error)
863
+ }
864
+ })
865
+
866
+ node.on('close', function (done) {
867
+ mqtt.unsubscribe(node, node.responseTopic)
868
+ .then(() => {})
869
+ .catch(_err => {})
870
+ .finally(() => {
871
+ mqtt.deregisterStatus(node)
872
+ if (!mqtt.hasSubscriptions && !mqtt.closing) {
873
+ mqtt.close(done)
874
+ } else {
875
+ done()
876
+ }
877
+ })
878
+ })
879
+
880
+ node.returnLinkMessage = function (eventId, msg) {
881
+ try {
882
+ if (msg.projectLink?.callStack?.length === 0) {
883
+ delete msg.projectLink.callStack
884
+ }
885
+ const messageEvent = messageEvents[eventId]
886
+ if (messageEvent) {
887
+ if (msg.res === `RES:${eventId}` && msg.req === `REQ:${eventId}`) {
888
+ // this msg is a HTTP In msg & its req/res was temporarily detached for transmission over
889
+ // the link - reattach the original req/res
890
+ msg.req = messageEvent.msg.req
891
+ msg.res = messageEvent.msg.res
892
+ }
893
+ messageEvent.send(msg)
894
+ clearTimeout(messageEvent.timeout)
895
+ delete messageEvents[eventId]
896
+ messageEvent.done()
897
+ } else {
898
+ node.send(msg)
899
+ }
900
+ } catch (error) {
901
+ node.error(error, msg)
902
+ }
903
+ }
904
+
905
+ function timeoutMessage (eventId) {
906
+ const messageEvent = messageEvents[eventId]
907
+ if (messageEvent) {
908
+ delete messageEvents[eventId]
909
+ node.error('timeout', messageEvent.msg)
910
+ }
911
+ }
912
+ }
913
+ RED.nodes.registerType('project link call', ProjectLinkCallNode)
914
+
915
+ // Endpoint for querying list of projects in node UI
916
+ RED.httpAdmin.get('/nr-project-link/projects', RED.auth.needsPermission('flows.write'), async function (_req, res) {
917
+ const url = `${RED.settings.flowforge.forgeURL}/api/${API_VERSION}/teams/${RED.settings.flowforge.teamID}/projects`
918
+ try {
919
+ const data = await got.get(url, {
920
+ headers: {
921
+ Authorization: `Bearer ${RED.settings.flowforge.projectLink.token}`
922
+ },
923
+ timeout: {
924
+ request: 4000
925
+ }
926
+ }).json()
927
+ if (data != null) {
928
+ res.json(data)
929
+ } else {
930
+ res.sendStatus(404)
931
+ }
932
+ } catch (err) {
933
+ res.sendStatus(500)
934
+ }
935
+ })
936
+ }