@flowfuse/nr-assistant 0.8.1-317b279-202601221339.0 → 0.8.1-8f07517-202601230815.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/index.html +4 -279
- package/package.json +1 -1
- package/resources/expertComms.js +479 -0
package/index.html
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
<script src="resources/@flowfuse/nr-assistant/expertComms.js"></script>
|
|
1
2
|
<script src="resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
|
|
2
3
|
<script>
|
|
4
|
+
/* global FFExpertComms */ /* loaded from expertComms.js */
|
|
3
5
|
/* global FFAssistantUtils */ /* loaded from sharedUtils.js */
|
|
4
6
|
/* global RED, $ */ /* loaded from Node-RED core */
|
|
5
7
|
(function (RED, n) {
|
|
@@ -160,6 +162,7 @@
|
|
|
160
162
|
}
|
|
161
163
|
})
|
|
162
164
|
}
|
|
165
|
+
|
|
163
166
|
function initAssistant (options) {
|
|
164
167
|
debug('initialising...', assistantOptions)
|
|
165
168
|
if (!initialisedInterlock) {
|
|
@@ -200,163 +203,7 @@
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
if (!assistantInitialised) {
|
|
203
|
-
|
|
204
|
-
if (!window.parent?.postMessage || window.self === window.top) {
|
|
205
|
-
console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
|
|
206
|
-
} else {
|
|
207
|
-
const MESSAGE_SOURCE = 'nr-assistant'
|
|
208
|
-
const MESSAGE_TARGET = 'flowfuse-expert'
|
|
209
|
-
const MESSAGE_SCOPE = 'flowfuse-expert'
|
|
210
|
-
|
|
211
|
-
// proxy certain events from RED Events to the parent window (for state tracking)
|
|
212
|
-
RED.events.on('editor:open', function () {
|
|
213
|
-
window.parent.postMessage({
|
|
214
|
-
type: 'editor:open',
|
|
215
|
-
source: MESSAGE_SOURCE,
|
|
216
|
-
scope: MESSAGE_SCOPE,
|
|
217
|
-
target: MESSAGE_TARGET
|
|
218
|
-
}, '*')
|
|
219
|
-
})
|
|
220
|
-
RED.events.on('editor:close', function () {
|
|
221
|
-
window.parent.postMessage({
|
|
222
|
-
type: 'editor:close',
|
|
223
|
-
source: MESSAGE_SOURCE,
|
|
224
|
-
scope: MESSAGE_SCOPE,
|
|
225
|
-
target: MESSAGE_TARGET
|
|
226
|
-
}, '*')
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
// Define supported actions and their parameter schemas
|
|
230
|
-
const supportedActions = {
|
|
231
|
-
'core:manage-palette': {
|
|
232
|
-
params: {
|
|
233
|
-
type: 'object',
|
|
234
|
-
properties: {
|
|
235
|
-
view: {
|
|
236
|
-
type: 'string',
|
|
237
|
-
enum: ['nodes', 'install'],
|
|
238
|
-
default: 'install'
|
|
239
|
-
},
|
|
240
|
-
filter: {
|
|
241
|
-
description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
|
|
242
|
-
type: 'string'
|
|
243
|
-
}
|
|
244
|
-
},
|
|
245
|
-
required: ['filter']
|
|
246
|
-
}
|
|
247
|
-
},
|
|
248
|
-
'custom:import-flow': {
|
|
249
|
-
params: {
|
|
250
|
-
type: 'object',
|
|
251
|
-
properties: {
|
|
252
|
-
flow: {
|
|
253
|
-
type: 'string',
|
|
254
|
-
description: 'The flow JSON to import'
|
|
255
|
-
},
|
|
256
|
-
addFlow: {
|
|
257
|
-
type: 'boolean',
|
|
258
|
-
description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
required: ['flow']
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Listen for postMessages from parent window
|
|
267
|
-
window.addEventListener('message', function (event) {
|
|
268
|
-
// prevent own messages being processed
|
|
269
|
-
if (event.source === window.self) {
|
|
270
|
-
return
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const { type, action, params, target, source, scope } = event.data || {}
|
|
274
|
-
|
|
275
|
-
// Ensure scope and source match expected values
|
|
276
|
-
if (target !== MESSAGE_SOURCE || source !== MESSAGE_TARGET || scope !== MESSAGE_SCOPE) {
|
|
277
|
-
return
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
debug('Received postMessage:', event.data)
|
|
281
|
-
|
|
282
|
-
const postReply = (message) => {
|
|
283
|
-
debug('Posting reply message:', message)
|
|
284
|
-
if (event.source && typeof event.source.postMessage === 'function') {
|
|
285
|
-
event.source.postMessage({
|
|
286
|
-
...message,
|
|
287
|
-
source: MESSAGE_SOURCE,
|
|
288
|
-
scope: MESSAGE_SCOPE,
|
|
289
|
-
target: MESSAGE_TARGET
|
|
290
|
-
}, event.origin)
|
|
291
|
-
} else {
|
|
292
|
-
console.warn('Unable to post message reply, source not available', message)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// handle version request
|
|
297
|
-
if (type === 'get-assistant-version') {
|
|
298
|
-
// Reply with the current version
|
|
299
|
-
postReply({ type, version: assistantOptions.assistantVersion, success: true }, event.origin)
|
|
300
|
-
return
|
|
301
|
-
}
|
|
302
|
-
// handle supported actions request
|
|
303
|
-
if (type === 'get-supported-actions') {
|
|
304
|
-
// Reply with the supported actions and their schemas
|
|
305
|
-
postReply({ type, supportedActions, success: true }, event.origin)
|
|
306
|
-
return
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// handle action invocation requests (must be registered actions in supportedActions)
|
|
310
|
-
if (type === 'invoke-action' && typeof action === 'string') {
|
|
311
|
-
if (!supportedActions[action]) {
|
|
312
|
-
console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
|
|
313
|
-
postReply({ type, action, error: 'unknown-action' })
|
|
314
|
-
return
|
|
315
|
-
}
|
|
316
|
-
// Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
|
|
317
|
-
const actionSchema = supportedActions[action].params
|
|
318
|
-
if (actionSchema) {
|
|
319
|
-
const validation = validateSchema(params, actionSchema)
|
|
320
|
-
if (!validation || !validation.valid) {
|
|
321
|
-
console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
|
|
322
|
-
postReply({ type, action, error: validation.error || 'invalid-parameters' })
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (action === 'custom:import-flow') {
|
|
328
|
-
// import-flow is a custom action - handle it here directly
|
|
329
|
-
try {
|
|
330
|
-
importNodes(params.flow, params.addFlow === true)
|
|
331
|
-
postReply({ type, success: true })
|
|
332
|
-
} catch (err) {
|
|
333
|
-
postReply({ type, error: err?.message })
|
|
334
|
-
}
|
|
335
|
-
} else {
|
|
336
|
-
// Handle (supported) native Node-RED actions
|
|
337
|
-
try {
|
|
338
|
-
RED.actions.invoke(action, params)
|
|
339
|
-
postReply({ type, action, success: true })
|
|
340
|
-
} catch (err) {
|
|
341
|
-
postReply({ type, action, error: err?.message })
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// unknown message type
|
|
348
|
-
postReply({ type: 'error', error: 'unknown-type', data: event.data })
|
|
349
|
-
}, false)
|
|
350
|
-
|
|
351
|
-
// Notify the parent window that the assistant is ready
|
|
352
|
-
window.parent.postMessage({
|
|
353
|
-
type: 'assistant-ready',
|
|
354
|
-
source: MESSAGE_SOURCE,
|
|
355
|
-
scope: MESSAGE_SCOPE,
|
|
356
|
-
target: MESSAGE_TARGET,
|
|
357
|
-
version: assistantOptions.assistantVersion
|
|
358
|
-
}, '*')
|
|
359
|
-
}
|
|
206
|
+
FFExpertComms.init(RED, assistantOptions)
|
|
360
207
|
|
|
361
208
|
registerMonacoExtensions()
|
|
362
209
|
RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
|
|
@@ -2117,128 +1964,6 @@
|
|
|
2117
1964
|
RED.menu.refreshShortcuts()
|
|
2118
1965
|
}
|
|
2119
1966
|
|
|
2120
|
-
/**
|
|
2121
|
-
* Validates data against a simple schema
|
|
2122
|
-
* NOTE: This is a very basic implementation and only supports a subset of JSON Schema for now.
|
|
2123
|
-
* @param {any} data - The data to validate
|
|
2124
|
-
* @param {object} schema - The schema to validate against
|
|
2125
|
-
* @returns {{valid: boolean, error?: string}} - The validation result
|
|
2126
|
-
*/
|
|
2127
|
-
function validateSchema (data, schema) {
|
|
2128
|
-
if (schema.type === 'object') {
|
|
2129
|
-
if (typeof data !== 'object') {
|
|
2130
|
-
return {
|
|
2131
|
-
valid: false,
|
|
2132
|
-
error: 'Data is not of type object'
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
if (Array.isArray(data)) {
|
|
2136
|
-
return {
|
|
2137
|
-
valid: false,
|
|
2138
|
-
error: 'Data is an array but an object was expected'
|
|
2139
|
-
}
|
|
2140
|
-
}
|
|
2141
|
-
// check required properties
|
|
2142
|
-
if (Array.isArray(schema.required)) {
|
|
2143
|
-
for (const reqProp of schema.required) {
|
|
2144
|
-
if (!(reqProp in data)) {
|
|
2145
|
-
return {
|
|
2146
|
-
valid: false,
|
|
2147
|
-
error: `Data is missing required parameter "${reqProp}"`
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
}
|
|
2152
|
-
// check properties & apply defaults
|
|
2153
|
-
if (schema.properties) {
|
|
2154
|
-
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
2155
|
-
const propExists = propName in data
|
|
2156
|
-
// check type
|
|
2157
|
-
if (propSchema.type && propExists) {
|
|
2158
|
-
const expectedType = propSchema.type
|
|
2159
|
-
const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName]
|
|
2160
|
-
if (actualType !== expectedType) {
|
|
2161
|
-
return {
|
|
2162
|
-
valid: false,
|
|
2163
|
-
error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"`
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
// check enum
|
|
2168
|
-
if (propSchema.enum && propExists) {
|
|
2169
|
-
if (!propSchema.enum.includes(data[propName])) {
|
|
2170
|
-
return {
|
|
2171
|
-
valid: false,
|
|
2172
|
-
error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}`
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
// apply defaults
|
|
2177
|
-
if (propSchema.default !== undefined && !propExists) {
|
|
2178
|
-
data[propName] = propSchema.default
|
|
2179
|
-
}
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
return { valid: true }
|
|
2184
|
-
}
|
|
2185
|
-
|
|
2186
|
-
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
|
|
2187
|
-
/**
|
|
2188
|
-
* Performs the import of nodes, handling any conflicts that may arise
|
|
2189
|
-
* @param {string} nodesStr the nodes to import as a string
|
|
2190
|
-
* @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
|
|
2191
|
-
*/
|
|
2192
|
-
function importNodes (nodesStr, addFlow) {
|
|
2193
|
-
let newNodes = nodesStr
|
|
2194
|
-
if (typeof nodesStr === 'string') {
|
|
2195
|
-
try {
|
|
2196
|
-
nodesStr = nodesStr.trim()
|
|
2197
|
-
if (nodesStr.length === 0) {
|
|
2198
|
-
return
|
|
2199
|
-
}
|
|
2200
|
-
newNodes = validateFlowString(nodesStr)
|
|
2201
|
-
} catch (err) {
|
|
2202
|
-
const e = new Error(RED._('clipboard.invalidFlow', { message: 'test' }))
|
|
2203
|
-
e.code = 'NODE_RED'
|
|
2204
|
-
throw e
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
const importOptions = { generateIds: true, addFlow }
|
|
2208
|
-
try {
|
|
2209
|
-
RED.view.importNodes(newNodes, importOptions)
|
|
2210
|
-
} catch (error) {
|
|
2211
|
-
// Thrown for import_conflict
|
|
2212
|
-
RED.notify('Import failed:' + error.message, 'error')
|
|
2213
|
-
throw error
|
|
2214
|
-
}
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
|
|
2218
|
-
/**
|
|
2219
|
-
* Validates if the provided string looks like valid flow json
|
|
2220
|
-
* @param {string} flowString the string to validate
|
|
2221
|
-
* @returns If valid, returns the node array
|
|
2222
|
-
*/
|
|
2223
|
-
function validateFlowString (flowString) {
|
|
2224
|
-
const res = JSON.parse(flowString)
|
|
2225
|
-
if (!Array.isArray(res)) {
|
|
2226
|
-
throw new Error(RED._('clipboard.import.errors.notArray'))
|
|
2227
|
-
}
|
|
2228
|
-
for (let i = 0; i < res.length; i++) {
|
|
2229
|
-
if (typeof res[i] !== 'object') {
|
|
2230
|
-
throw new Error(RED._('clipboard.import.errors.itemNotObject', { index: i }))
|
|
2231
|
-
}
|
|
2232
|
-
if (!Object.hasOwn(res[i], 'id')) {
|
|
2233
|
-
throw new Error(RED._('clipboard.import.errors.missingId', { index: i }))
|
|
2234
|
-
}
|
|
2235
|
-
if (!Object.hasOwn(res[i], 'type')) {
|
|
2236
|
-
throw new Error(RED._('clipboard.import.errors.missingType', { index: i }))
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
return res
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
1967
|
function debug (...args) {
|
|
2243
1968
|
if (RED.nrAssistant?.DEBUG) {
|
|
2244
1969
|
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
|
package/package.json
CHANGED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFAssistant Utils
|
|
3
|
+
* Expert Communication functions for the FlowFuse Assistant
|
|
4
|
+
* To import this in js backend code (although you shouldn't), use:
|
|
5
|
+
* const FFExpertComms = require('flowfuse-nr-assistant/resources/expertComms.js')
|
|
6
|
+
* To import this in frontend code, use:
|
|
7
|
+
* <script src="/resources/@flowfuse/nr-assistant/expertComms.js"></script>
|
|
8
|
+
* To use this in the browser, you can access it via:
|
|
9
|
+
* FFExpertComms.cleanFlow(nodeArray)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
(function (root, factory) {
|
|
15
|
+
if (typeof module === 'object' && module.exports) {
|
|
16
|
+
// Node.js / CommonJS
|
|
17
|
+
module.exports = factory()
|
|
18
|
+
} else {
|
|
19
|
+
// Browser
|
|
20
|
+
root.FFExpertComms = factory()
|
|
21
|
+
}
|
|
22
|
+
}(typeof self !== 'undefined' ? self : this, function () {
|
|
23
|
+
'use strict'
|
|
24
|
+
|
|
25
|
+
class ExpertComms {
|
|
26
|
+
/** @type {import('node-red').NodeRedInstance} */
|
|
27
|
+
RED = null
|
|
28
|
+
assistantOptions = {}
|
|
29
|
+
|
|
30
|
+
MESSAGE_SOURCE = 'nr-assistant'
|
|
31
|
+
MESSAGE_TARGET = 'flowfuse-expert'
|
|
32
|
+
MESSAGE_SCOPE = 'flowfuse-expert'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* targetOrigin is set to '*' by default, which allows messages to be sent and received from any origin.
|
|
36
|
+
* This is fine for the initial handshake with the FF Expert (will change to the origin of the expert page once it is loaded)
|
|
37
|
+
*
|
|
38
|
+
* @type {string}
|
|
39
|
+
*/
|
|
40
|
+
targetOrigin = '*'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Define supported actions and their parameter schemas
|
|
44
|
+
*/
|
|
45
|
+
supportedActions = {
|
|
46
|
+
'core:manage-palette': {
|
|
47
|
+
params: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
view: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
enum: ['nodes', 'install'],
|
|
53
|
+
default: 'install'
|
|
54
|
+
},
|
|
55
|
+
filter: {
|
|
56
|
+
description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
|
|
57
|
+
type: 'string'
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
required: ['filter']
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
'custom:import-flow': {
|
|
64
|
+
params: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: {
|
|
67
|
+
flow: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'The flow JSON to import'
|
|
70
|
+
},
|
|
71
|
+
addFlow: {
|
|
72
|
+
type: 'boolean',
|
|
73
|
+
description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
required: ['flow']
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A mapping of Node-RED core events to their respective handler logic.
|
|
83
|
+
*
|
|
84
|
+
* This map acts as a router for the assistant's event listeners:
|
|
85
|
+
* - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
|
|
86
|
+
* - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
|
|
87
|
+
* The method name being referenced must be appended with 'notify'
|
|
88
|
+
*
|
|
89
|
+
* @type {Object.<string, Function|string>}
|
|
90
|
+
*/
|
|
91
|
+
nodeRedEventsMap = {
|
|
92
|
+
'editor:open': () => {
|
|
93
|
+
this.postParent({ type: 'editor:open' })
|
|
94
|
+
},
|
|
95
|
+
'editor:close': () => {
|
|
96
|
+
this.postParent({ type: 'editor:close' })
|
|
97
|
+
},
|
|
98
|
+
'registry:node-set-added': 'notifyPaletteChange',
|
|
99
|
+
'registry:node-set-removed': 'notifyPaletteChange',
|
|
100
|
+
'registry:node-set-disabled': 'notifyPaletteChange',
|
|
101
|
+
'registry:node-set-enabled': 'notifyPaletteChange'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A mapping of FlowFuse Expert events to their respective handler logic.
|
|
106
|
+
*
|
|
107
|
+
* This map acts as a router for the expert's event listeners:
|
|
108
|
+
* - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
|
|
109
|
+
* - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
|
|
110
|
+
* The method name being referenced must be appended with 'handle'
|
|
111
|
+
*
|
|
112
|
+
* @type {Object.<string, Function|string>}
|
|
113
|
+
*/
|
|
114
|
+
expertEventsMap = {
|
|
115
|
+
'get-assistant-version': ({ event, type, action, params } = {}) => {
|
|
116
|
+
// handle version request
|
|
117
|
+
this.postReply({ type, version: this.assistantOptions.assistantVersion, success: true }, event)
|
|
118
|
+
},
|
|
119
|
+
'get-supported-actions': ({ event, type, action, params } = {}) => {
|
|
120
|
+
// handle supported actions request
|
|
121
|
+
this.postReply({ type, supportedActions: this.supportedActions, success: true }, event)
|
|
122
|
+
},
|
|
123
|
+
'get-palette': async ({ event, type, action, params } = {}) => {
|
|
124
|
+
// handle palette request
|
|
125
|
+
this.postReply({ type: 'set-palette', palette: await this.getPalette(), success: true }, event)
|
|
126
|
+
},
|
|
127
|
+
'invoke-action': 'handleActionInvocation'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
init (RED, assistantOptions) {
|
|
131
|
+
/** @type {import('node-red').NodeRedInstance} */
|
|
132
|
+
this.RED = RED
|
|
133
|
+
this.assistantOptions = assistantOptions
|
|
134
|
+
|
|
135
|
+
if (!window.parent?.postMessage || window.self === window.top) {
|
|
136
|
+
console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.setNodeRedEventListeners()
|
|
141
|
+
|
|
142
|
+
this.setupMessageListeners()
|
|
143
|
+
|
|
144
|
+
// Notify the parent window that the assistant is ready
|
|
145
|
+
this.postParent({ type: 'assistant-ready', version: this.assistantOptions.assistantVersion })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setupMessageListeners () {
|
|
149
|
+
// Listen for postMessages from the parent window
|
|
150
|
+
window.addEventListener('message', async (event) => {
|
|
151
|
+
// prevent own messages being processed
|
|
152
|
+
if (event.source === window.self) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { type, action, params, target, source, scope } = event.data || {}
|
|
157
|
+
|
|
158
|
+
// Ensure scope and source match expected values
|
|
159
|
+
if (target !== this.MESSAGE_SOURCE || source !== this.MESSAGE_TARGET || scope !== this.MESSAGE_SCOPE) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Setting target origin for future calls
|
|
164
|
+
if (this.targetOrigin === '*') {
|
|
165
|
+
this.targetOrigin = event.origin
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.debug('Received postMessage:', event.data)
|
|
169
|
+
|
|
170
|
+
const payload = {
|
|
171
|
+
event,
|
|
172
|
+
type,
|
|
173
|
+
action,
|
|
174
|
+
params
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const eventName in this.expertEventsMap) {
|
|
178
|
+
if (type === eventName && typeof this.expertEventsMap[eventName] === 'function') {
|
|
179
|
+
return this.expertEventsMap[eventName](payload)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
type === eventName &&
|
|
184
|
+
typeof this.expertEventsMap[eventName] === 'string' &&
|
|
185
|
+
this.expertEventsMap[eventName] in this
|
|
186
|
+
) {
|
|
187
|
+
return this[this.expertEventsMap[eventName]](payload)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// handles unknown message type
|
|
192
|
+
this.postReply({ type: 'error', error: 'unknown-type', data: event.data }, event)
|
|
193
|
+
}, false)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setNodeRedEventListeners () {
|
|
197
|
+
Object.keys(this.nodeRedEventsMap).forEach(eventName => {
|
|
198
|
+
if (typeof this.nodeRedEventsMap[eventName] === 'function') {
|
|
199
|
+
this.RED.events.on(eventName, this.nodeRedEventsMap[eventName].bind(this))
|
|
200
|
+
}
|
|
201
|
+
if (typeof this.nodeRedEventsMap[eventName] === 'string' && this.nodeRedEventsMap[eventName] in this) {
|
|
202
|
+
this.RED.events.on(eventName, this[this.nodeRedEventsMap[eventName]].bind(this))
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* FlowFuse Expert Node-RED event notifiers
|
|
209
|
+
*/
|
|
210
|
+
async notifyPaletteChange () {
|
|
211
|
+
this.postParent({
|
|
212
|
+
type: 'set-palette',
|
|
213
|
+
palette: await this.getPalette()
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* FlowFuse Expert message handlers
|
|
219
|
+
*/
|
|
220
|
+
handleActionInvocation ({ event, type, action, params } = {}) {
|
|
221
|
+
// handle action invocation requests (must be registered actions in supportedActions)
|
|
222
|
+
if (typeof action !== 'string') {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!this.supportedActions[action]) {
|
|
227
|
+
console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
|
|
228
|
+
this.postReply({ type, action, error: 'unknown-action' }, event)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
|
|
233
|
+
const actionSchema = this.supportedActions[action].params
|
|
234
|
+
if (actionSchema) {
|
|
235
|
+
const validation = this.validateSchema(params, actionSchema)
|
|
236
|
+
if (!validation || !validation.valid) {
|
|
237
|
+
console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
|
|
238
|
+
this.postReply({ type, action, error: validation.error || 'invalid-parameters' }, event)
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (action === 'custom:import-flow') {
|
|
244
|
+
// import-flow is a custom action - handle it here directly
|
|
245
|
+
try {
|
|
246
|
+
this.importNodes(params.flow, params.addFlow === true)
|
|
247
|
+
this.postReply({ type, success: true }, event)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
this.postReply({ type, error: err?.message }, event)
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// Handle (supported) native Node-RED actions
|
|
253
|
+
try {
|
|
254
|
+
this.RED.actions.invoke(action, params)
|
|
255
|
+
this.postReply({ type, action, success: true }, event)
|
|
256
|
+
} catch (err) {
|
|
257
|
+
this.postReply({ type, action, error: err?.message }, event)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getPalette () {
|
|
263
|
+
const palette = {}
|
|
264
|
+
const plugins = await $.ajax({
|
|
265
|
+
url: 'plugins',
|
|
266
|
+
method: 'GET',
|
|
267
|
+
headers: {
|
|
268
|
+
Accept: 'application/json'
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
const nodes = await $.ajax({
|
|
272
|
+
url: 'nodes',
|
|
273
|
+
method: 'GET',
|
|
274
|
+
headers: {
|
|
275
|
+
Accept: 'application/json'
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
plugins.forEach(plugin => {
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(palette, plugin.module)) {
|
|
281
|
+
palette[plugin.module].plugins.push(plugin)
|
|
282
|
+
} else {
|
|
283
|
+
palette[plugin.module] = {
|
|
284
|
+
version: plugin.version,
|
|
285
|
+
enabled: plugin.enabled,
|
|
286
|
+
module: plugin.module,
|
|
287
|
+
plugins: [
|
|
288
|
+
plugin
|
|
289
|
+
],
|
|
290
|
+
nodes: []
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
nodes.forEach(node => {
|
|
296
|
+
if (Object.prototype.hasOwnProperty.call(palette, node.module)) {
|
|
297
|
+
palette[node.module].nodes.push(node)
|
|
298
|
+
} else {
|
|
299
|
+
palette[node.module] = {
|
|
300
|
+
version: node.version,
|
|
301
|
+
enabled: node.enabled,
|
|
302
|
+
module: node.module,
|
|
303
|
+
plugins: [],
|
|
304
|
+
nodes: [
|
|
305
|
+
node
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
return palette
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
validateSchema (data, schema) {
|
|
315
|
+
if (schema.type === 'object') {
|
|
316
|
+
if (typeof data !== 'object') {
|
|
317
|
+
return {
|
|
318
|
+
valid: false,
|
|
319
|
+
error: 'Data is not of type object'
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (Array.isArray(data)) {
|
|
323
|
+
return {
|
|
324
|
+
valid: false,
|
|
325
|
+
error: 'Data is an array but an object was expected'
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// check required properties
|
|
329
|
+
if (Array.isArray(schema.required)) {
|
|
330
|
+
for (const reqProp of schema.required) {
|
|
331
|
+
if (!(reqProp in data)) {
|
|
332
|
+
return {
|
|
333
|
+
valid: false,
|
|
334
|
+
error: `Data is missing required parameter "${reqProp}"`
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// check properties & apply defaults
|
|
340
|
+
if (schema.properties) {
|
|
341
|
+
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
342
|
+
const propExists = propName in data
|
|
343
|
+
// check type
|
|
344
|
+
if (propSchema.type && propExists) {
|
|
345
|
+
const expectedType = propSchema.type
|
|
346
|
+
const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName]
|
|
347
|
+
if (actualType !== expectedType) {
|
|
348
|
+
return {
|
|
349
|
+
valid: false,
|
|
350
|
+
error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"`
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// check enum
|
|
355
|
+
if (propSchema.enum && propExists) {
|
|
356
|
+
if (!propSchema.enum.includes(data[propName])) {
|
|
357
|
+
return {
|
|
358
|
+
valid: false,
|
|
359
|
+
error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}`
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// apply defaults
|
|
364
|
+
if (propSchema.default !== undefined && !propExists) {
|
|
365
|
+
data[propName] = propSchema.default
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { valid: true }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
|
|
374
|
+
/**
|
|
375
|
+
* Performs the import of nodes, handling any conflicts that may arise
|
|
376
|
+
* @param {string} nodesStr the nodes to import as a string
|
|
377
|
+
* @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
|
|
378
|
+
*/
|
|
379
|
+
importNodes (nodesStr, addFlow) {
|
|
380
|
+
let newNodes = nodesStr
|
|
381
|
+
if (typeof nodesStr === 'string') {
|
|
382
|
+
try {
|
|
383
|
+
nodesStr = nodesStr.trim()
|
|
384
|
+
if (nodesStr.length === 0) {
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
newNodes = this.validateFlowString(nodesStr)
|
|
388
|
+
} catch (err) {
|
|
389
|
+
const e = new Error(this.RED._('clipboard.invalidFlow', { message: 'test' }))
|
|
390
|
+
e.code = 'NODE_RED'
|
|
391
|
+
throw e
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const importOptions = { generateIds: true, addFlow }
|
|
395
|
+
try {
|
|
396
|
+
this.RED.view.importNodes(newNodes, importOptions)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
// Thrown for import_conflict
|
|
399
|
+
this.RED.notify('Import failed:' + error.message, 'error')
|
|
400
|
+
throw error
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
|
|
405
|
+
/**
|
|
406
|
+
* Validates if the provided string looks like valid flow json
|
|
407
|
+
* @param {string} flowString the string to validate
|
|
408
|
+
* @returns If valid, returns the node array
|
|
409
|
+
*/
|
|
410
|
+
validateFlowString (flowString) {
|
|
411
|
+
const res = JSON.parse(flowString)
|
|
412
|
+
if (!Array.isArray(res)) {
|
|
413
|
+
throw new Error(this.RED._('clipboard.import.errors.notArray'))
|
|
414
|
+
}
|
|
415
|
+
for (let i = 0; i < res.length; i++) {
|
|
416
|
+
if (typeof res[i] !== 'object') {
|
|
417
|
+
throw new Error(this.RED._('clipboard.import.errors.itemNotObject', { index: i }))
|
|
418
|
+
}
|
|
419
|
+
if (!Object.hasOwn(res[i], 'id')) {
|
|
420
|
+
throw new Error(this.RED._('clipboard.import.errors.missingId', { index: i }))
|
|
421
|
+
}
|
|
422
|
+
if (!Object.hasOwn(res[i], 'type')) {
|
|
423
|
+
throw new Error(this.RED._('clipboard.import.errors.missingType', { index: i }))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return res
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
debug (...args) {
|
|
430
|
+
if (this.RED.nrAssistant?.DEBUG) {
|
|
431
|
+
const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
|
|
432
|
+
const stackLine = new Error().stack.split('\n')[2].trim()
|
|
433
|
+
const match = stackLine.match(/\(?([^\s)]+):(\d+):(\d+)\)?$/) || stackLine.match(/@?([^@]+):(\d+):(\d+)$/)
|
|
434
|
+
const file = match?.[1] || 'anonymous'
|
|
435
|
+
const line = match?.[2] || '1'
|
|
436
|
+
const col = match?.[3] || '1'
|
|
437
|
+
let link = `${window.location.origin}/${scriptName}:${line}:${col}`
|
|
438
|
+
if (/^VM\d+$/.test(file)) {
|
|
439
|
+
link = `debugger:///${file}:${line}:${col}`
|
|
440
|
+
} else if (file !== 'anonymous' && file !== '<anonymous>' && file !== scriptName) {
|
|
441
|
+
link = `${file}:${line}:${col}`
|
|
442
|
+
if (!link.startsWith('http') && !link.includes('/')) {
|
|
443
|
+
link = `${window.location.origin}/${link}`
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// eslint-disable-next-line no-console
|
|
447
|
+
console.log('[nr-assistant]', ...args, `\n at ${link}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Internal helper to send a formatted message to a target window
|
|
453
|
+
*/
|
|
454
|
+
_post (payload, targetWindow) {
|
|
455
|
+
if (targetWindow && typeof targetWindow.postMessage === 'function') {
|
|
456
|
+
targetWindow.postMessage({
|
|
457
|
+
...payload,
|
|
458
|
+
source: this.MESSAGE_SOURCE,
|
|
459
|
+
scope: this.MESSAGE_SCOPE,
|
|
460
|
+
target: this.MESSAGE_TARGET
|
|
461
|
+
}, this.targetOrigin)
|
|
462
|
+
} else {
|
|
463
|
+
console.warn('Unable to post message, target window not available', payload)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
postParent (payload = {}) {
|
|
468
|
+
this.debug('Posting parent message', payload)
|
|
469
|
+
this._post(payload, window.parent)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
postReply (payload, event) {
|
|
473
|
+
this.debug('Posting reply message:', payload)
|
|
474
|
+
this._post(payload, event.source)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return new ExpertComms()
|
|
479
|
+
}))
|