@malysus/node-red-contrib-flow-splitter-extended 0.0.0-stage → 1.4.1

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,764 @@
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
+ * Main function executed on each flow restart
622
+ * @param {FlowStartedEventType} flowEventData
623
+ * @returns {void}
624
+ */
625
+ async function onFlowReload(flowEventData) {
626
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Flow restart event")
627
+
628
+ const projectPath = getProjectPath()
629
+ if (!projectPath) {
630
+ RED.log.warn("[node-red-contrib-flow-splitter-extended] RED.settings.userDir is not available yet, skipping flow-splitter processing for this event")
631
+ return
632
+ }
633
+ const cfg = loadSplitterConfig(projectPath)
634
+
635
+ if (flowEventData.config.flows.length === 0) {
636
+ // The flow file does not exist or is empty - rebuild from split files
637
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Rebuilding single flows.json file from source files")
638
+
639
+ let rebuildFailed = false
640
+ let updatedCfg = cfg
641
+
642
+ try {
643
+ restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
644
+ assertSplitFilesParse(cfg, projectPath)
645
+
646
+ const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
647
+
648
+ if (!flowSet) {
649
+ RED.log.error("[node-red-contrib-flow-splitter-extended] Cannot build FlowSet from source tree files")
650
+ rebuildFailed = true
651
+ } else {
652
+ updatedCfg = manager.constructMonolithFileFromFlowSet(flowSet, cfg, projectPath, false)
653
+ writeSplitterConfig(updatedCfg, projectPath)
654
+ writeMonolithArtifact(updatedCfg, projectPath)
655
+ }
656
+ } catch (error) {
657
+ // Anything from a malformed YAML/JSON split file to a bug in
658
+ // flows-file-manager itself can throw here. Treat it the same
659
+ // as an explicit "cannot build" failure: fall back below rather
660
+ // than letting Node-RED come up with zero flows silently.
661
+ RED.log.error(`[node-red-contrib-flow-splitter-extended] Rebuild from source files threw an error: ${error.message}`)
662
+ rebuildFailed = true
663
+ }
664
+
665
+ if (rebuildFailed) {
666
+ if (cfg.enableArtifact !== true) {
667
+ 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.")
668
+ return
669
+ }
670
+ if (!restoreFromArtifactFallback(cfg, projectPath)) {
671
+ return
672
+ }
673
+ }
674
+
675
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Stopping and loading nodes")
676
+
677
+ try {
678
+ await getPrivateRED().nodes.loadFlows(true)
679
+ RED.log.info(rebuildFailed
680
+ ? "[node-red-contrib-flow-splitter-extended] Flows loaded from artifact fallback"
681
+ : "[node-red-contrib-flow-splitter-extended] Flows are rebuilt and available")
682
+ } catch (error) {
683
+ RED.log.error(`[node-red-contrib-flow-splitter-extended] Could not load the rebuilt flows: ${error.message}. Restart Node-RED to load them.`)
684
+ }
685
+ return
686
+ }
687
+
688
+ // Flows exist - split into source files
689
+
690
+ let updatedCfg
691
+ if (cfg.fileFormat === 'json' && cfg.fastJsonSplit !== false) {
692
+ updatedCfg = fastJsonTree(flowEventData.config.flows, cfg, projectPath)
693
+ } else {
694
+ let flowSet
695
+ // Guarantee a label/name to avoid a crash inside flows-file-manager's
696
+ // disambiguate() (see ensureFlowIdentifiers doc above)
697
+ try {
698
+ flowSet = manager.constructFlowSetFromMonolithObject(ensureFlowIdentifiers(flowEventData.config.flows))
699
+ } catch (error) {
700
+ RED.log.error(`[node-red-contrib-flow-splitter-extended] Failed to build FlowSet from monolith object (flows-file-manager error): ${error.message}`)
701
+ return
702
+ }
703
+ cleanFlowSet(flowSet, flowEventData.config.flows)
704
+ updatedCfg = constructTreeFilesIncrementally(flowSet, cfg, projectPath)
705
+ }
706
+ writeSplitterConfig(updatedCfg, projectPath)
707
+ // Clean up extraction directories of renamed/deleted flows
708
+ const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
709
+ cleanupOrphanedDirectories(path.join(srcDir, 'tabs'), cfg.fileFormat)
710
+ cleanupOrphanedDirectories(path.join(srcDir, 'subflows'), cfg.fileFormat)
711
+ // Node-RED has already persisted the current monolith. Copying it is much
712
+ // cheaper than JSON.stringify-ing the complete flow again.
713
+ writeMonolithArtifact(updatedCfg, projectPath)
714
+
715
+ const extractionFingerprint = getExtractionFingerprint(flowEventData.config.flows)
716
+ if (extractionFingerprint !== lastExtractionFingerprint) {
717
+ if (cfg.fileFormat === 'json' && cfg.fastJsonSplit !== false) {
718
+ extractFunctionsTemplatesFromRawFlow(updatedCfg, projectPath, flowEventData.config.flows)
719
+ } else {
720
+ extractFunctionsTemplatesFromSplitFiles(updatedCfg, projectPath)
721
+ }
722
+ lastExtractionFingerprint = extractionFingerprint
723
+ } else {
724
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Skipping unchanged function/template extraction")
725
+ }
726
+
727
+ // NOTE: earlier versions of this plugin deleted RED.settings.flowFile here
728
+ // to force the "rebuild from src/" branch above on the next start. That
729
+ // meant the monolith flows.json only ever existed transiently on disk -
730
+ // if this plugin were ever removed (npm uninstall, or simply not carried
731
+ // over into a new image), Node-RED would find no flowFile to boot from
732
+ // and come up with zero flows, with no plugin left to rebuild it.
733
+ //
734
+ // We deliberately do NOT delete it anymore. Node-RED's own deploy/storage
735
+ // layer already wrote a fully current flowFile to disk as part of this
736
+ // same deploy - we simply leave it in place. This plugin's split files in
737
+ // 'src/' remain the source you actually edit and diff in git (still add
738
+ // the flowFile itself to .gitignore for that reason), but the flowFile on
739
+ // disk is always a valid, current, plugin-independent fallback: if this
740
+ // plugin is ever uninstalled, vanilla Node-RED keeps booting normally from
741
+ // it, with zero data loss and zero code changes required.
742
+ }
743
+
744
+ /**
745
+ * @param {REDType} REDRuntime
746
+ */
747
+ module.exports = function (REDRuntime) {
748
+ RED = REDRuntime
749
+
750
+ // Register the plugin for Node-RED
751
+ RED.plugins.registerPlugin("node-red-contrib-flow-splitter-extended", {
752
+ type: "exotec-deploy-plugins",
753
+ onadd: function () {
754
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Initialized plugin successfully")
755
+ }
756
+ })
757
+
758
+ // Register HTTP endpoint for manual reload
759
+ RED.httpAdmin.post("/flow-splitter/reload", manualReload)
760
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Manual reload endpoint registered at POST /flow-splitter/reload")
761
+
762
+ // Code to launch on every restart of the flows = boot or deploy event
763
+ RED.events.on('flows:started', onFlowReload)
764
+ }