@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/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
+ }