@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.
- package/.eslintrc +19 -0
- package/.github/workflows/build.yml +25 -0
- package/.github/workflows/project-automation.yml +10 -0
- package/.github/workflows/publish.yml +66 -0
- package/.github/workflows/release-publish.yml +19 -0
- package/CHANGELOG.md +31 -0
- package/LICENSE +203 -0
- package/README.md +49 -0
- package/nodes/icons/ff-logo.svg +6 -0
- package/nodes/project-link.html +475 -0
- package/nodes/project-link.js +936 -0
- package/package.json +37 -0
|
@@ -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
|
+
}
|