@malysus/node-red-contrib-flow-splitter-extended 0.0.0-stage → 1.4.2
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/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +319 -2
- package/functions-templates-handler.js +369 -0
- package/index.d.ts +31 -0
- package/index.js +796 -0
- package/package.json +65 -4
package/index.js
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const manager = require('flows-file-manager')
|
|
4
|
+
const eol = require('eol')
|
|
5
|
+
const yaml = require('js-yaml')
|
|
6
|
+
const crypto = require('crypto')
|
|
7
|
+
const functionsTemplatesHandler = require('./functions-templates-handler')
|
|
8
|
+
const { writeFileAtomic } = functionsTemplatesHandler
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Here we define some types to allow the IDE to provide us autocompletion.
|
|
12
|
+
* Some are documented directly by the nodered team but some are not.
|
|
13
|
+
*
|
|
14
|
+
* That's why we also made an `index.d.ts` for undocumented types we need to manipulate.
|
|
15
|
+
* Those are defined by analysing the nodered code and some logging of those objects.
|
|
16
|
+
* In this regard, the types defined by ourselves might be incomplete.
|
|
17
|
+
*
|
|
18
|
+
* @typedef {import('./index').noderedEvent.FlowStartedEvent} FlowStartedEventType
|
|
19
|
+
* @typedef {import('./index').noderedEvent.ExtendedNodeDef} ExtendedNodeDef
|
|
20
|
+
* @typedef {import("node-red").NodeRedApp} REDType
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exposing the RED runtime globally to avoid passing it in every functions.
|
|
25
|
+
* @type {REDType}
|
|
26
|
+
*/
|
|
27
|
+
let RED
|
|
28
|
+
let lastExtractionFingerprint = null
|
|
29
|
+
|
|
30
|
+
const splitCfgFilename = '.config.flow-splitter.json'
|
|
31
|
+
const artifactsDirname = 'artifact'
|
|
32
|
+
const artifactsFilename = 'flows.json'
|
|
33
|
+
const DEFAULT_CFG = {
|
|
34
|
+
fileFormat: 'json',
|
|
35
|
+
destinationFolder: 'src',
|
|
36
|
+
tabsOrder: [],
|
|
37
|
+
monolithFilename: "flows.json",
|
|
38
|
+
extractFunctionsTemplates: true,
|
|
39
|
+
restoreFunctionsTemplates: false,
|
|
40
|
+
enableArtifact: true,
|
|
41
|
+
fastJsonSplit: true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write splitter configuration to disk
|
|
46
|
+
* @param {object} cfg - Splitter configuration
|
|
47
|
+
* @param {string} projectPath - Path to the project
|
|
48
|
+
*/
|
|
49
|
+
function writeSplitterConfig(cfg, projectPath) {
|
|
50
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Writing new config")
|
|
51
|
+
try {
|
|
52
|
+
const splitterCfgToWrite = JSON.parse(JSON.stringify(cfg))
|
|
53
|
+
delete splitterCfgToWrite.monolithFilename
|
|
54
|
+
writeFileAtomic(path.join(projectPath, splitCfgFilename), eol.auto(JSON.stringify(splitterCfgToWrite, null, 2)))
|
|
55
|
+
} catch (error) {
|
|
56
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not write splitter config '${splitCfgFilename}': ${error}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the project path (handles both project mode and non-project mode)
|
|
62
|
+
* @returns {string} Project path
|
|
63
|
+
*/
|
|
64
|
+
function getProjectPath() {
|
|
65
|
+
const userDir = RED.settings.userDir
|
|
66
|
+
if (!userDir) {
|
|
67
|
+
// RED.settings.userDir can be undefined if this handler runs on a
|
|
68
|
+
// 'flows:started' event fired very early during Node-RED startup,
|
|
69
|
+
// before runtime settings are fully resolved. Returning null here
|
|
70
|
+
// lets the caller skip processing gracefully instead of crashing
|
|
71
|
+
// with "TypeError [ERR_INVALID_ARG_TYPE]: The 'path' argument must
|
|
72
|
+
// be of type string. Received undefined".
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
const projectsConfigFile = path.join(userDir, '.config.projects.json')
|
|
76
|
+
|
|
77
|
+
if (fs.existsSync(projectsConfigFile)) {
|
|
78
|
+
const nrProjectsCfg = JSON.parse(fs.readFileSync(projectsConfigFile))
|
|
79
|
+
// nrProjectsCfg.activeProject can be missing/undefined when project
|
|
80
|
+
// mode is not actually in use (e.g. an empty or stale
|
|
81
|
+
// .config.projects.json). Passing undefined to path.join() throws
|
|
82
|
+
// "TypeError [ERR_INVALID_ARG_TYPE]: The 'path' argument must be of
|
|
83
|
+
// type string. Received undefined", so fall back to the plain
|
|
84
|
+
// userDir in that case instead of crashing.
|
|
85
|
+
if (nrProjectsCfg && nrProjectsCfg.activeProject) {
|
|
86
|
+
return path.join(userDir, 'projects', nrProjectsCfg.activeProject)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return userDir
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Load splitter configuration from disk
|
|
94
|
+
* @param {string} projectPath - Path to the project
|
|
95
|
+
* @returns {object} Splitter configuration
|
|
96
|
+
*/
|
|
97
|
+
function loadSplitterConfig(projectPath) {
|
|
98
|
+
let cfg = { ...DEFAULT_CFG }
|
|
99
|
+
cfg.monolithFilename = RED.settings.flowFile || 'flows.json'
|
|
100
|
+
|
|
101
|
+
const configPath = path.join(projectPath, splitCfgFilename)
|
|
102
|
+
if (fs.existsSync(configPath)) {
|
|
103
|
+
const loadedCfg = JSON.parse(fs.readFileSync(configPath))
|
|
104
|
+
cfg = { ...cfg, ...loadedCfg }
|
|
105
|
+
cfg.monolithFilename = loadedCfg.monolithFilename || RED.settings.flowFile || 'flows.json'
|
|
106
|
+
}
|
|
107
|
+
return cfg
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get private RED instance to access internal APIs
|
|
112
|
+
* Plugins only get the public API, which cannot reload flows from disk, so this
|
|
113
|
+
* relies on Node-RED internals and throws a clear error if they change.
|
|
114
|
+
* @returns {REDType} Private RED instance
|
|
115
|
+
*/
|
|
116
|
+
function getPrivateRED() {
|
|
117
|
+
let privateRED
|
|
118
|
+
const child = require.main && require.main.children.find(child => child.filename.endsWith('red.js'))
|
|
119
|
+
try {
|
|
120
|
+
privateRED = child ? require(child.filename) : require('node-red')
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Cannot access the Node-RED runtime to reload flows: ${error.message}`)
|
|
123
|
+
}
|
|
124
|
+
if (typeof privateRED?.nodes?.loadFlows !== 'function') {
|
|
125
|
+
throw new Error('Cannot reload flows: RED.nodes.loadFlows() is not available in this Node-RED version')
|
|
126
|
+
}
|
|
127
|
+
return privateRED
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* flows-file-manager skips split files it cannot parse, so one corrupted file
|
|
132
|
+
* would silently drop its tab from the rebuilt flows.json. Throw instead, so
|
|
133
|
+
* the boot rebuild falls back to the artifact and manual reload reports it.
|
|
134
|
+
* @param {object} cfg - Splitter configuration
|
|
135
|
+
* @param {string} projectPath - Path to the project
|
|
136
|
+
*/
|
|
137
|
+
function assertSplitFilesParse(cfg, projectPath) {
|
|
138
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
139
|
+
const extension = `.${cfg.fileFormat}`
|
|
140
|
+
for (const folder of ['tabs', 'subflows', 'config-nodes']) {
|
|
141
|
+
const dir = path.join(srcDir, folder)
|
|
142
|
+
if (!fs.existsSync(dir)) continue
|
|
143
|
+
for (const file of fs.readdirSync(dir).filter(f => f.endsWith(extension))) {
|
|
144
|
+
const fileContent = fs.readFileSync(path.join(dir, file), 'utf8')
|
|
145
|
+
try {
|
|
146
|
+
if (cfg.fileFormat.toLowerCase() !== 'json') {
|
|
147
|
+
yaml.load(fileContent)
|
|
148
|
+
} else {
|
|
149
|
+
JSON.parse(fileContent)
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new Error(`Cannot parse split file '${folder}/${file}': ${error.message}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Extract functions and templates from split flow files
|
|
160
|
+
* @param {object} cfg - Splitter configuration
|
|
161
|
+
* @param {string} projectPath - Path to the project
|
|
162
|
+
*/
|
|
163
|
+
function extractFunctionsTemplatesFromSplitFiles(cfg, projectPath) {
|
|
164
|
+
if (cfg.extractFunctionsTemplates === false) {
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
169
|
+
const tabsDir = path.join(srcDir, 'tabs')
|
|
170
|
+
const subflowsDir = path.join(srcDir, 'subflows')
|
|
171
|
+
|
|
172
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Extracting functions and templates...")
|
|
173
|
+
|
|
174
|
+
processFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
|
|
175
|
+
processFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Return a stable fingerprint for the fields that are externalized. Changes
|
|
180
|
+
* to wiring or node metadata do not require rewriting thousands of source
|
|
181
|
+
* files, while edits to function/template content still invalidate it.
|
|
182
|
+
*/
|
|
183
|
+
function getExtractionFingerprint(flowNodes) {
|
|
184
|
+
const hash = crypto.createHash('sha1')
|
|
185
|
+
for (const node of flowNodes) {
|
|
186
|
+
if (node.type !== 'function' && node.type !== 'ui-template' && node.type !== 'template' &&
|
|
187
|
+
node.type !== 'tab' && node.type !== 'subflow') continue
|
|
188
|
+
// Tab/subflow names and node.z decide the extraction directory
|
|
189
|
+
hash.update(JSON.stringify([
|
|
190
|
+
node.id, node.type, node.name, node.label, node.z,
|
|
191
|
+
node.func, node.initialize, node.finalize,
|
|
192
|
+
node.info, node.format, node.template
|
|
193
|
+
]))
|
|
194
|
+
}
|
|
195
|
+
return hash.digest('hex')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Remove split files that are no longer generated (deleted or renamed
|
|
200
|
+
* tabs/subflows/config nodes). Otherwise they are read back and resurrected
|
|
201
|
+
* the next time flows.json is rebuilt from the source tree.
|
|
202
|
+
* @param {string} dir - tabs, subflows or config-nodes directory
|
|
203
|
+
* @param {Set<string>} keep - File names generated by the current split
|
|
204
|
+
*/
|
|
205
|
+
function removeStaleFiles(dir, keep) {
|
|
206
|
+
for (const file of fs.readdirSync(dir)) {
|
|
207
|
+
const filePath = path.join(dir, file)
|
|
208
|
+
if (!keep.has(file) && fs.statSync(filePath).isFile()) {
|
|
209
|
+
fs.unlinkSync(filePath)
|
|
210
|
+
RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed stale file: ${file}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* flows-file-manager currently writes every generated source file on every
|
|
217
|
+
* deploy. Generate into a private staging tree and promote only changed
|
|
218
|
+
* files, so deploys do not churn the project's source tree.
|
|
219
|
+
*/
|
|
220
|
+
function constructTreeFilesIncrementally(flowSet, cfg, projectPath) {
|
|
221
|
+
const stagingName = `.flow-splitter-stage-${process.pid}-${Date.now()}`
|
|
222
|
+
const stagingCfg = { ...cfg, destinationFolder: stagingName }
|
|
223
|
+
const stagingRoot = path.join(projectPath, stagingName)
|
|
224
|
+
const sourceRoot = path.join(projectPath, cfg.destinationFolder)
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const stagedCfg = manager.constructTreeFilesFromFlowSet(flowSet, stagingCfg, projectPath)
|
|
228
|
+
const updatedCfg = { ...stagedCfg, destinationFolder: cfg.destinationFolder }
|
|
229
|
+
for (const folder of ['tabs', 'subflows', 'config-nodes']) {
|
|
230
|
+
const fromDir = path.join(stagingRoot, folder)
|
|
231
|
+
const toDir = path.join(sourceRoot, folder)
|
|
232
|
+
if (!fs.existsSync(fromDir)) continue
|
|
233
|
+
fs.mkdirSync(toDir, { recursive: true })
|
|
234
|
+
const files = fs.readdirSync(fromDir)
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
const from = path.join(fromDir, file)
|
|
237
|
+
const to = path.join(toDir, file)
|
|
238
|
+
if (!fs.existsSync(to) || fs.readFileSync(from).compare(fs.readFileSync(to)) !== 0) {
|
|
239
|
+
writeFileAtomic(to, fs.readFileSync(from))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
removeStaleFiles(toDir, new Set(files))
|
|
243
|
+
}
|
|
244
|
+
return updatedCfg
|
|
245
|
+
} finally {
|
|
246
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true })
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Remove subdirectories that don't have a corresponding flow file
|
|
252
|
+
* @param {string} dir - Directory to clean (tabs or subflows)
|
|
253
|
+
* @param {string} fileFormat - File format (yaml, yml or json), also the file extension
|
|
254
|
+
*/
|
|
255
|
+
function cleanupOrphanedDirectories(dir, fileFormat) {
|
|
256
|
+
if (!fs.existsSync(dir)) {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const extension = `.${fileFormat}`
|
|
261
|
+
|
|
262
|
+
// Get all flow files
|
|
263
|
+
const flowFiles = fs.readdirSync(dir)
|
|
264
|
+
.filter(f => f.endsWith(extension))
|
|
265
|
+
.map(f => path.basename(f, extension))
|
|
266
|
+
|
|
267
|
+
// Get all subdirectories
|
|
268
|
+
const subdirs = fs.readdirSync(dir)
|
|
269
|
+
.filter(f => {
|
|
270
|
+
const fullPath = path.join(dir, f)
|
|
271
|
+
return fs.statSync(fullPath).isDirectory()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Remove orphaned subdirectories
|
|
275
|
+
subdirs.forEach(subdir => {
|
|
276
|
+
if (!flowFiles.includes(subdir)) {
|
|
277
|
+
const subdirPath = path.join(dir, subdir)
|
|
278
|
+
try {
|
|
279
|
+
fs.rmSync(subdirPath, { recursive: true, force: true })
|
|
280
|
+
RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed orphaned directory: ${subdir}`)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not remove orphaned directory ${subdir}: ${error.message}`)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Ensure every tab/subflow/config-node has a non-empty label (tabs) or name
|
|
290
|
+
* (everything else) before handing the flows off to flows-file-manager.
|
|
291
|
+
*
|
|
292
|
+
* Works around a bug in flows-file-manager's disambiguate() function: it
|
|
293
|
+
* unconditionally reads `subelement.config.site.name` as part of its
|
|
294
|
+
* fallback chain (`config[attribute] || subelement[attribute] || subelement.config.site.name || ...`).
|
|
295
|
+
* When a node has no label/name AND no config.site property (the normal case
|
|
296
|
+
* for most nodes), that access throws "Cannot read properties of undefined
|
|
297
|
+
* (reading 'name')" and crashes the flow reload. Guaranteeing a truthy
|
|
298
|
+
* label/name here means the first operand of that chain always short-circuits
|
|
299
|
+
* before the buggy access is ever reached.
|
|
300
|
+
*
|
|
301
|
+
* Only tabs, subflows and config nodes (no x/y, the rule used by
|
|
302
|
+
* @node-red/flow-parser) are disambiguated, so regular nodes are left
|
|
303
|
+
* untouched. The live Node-RED config is never mutated: placeholders go on
|
|
304
|
+
* copies, otherwise they show up in the editor and end up in flows.json.
|
|
305
|
+
* @param {Array} flowNodes - Array of all flow nodes from Node-RED
|
|
306
|
+
* @returns {Array} Copy of flowNodes with guaranteed identifiers
|
|
307
|
+
*/
|
|
308
|
+
function ensureFlowIdentifiers(flowNodes) {
|
|
309
|
+
return flowNodes.map(node => {
|
|
310
|
+
if (node.type !== 'tab' && node.type !== 'subflow' && 'x' in node && 'y' in node) return node
|
|
311
|
+
const attribute = node.type === 'tab' ? 'label' : 'name'
|
|
312
|
+
return node[attribute] ? node : { ...node, [attribute]: node.type }
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Keep flows-file-manager artifacts out of the split files: undo the
|
|
318
|
+
* ensureFlowIdentifiers() placeholders on the parsed copies (flow-parser
|
|
319
|
+
* deep-clones its input) and hide the normalizedLabel added by
|
|
320
|
+
* disambiguate(). The tree writer still reads normalizedLabel for file
|
|
321
|
+
* names, but flow-parser's export() only copies enumerable properties.
|
|
322
|
+
* @param {object} flowSet - FlowSet built by constructFlowSetFromMonolithObject()
|
|
323
|
+
* @param {Array} flowNodes - Original flow nodes from Node-RED
|
|
324
|
+
*/
|
|
325
|
+
function cleanFlowSet(flowSet, flowNodes) {
|
|
326
|
+
const originals = new Map(flowNodes.map(node => [node.id, node]))
|
|
327
|
+
for (const element of [...flowSet.flows.values(), ...flowSet.subflows.values(), ...flowSet.configNodes.values()]) {
|
|
328
|
+
const original = originals.get(element.id)
|
|
329
|
+
const attribute = element.type === 'tab' ? 'label' : 'name'
|
|
330
|
+
if (original && !original[attribute]) {
|
|
331
|
+
if (attribute in original) element.config[attribute] = original[attribute]
|
|
332
|
+
else delete element.config[attribute]
|
|
333
|
+
}
|
|
334
|
+
Object.defineProperty(element.config, 'normalizedLabel', { enumerable: false })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function fastNormalizeString(value) {
|
|
339
|
+
let normalized = String(value || '').replace(/[^a-zA-Z0-9\.]/g, '-').replace(/----/g, '-').replace(/---/g, '-').replace(/--/g, '-').toLowerCase()
|
|
340
|
+
return normalized.replace(/^-+|-+$/g, '')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function fastFileName(node, usedNames) {
|
|
344
|
+
let fileName = fastNormalizeString(node.label || node.name || node.type || node.id)
|
|
345
|
+
if (!fileName) fileName = node.id
|
|
346
|
+
if (usedNames.has(fileName)) fileName = `${fileName}-${node.id}`
|
|
347
|
+
usedNames.add(fileName)
|
|
348
|
+
return fileName
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function fastJsonTree(flowNodes, cfg, projectPath) {
|
|
352
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
353
|
+
const groups = []
|
|
354
|
+
const tabs = flowNodes.filter(node => node.type === 'tab')
|
|
355
|
+
const subflows = flowNodes.filter(node => node.type === 'subflow')
|
|
356
|
+
const usedNames = new Set()
|
|
357
|
+
|
|
358
|
+
const addGroup = (folder, node, contents) => {
|
|
359
|
+
const fileName = fastFileName(node, usedNames)
|
|
360
|
+
groups.push({ folder, fileName, contents: [node].concat(contents.filter(item => item !== node).sort((a, b) => String(a.id).localeCompare(String(b.id)))) })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const tab of tabs) addGroup('tabs', tab, flowNodes.filter(node => node.z === tab.id))
|
|
364
|
+
for (const subflow of subflows) addGroup('subflows', subflow, flowNodes.filter(node => node.z === subflow.id))
|
|
365
|
+
|
|
366
|
+
const nestedIds = new Set(tabs.concat(subflows).map(node => node.id))
|
|
367
|
+
for (const node of flowNodes) {
|
|
368
|
+
if (!node.z && node.type !== 'tab' && node.type !== 'subflow' && !nestedIds.has(node.id)) {
|
|
369
|
+
addGroup('config-nodes', node, [])
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const folder of ['tabs', 'subflows', 'config-nodes']) fs.mkdirSync(path.join(srcDir, folder), { recursive: true })
|
|
374
|
+
for (const group of groups) {
|
|
375
|
+
const filePath = path.join(srcDir, group.folder, `${group.fileName}.json`)
|
|
376
|
+
// normalizedLabel comes from split files written by flows-file-manager
|
|
377
|
+
const content = eol.auto(JSON.stringify(group.contents.map(({ normalizedLabel, ...node }) => node), null, 2))
|
|
378
|
+
if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== content) writeFileAtomic(filePath, content)
|
|
379
|
+
}
|
|
380
|
+
for (const folder of ['tabs', 'subflows', 'config-nodes']) {
|
|
381
|
+
removeStaleFiles(path.join(srcDir, folder), new Set(groups.filter(group => group.folder === folder).map(group => `${group.fileName}.json`)))
|
|
382
|
+
}
|
|
383
|
+
return { ...cfg, tabsOrder: tabs.map(tab => tab.id) }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function extractFunctionsTemplatesFromRawFlow(cfg, projectPath, flowNodes) {
|
|
387
|
+
if (cfg.extractFunctionsTemplates === false) return
|
|
388
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
389
|
+
const tabs = flowNodes.filter(node => node.type === 'tab')
|
|
390
|
+
const subflows = flowNodes.filter(node => node.type === 'subflow')
|
|
391
|
+
const usedNames = new Set()
|
|
392
|
+
const flowName = node => fastFileName(node, usedNames)
|
|
393
|
+
for (const tab of tabs) {
|
|
394
|
+
const name = flowName(tab)
|
|
395
|
+
functionsTemplatesHandler.extractFunctionsAndTemplates([tab].concat(flowNodes.filter(node => node.z === tab.id)), name, path.join(srcDir, 'tabs'), RED)
|
|
396
|
+
}
|
|
397
|
+
for (const subflow of subflows) {
|
|
398
|
+
const name = flowName(subflow)
|
|
399
|
+
functionsTemplatesHandler.extractFunctionsAndTemplates([subflow].concat(flowNodes.filter(node => node.z === subflow.id)), name, path.join(srcDir, 'subflows'), RED)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Process a directory of flow files to extract functions/templates
|
|
405
|
+
* @param {string} dir - Directory to process
|
|
406
|
+
* @param {string} fileFormat - File format (yaml, yml or json), also the file extension
|
|
407
|
+
* @param {string} flowType - Type of flow (tab or subflow)
|
|
408
|
+
*/
|
|
409
|
+
function processFlowDirectory(dir, fileFormat, flowType) {
|
|
410
|
+
if (!fs.existsSync(dir)) {
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const extension = `.${fileFormat}`
|
|
415
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(extension))
|
|
416
|
+
|
|
417
|
+
files.forEach(file => {
|
|
418
|
+
const filePath = path.join(dir, file)
|
|
419
|
+
const flowName = path.basename(file, extension)
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
let flowData
|
|
423
|
+
const fileContent = fs.readFileSync(filePath, 'utf8')
|
|
424
|
+
|
|
425
|
+
if (fileFormat.toLowerCase() !== 'json') {
|
|
426
|
+
flowData = yaml.load(fileContent)
|
|
427
|
+
} else {
|
|
428
|
+
flowData = JSON.parse(fileContent)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const flowNodes = Array.isArray(flowData) ? flowData : [flowData]
|
|
432
|
+
functionsTemplatesHandler.extractFunctionsAndTemplates(flowNodes, flowName, dir, RED)
|
|
433
|
+
|
|
434
|
+
} catch (error) {
|
|
435
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error processing ${flowType} ${flowName}: ${error.message}`)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Restore functions and templates back into split flow files before rebuilding single flows.json file
|
|
442
|
+
* @param {object} cfg - Splitter configuration
|
|
443
|
+
* @param {string} projectPath - Path to the project
|
|
444
|
+
*/
|
|
445
|
+
function restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
|
|
446
|
+
if (cfg.restoreFunctionsTemplates === false) {
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
451
|
+
const tabsDir = path.join(srcDir, 'tabs')
|
|
452
|
+
const subflowsDir = path.join(srcDir, 'subflows')
|
|
453
|
+
|
|
454
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Restoring functions and templates...")
|
|
455
|
+
|
|
456
|
+
restoreIntoFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
|
|
457
|
+
restoreIntoFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Process a directory of flow files to restore functions/templates
|
|
462
|
+
* @param {string} dir - Directory to process
|
|
463
|
+
* @param {string} fileFormat - File format (yaml, yml or json), also the file extension
|
|
464
|
+
* @param {string} flowType - Type of flow (tab or subflow)
|
|
465
|
+
*/
|
|
466
|
+
function restoreIntoFlowDirectory(dir, fileFormat, flowType) {
|
|
467
|
+
if (!fs.existsSync(dir)) {
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const extension = `.${fileFormat}`
|
|
472
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith(extension))
|
|
473
|
+
|
|
474
|
+
files.forEach(file => {
|
|
475
|
+
const filePath = path.join(dir, file)
|
|
476
|
+
const flowName = path.basename(file, extension)
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
let flowData
|
|
480
|
+
const fileContent = fs.readFileSync(filePath, 'utf8')
|
|
481
|
+
|
|
482
|
+
if (fileFormat.toLowerCase() !== 'json') {
|
|
483
|
+
flowData = yaml.load(fileContent)
|
|
484
|
+
} else {
|
|
485
|
+
flowData = JSON.parse(fileContent)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let flowNodes = Array.isArray(flowData) ? flowData : [flowData]
|
|
489
|
+
flowNodes = functionsTemplatesHandler.restoreFunctionsAndTemplates(flowNodes, flowName, dir, RED)
|
|
490
|
+
|
|
491
|
+
if (fileFormat.toLowerCase() !== 'json') {
|
|
492
|
+
const yamlContent = yaml.dump(flowNodes, {
|
|
493
|
+
indent: 2,
|
|
494
|
+
lineWidth: -1,
|
|
495
|
+
noRefs: true,
|
|
496
|
+
sortKeys: false
|
|
497
|
+
})
|
|
498
|
+
writeFileAtomic(filePath, eol.auto(yamlContent))
|
|
499
|
+
} else {
|
|
500
|
+
writeFileAtomic(filePath, eol.auto(JSON.stringify(flowNodes, null, 2)))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
} catch (error) {
|
|
504
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error restoring ${flowType} ${flowName}: ${error.message}`)
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Write a monolith artifact file to artifact/flows.json when enabled.
|
|
511
|
+
* @param {object} cfg - Splitter configuration
|
|
512
|
+
* @param {string} projectPath - Path to the project
|
|
513
|
+
* @param {Array} [flowNodes] - Optional flow nodes to serialize directly
|
|
514
|
+
*/
|
|
515
|
+
function writeMonolithArtifact(cfg, projectPath, flowNodes) {
|
|
516
|
+
if (cfg.enableArtifact !== true) {
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const artifactDir = path.join(projectPath, artifactsDirname)
|
|
521
|
+
const artifactPath = path.join(artifactDir, artifactsFilename)
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
fs.mkdirSync(artifactDir, { recursive: true })
|
|
525
|
+
|
|
526
|
+
if (Array.isArray(flowNodes)) {
|
|
527
|
+
writeFileAtomic(artifactPath, eol.auto(JSON.stringify(flowNodes, null, 2)))
|
|
528
|
+
} else {
|
|
529
|
+
const monolithPath = path.join(projectPath, cfg.monolithFilename || RED.settings.flowFile || 'flows.json')
|
|
530
|
+
|
|
531
|
+
if (!fs.existsSync(monolithPath)) {
|
|
532
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not write artifact: monolith file not found at '${monolithPath}'`)
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
writeFileAtomic(artifactPath, fs.readFileSync(monolithPath))
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
RED.log.info(`[node-red-contrib-flow-splitter-extended] Wrote monolith artifact at '${artifactPath}'`)
|
|
540
|
+
} catch (error) {
|
|
541
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not write monolith artifact: ${error.message}`)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Restore flows.json from the artifact backup after a failed rebuild from
|
|
547
|
+
* split source files. Only usable when `enableArtifact` is true and a
|
|
548
|
+
* previous successful reload has left a backup on disk.
|
|
549
|
+
* @param {object} cfg - Splitter configuration
|
|
550
|
+
* @param {string} projectPath - Path to the project
|
|
551
|
+
* @returns {boolean} true if the restore succeeded
|
|
552
|
+
*/
|
|
553
|
+
function restoreFromArtifactFallback(cfg, projectPath) {
|
|
554
|
+
const artifactPath = path.join(projectPath, artifactsDirname, artifactsFilename)
|
|
555
|
+
|
|
556
|
+
if (!fs.existsSync(artifactPath)) {
|
|
557
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Rebuild from source files failed and no artifact backup exists at '${artifactPath}' - flows will remain empty. Enable 'enableArtifact' and let at least one successful reload run to create a backup.`)
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const monolithPath = path.join(projectPath, cfg.monolithFilename || RED.settings.flowFile || 'flows.json')
|
|
563
|
+
writeFileAtomic(monolithPath, fs.readFileSync(artifactPath))
|
|
564
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] Rebuild from source files failed - restored flows from artifact backup '${artifactPath}' instead. Check 'src/' for corrupted split files.`)
|
|
565
|
+
return true
|
|
566
|
+
} catch (error) {
|
|
567
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Rebuild failed AND could not restore from artifact backup: ${error.message}`)
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Manual reload endpoint handler
|
|
574
|
+
* Restores functions/templates from files and reloads flows
|
|
575
|
+
*/
|
|
576
|
+
async function manualReload(req, res) {
|
|
577
|
+
try {
|
|
578
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Manual reload triggered")
|
|
579
|
+
|
|
580
|
+
const projectPath = getProjectPath()
|
|
581
|
+
const cfg = loadSplitterConfig(projectPath)
|
|
582
|
+
|
|
583
|
+
// The endpoint exists to apply external edits, so restore regardless
|
|
584
|
+
// of restoreFunctionsTemplates (which only gates the boot rebuild)
|
|
585
|
+
restoreFunctionsTemplatesIntoSplitFiles({ ...cfg, restoreFunctionsTemplates: true }, projectPath)
|
|
586
|
+
assertSplitFilesParse(cfg, projectPath)
|
|
587
|
+
|
|
588
|
+
const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
|
|
589
|
+
|
|
590
|
+
if (!flowSet) {
|
|
591
|
+
RED.log.error("[node-red-contrib-flow-splitter-extended] Cannot build FlowSet from source tree files")
|
|
592
|
+
return res.status(500).json({
|
|
593
|
+
success: false,
|
|
594
|
+
error: "Cannot build FlowSet from source tree files"
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
manager.constructMonolithFileFromFlowSet(flowSet, cfg, projectPath, false)
|
|
599
|
+
writeMonolithArtifact(cfg, projectPath)
|
|
600
|
+
|
|
601
|
+
const PRIVATE_RED = getPrivateRED()
|
|
602
|
+
await PRIVATE_RED.nodes.loadFlows(true)
|
|
603
|
+
|
|
604
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Manual reload completed successfully")
|
|
605
|
+
|
|
606
|
+
res.json({
|
|
607
|
+
success: true,
|
|
608
|
+
message: "Functions and templates reloaded successfully"
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
} catch (error) {
|
|
612
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Manual reload failed: ${error.message}`)
|
|
613
|
+
res.status(500).json({
|
|
614
|
+
success: false,
|
|
615
|
+
error: error.message
|
|
616
|
+
})
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Node-RED keeps ONE global-config node (global env vars, palette modules),
|
|
622
|
+
* but nothing stops an editor plugin from adding a second one: the folders
|
|
623
|
+
* plugin creates its own when it saves before the flows are loaded. Every
|
|
624
|
+
* copy then lands in config-nodes/, comes back on the next rebuild and the
|
|
625
|
+
* pile grows with each editor session. Keep the first one and fold the
|
|
626
|
+
* others into it: for an env name present twice the first value wins, which
|
|
627
|
+
* is also the copy the folders plugin reads.
|
|
628
|
+
* @param {Array} flowNodes - Array of all flow nodes from Node-RED
|
|
629
|
+
* @returns {Array} flowNodes with at most one global-config node
|
|
630
|
+
*/
|
|
631
|
+
function mergeGlobalConfigs(flowNodes) {
|
|
632
|
+
const configs = flowNodes.filter(node => node.type === 'global-config')
|
|
633
|
+
if (configs.length < 2) return flowNodes
|
|
634
|
+
const [first, ...others] = configs
|
|
635
|
+
const env = [...(first.env || [])]
|
|
636
|
+
const modules = { ...(first.modules || {}) }
|
|
637
|
+
for (const other of others) {
|
|
638
|
+
for (const item of other.env || []) {
|
|
639
|
+
if (!env.some(e => e.name === item.name)) env.push(item)
|
|
640
|
+
}
|
|
641
|
+
for (const [name, version] of Object.entries(other.modules || {})) {
|
|
642
|
+
if (!(name in modules)) modules[name] = version
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
RED.log.warn(`[node-red-contrib-flow-splitter-extended] ${configs.length} global-config nodes found, kept ${first.id} in the split files. Remove the extra ones from the editor (${others.map(n => n.id).join(', ')}).`)
|
|
646
|
+
const merged = { ...first, env, modules }
|
|
647
|
+
const dropped = new Set(others.map(n => n.id))
|
|
648
|
+
return flowNodes.filter(node => !dropped.has(node.id)).map(node => node === first ? merged : node)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Main function executed on each flow restart
|
|
653
|
+
* @param {FlowStartedEventType} flowEventData
|
|
654
|
+
* @returns {void}
|
|
655
|
+
*/
|
|
656
|
+
async function onFlowReload(flowEventData) {
|
|
657
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Flow restart event")
|
|
658
|
+
|
|
659
|
+
const projectPath = getProjectPath()
|
|
660
|
+
if (!projectPath) {
|
|
661
|
+
RED.log.warn("[node-red-contrib-flow-splitter-extended] RED.settings.userDir is not available yet, skipping flow-splitter processing for this event")
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
const cfg = loadSplitterConfig(projectPath)
|
|
665
|
+
|
|
666
|
+
if (flowEventData.config.flows.length === 0) {
|
|
667
|
+
// The flow file does not exist or is empty - rebuild from split files
|
|
668
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Rebuilding single flows.json file from source files")
|
|
669
|
+
|
|
670
|
+
let rebuildFailed = false
|
|
671
|
+
let updatedCfg = cfg
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
|
|
675
|
+
assertSplitFilesParse(cfg, projectPath)
|
|
676
|
+
|
|
677
|
+
const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
|
|
678
|
+
|
|
679
|
+
if (!flowSet) {
|
|
680
|
+
RED.log.error("[node-red-contrib-flow-splitter-extended] Cannot build FlowSet from source tree files")
|
|
681
|
+
rebuildFailed = true
|
|
682
|
+
} else {
|
|
683
|
+
updatedCfg = manager.constructMonolithFileFromFlowSet(flowSet, cfg, projectPath, false)
|
|
684
|
+
writeSplitterConfig(updatedCfg, projectPath)
|
|
685
|
+
writeMonolithArtifact(updatedCfg, projectPath)
|
|
686
|
+
}
|
|
687
|
+
} catch (error) {
|
|
688
|
+
// Anything from a malformed YAML/JSON split file to a bug in
|
|
689
|
+
// flows-file-manager itself can throw here. Treat it the same
|
|
690
|
+
// as an explicit "cannot build" failure: fall back below rather
|
|
691
|
+
// than letting Node-RED come up with zero flows silently.
|
|
692
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Rebuild from source files threw an error: ${error.message}`)
|
|
693
|
+
rebuildFailed = true
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (rebuildFailed) {
|
|
697
|
+
if (cfg.enableArtifact !== true) {
|
|
698
|
+
RED.log.error("[node-red-contrib-flow-splitter-extended] Rebuild failed and 'enableArtifact' is disabled, so there is no backup to fall back to. Flows will remain empty. Enable 'enableArtifact' in .config.flow-splitter.json to get automatic fallback next time.")
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
if (!restoreFromArtifactFallback(cfg, projectPath)) {
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Stopping and loading nodes")
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
await getPrivateRED().nodes.loadFlows(true)
|
|
710
|
+
RED.log.info(rebuildFailed
|
|
711
|
+
? "[node-red-contrib-flow-splitter-extended] Flows loaded from artifact fallback"
|
|
712
|
+
: "[node-red-contrib-flow-splitter-extended] Flows are rebuilt and available")
|
|
713
|
+
} catch (error) {
|
|
714
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Could not load the rebuilt flows: ${error.message}. Restart Node-RED to load them.`)
|
|
715
|
+
}
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Flows exist - split into source files
|
|
720
|
+
const flows = mergeGlobalConfigs(flowEventData.config.flows)
|
|
721
|
+
|
|
722
|
+
let updatedCfg
|
|
723
|
+
if (cfg.fileFormat === 'json' && cfg.fastJsonSplit !== false) {
|
|
724
|
+
updatedCfg = fastJsonTree(flows, cfg, projectPath)
|
|
725
|
+
} else {
|
|
726
|
+
let flowSet
|
|
727
|
+
// Guarantee a label/name to avoid a crash inside flows-file-manager's
|
|
728
|
+
// disambiguate() (see ensureFlowIdentifiers doc above)
|
|
729
|
+
try {
|
|
730
|
+
flowSet = manager.constructFlowSetFromMonolithObject(ensureFlowIdentifiers(flows))
|
|
731
|
+
} catch (error) {
|
|
732
|
+
RED.log.error(`[node-red-contrib-flow-splitter-extended] Failed to build FlowSet from monolith object (flows-file-manager error): ${error.message}`)
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
cleanFlowSet(flowSet, flows)
|
|
736
|
+
updatedCfg = constructTreeFilesIncrementally(flowSet, cfg, projectPath)
|
|
737
|
+
}
|
|
738
|
+
writeSplitterConfig(updatedCfg, projectPath)
|
|
739
|
+
// Clean up extraction directories of renamed/deleted flows
|
|
740
|
+
const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
|
|
741
|
+
cleanupOrphanedDirectories(path.join(srcDir, 'tabs'), cfg.fileFormat)
|
|
742
|
+
cleanupOrphanedDirectories(path.join(srcDir, 'subflows'), cfg.fileFormat)
|
|
743
|
+
// Node-RED has already persisted the current monolith. Copying it is much
|
|
744
|
+
// cheaper than JSON.stringify-ing the complete flow again.
|
|
745
|
+
writeMonolithArtifact(updatedCfg, projectPath)
|
|
746
|
+
|
|
747
|
+
const extractionFingerprint = getExtractionFingerprint(flows)
|
|
748
|
+
if (extractionFingerprint !== lastExtractionFingerprint) {
|
|
749
|
+
if (cfg.fileFormat === 'json' && cfg.fastJsonSplit !== false) {
|
|
750
|
+
extractFunctionsTemplatesFromRawFlow(updatedCfg, projectPath, flows)
|
|
751
|
+
} else {
|
|
752
|
+
extractFunctionsTemplatesFromSplitFiles(updatedCfg, projectPath)
|
|
753
|
+
}
|
|
754
|
+
lastExtractionFingerprint = extractionFingerprint
|
|
755
|
+
} else {
|
|
756
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Skipping unchanged function/template extraction")
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// NOTE: earlier versions of this plugin deleted RED.settings.flowFile here
|
|
760
|
+
// to force the "rebuild from src/" branch above on the next start. That
|
|
761
|
+
// meant the monolith flows.json only ever existed transiently on disk -
|
|
762
|
+
// if this plugin were ever removed (npm uninstall, or simply not carried
|
|
763
|
+
// over into a new image), Node-RED would find no flowFile to boot from
|
|
764
|
+
// and come up with zero flows, with no plugin left to rebuild it.
|
|
765
|
+
//
|
|
766
|
+
// We deliberately do NOT delete it anymore. Node-RED's own deploy/storage
|
|
767
|
+
// layer already wrote a fully current flowFile to disk as part of this
|
|
768
|
+
// same deploy - we simply leave it in place. This plugin's split files in
|
|
769
|
+
// 'src/' remain the source you actually edit and diff in git (still add
|
|
770
|
+
// the flowFile itself to .gitignore for that reason), but the flowFile on
|
|
771
|
+
// disk is always a valid, current, plugin-independent fallback: if this
|
|
772
|
+
// plugin is ever uninstalled, vanilla Node-RED keeps booting normally from
|
|
773
|
+
// it, with zero data loss and zero code changes required.
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* @param {REDType} REDRuntime
|
|
778
|
+
*/
|
|
779
|
+
module.exports = function (REDRuntime) {
|
|
780
|
+
RED = REDRuntime
|
|
781
|
+
|
|
782
|
+
// Register the plugin for Node-RED
|
|
783
|
+
RED.plugins.registerPlugin("node-red-contrib-flow-splitter-extended", {
|
|
784
|
+
type: "exotec-deploy-plugins",
|
|
785
|
+
onadd: function () {
|
|
786
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Initialized plugin successfully")
|
|
787
|
+
}
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
// Register HTTP endpoint for manual reload
|
|
791
|
+
RED.httpAdmin.post("/flow-splitter/reload", manualReload)
|
|
792
|
+
RED.log.info("[node-red-contrib-flow-splitter-extended] Manual reload endpoint registered at POST /flow-splitter/reload")
|
|
793
|
+
|
|
794
|
+
// Code to launch on every restart of the flows = boot or deploy event
|
|
795
|
+
RED.events.on('flows:started', onFlowReload)
|
|
796
|
+
}
|