@gmag11/nodered-mcp-server 1.0.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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +162 -0
  3. package/index.js +133 -0
  4. package/package.json +58 -0
  5. package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
  6. package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
  7. package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
  8. package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
  9. package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
  10. package/resources/skills/nodered-mustache/SKILL.md +588 -0
  11. package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
  12. package/resources/skills/nodered-node-reference/examples/common.json +113 -0
  13. package/resources/skills/nodered-node-reference/examples/network.json +107 -0
  14. package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
  15. package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
  16. package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
  17. package/resources/skills/nodered-patterns/SKILL.md +414 -0
  18. package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
  19. package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
  20. package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
  21. package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
  22. package/resources/skills/nodered-subflows/SKILL.md +261 -0
  23. package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
  24. package/src/auth/api-key-verifier.js +36 -0
  25. package/src/auth/composite-verifier.js +59 -0
  26. package/src/auth/config.js +106 -0
  27. package/src/auth/oauth-clients-store.js +107 -0
  28. package/src/auth/oauth-provider.js +149 -0
  29. package/src/auth/oauth-token-store.js +312 -0
  30. package/src/nodered/auth.js +158 -0
  31. package/src/nodered/client.js +199 -0
  32. package/src/nodered/comms-client.js +500 -0
  33. package/src/renderer/colors.js +161 -0
  34. package/src/renderer/geometry.js +115 -0
  35. package/src/renderer/html-builder.js +571 -0
  36. package/src/renderer/index.js +51 -0
  37. package/src/renderer/ir-builder.js +161 -0
  38. package/src/renderer/layout.js +126 -0
  39. package/src/renderer/mermaid-builder.js +109 -0
  40. package/src/renderer/svg-builder.js +228 -0
  41. package/src/schemas/responses.js +283 -0
  42. package/src/server.js +844 -0
  43. package/src/skills/loader.js +84 -0
  44. package/src/staging-store.js +258 -0
  45. package/src/tools/add-nodes-to-group.js +216 -0
  46. package/src/tools/connect-nodes.js +115 -0
  47. package/src/tools/constants.js +45 -0
  48. package/src/tools/create-flow.js +87 -0
  49. package/src/tools/create-node.js +126 -0
  50. package/src/tools/create-subflow-instance.js +123 -0
  51. package/src/tools/create-subflow.js +101 -0
  52. package/src/tools/delete-context.js +60 -0
  53. package/src/tools/delete-flow.js +81 -0
  54. package/src/tools/delete-group.js +116 -0
  55. package/src/tools/delete-node.js +73 -0
  56. package/src/tools/delete-subflow.js +103 -0
  57. package/src/tools/deploy.js +94 -0
  58. package/src/tools/disconnect-nodes.js +158 -0
  59. package/src/tools/export-flow.js +161 -0
  60. package/src/tools/export-subflow.js +78 -0
  61. package/src/tools/flow-utils.js +376 -0
  62. package/src/tools/get-config-nodes.js +86 -0
  63. package/src/tools/get-context.js +76 -0
  64. package/src/tools/get-flow-diagram.js +99 -0
  65. package/src/tools/get-flow-nodes.js +116 -0
  66. package/src/tools/get-flows.js +74 -0
  67. package/src/tools/get-node-detail.js +77 -0
  68. package/src/tools/get-node-type-detail.js +92 -0
  69. package/src/tools/get-palette-nodes.js +63 -0
  70. package/src/tools/get-staging-status.js +34 -0
  71. package/src/tools/get-subflow-detail.js +110 -0
  72. package/src/tools/get-subflows.js +105 -0
  73. package/src/tools/import-flow.js +310 -0
  74. package/src/tools/inject-message.js +117 -0
  75. package/src/tools/install-node.js +31 -0
  76. package/src/tools/read-debug-messages.js +155 -0
  77. package/src/tools/refresh-staging.js +62 -0
  78. package/src/tools/remove-nodes-from-group.js +162 -0
  79. package/src/tools/render-staging.js +69 -0
  80. package/src/tools/response-utils.js +42 -0
  81. package/src/tools/search-nodes.js +134 -0
  82. package/src/tools/uninstall-node.js +31 -0
  83. package/src/tools/update-flow.js +95 -0
  84. package/src/tools/update-group.js +77 -0
  85. package/src/tools/update-node.js +132 -0
  86. package/src/tools/update-subflow.js +84 -0
  87. package/src/transport/http.js +252 -0
  88. package/src/transport/stdio.js +16 -0
  89. package/src/transport/ws-server.js +223 -0
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Node-RED Comms WebSocket client.
3
+ *
4
+ * Maintains a persistent WebSocket connection to Node-RED's /comms endpoint
5
+ * using the Socket.IO v4 / Engine.IO v4 protocol. Buffers incoming debug
6
+ * messages in a fixed-size ring buffer and supports optional authentication.
7
+ *
8
+ * After connecting, the client subscribes to the "debug" event topic so
9
+ * that Node-RED pushes debug node output to this client.
10
+ *
11
+ * The WebSocket URL is derived from the HTTP baseUrl (replacing http(s)://
12
+ * with ws(s)://) and includes the required Engine.IO query parameters
13
+ * (EIO=4, transport=websocket). If an access token is provided, it is
14
+ * also appended as a query parameter.
15
+ */
16
+
17
+ import WebSocket from 'ws';
18
+ import { EventEmitter } from 'node:events';
19
+ import { getToken } from './auth.js';
20
+
21
+ /** Default ring buffer capacity. */
22
+ const DEFAULT_BUFFER_SIZE = 500;
23
+
24
+ /** Minimum allowed buffer size. */
25
+ const MIN_BUFFER_SIZE = 10;
26
+
27
+ /** Maximum allowed buffer size. */
28
+ const MAX_BUFFER_SIZE = 10000;
29
+
30
+ /** Initial reconnect delay in ms. */
31
+ const INITIAL_RECONNECT_DELAY = 1000;
32
+
33
+ /** Maximum reconnect delay in ms. */
34
+ const MAX_RECONNECT_DELAY = 30000;
35
+
36
+ /** Reconnect delay multiplier (exponential backoff). */
37
+ const RECONNECT_MULTIPLIER = 2;
38
+
39
+ /**
40
+ * Parse the buffer size from the NODE_RED_DEBUG_BUFFER_SIZE env var.
41
+ * Falls back to DEFAULT_BUFFER_SIZE if unset or invalid; clamps to
42
+ * MIN_BUFFER_SIZE / MAX_BUFFER_SIZE.
43
+ *
44
+ * @returns {number}
45
+ */
46
+ function parseBufferSize() {
47
+ const raw = process.env.NODE_RED_DEBUG_BUFFER_SIZE;
48
+ if (raw === undefined || raw === '') {
49
+ return DEFAULT_BUFFER_SIZE;
50
+ }
51
+ const parsed = parseInt(raw, 10);
52
+ if (Number.isNaN(parsed)) {
53
+ console.error(
54
+ `[CommsClient] Invalid NODE_RED_DEBUG_BUFFER_SIZE="${raw}", using default ${DEFAULT_BUFFER_SIZE}`,
55
+ );
56
+ return DEFAULT_BUFFER_SIZE;
57
+ }
58
+ if (parsed < MIN_BUFFER_SIZE) {
59
+ console.error(
60
+ `[CommsClient] NODE_RED_DEBUG_BUFFER_SIZE=${parsed} below minimum ${MIN_BUFFER_SIZE}, clamping`,
61
+ );
62
+ return MIN_BUFFER_SIZE;
63
+ }
64
+ if (parsed > MAX_BUFFER_SIZE) {
65
+ console.error(
66
+ `[CommsClient] NODE_RED_DEBUG_BUFFER_SIZE=${parsed} above maximum ${MAX_BUFFER_SIZE}, clamping`,
67
+ );
68
+ return MAX_BUFFER_SIZE;
69
+ }
70
+ return parsed;
71
+ }
72
+
73
+ /**
74
+ * Build the WebSocket URL from an HTTP base URL and optional token.
75
+ *
76
+ * Adds the required Engine.IO v4 / Socket.IO query parameters so the
77
+ * server recognises the connection as a Socket.IO WebSocket transport.
78
+ *
79
+ * @param {string} baseUrl - e.g. "http://localhost:1880"
80
+ * @param {string} [token] - Bearer access token
81
+ * @returns {string} e.g. "ws://localhost:1880/comms?EIO=4&transport=websocket&access_token=xxx"
82
+ */
83
+ function buildWsUrl(baseUrl, token) {
84
+ // Replace http(s):// with ws(s)://
85
+ const wsBase = baseUrl.replace(/^http/, 'ws');
86
+ const params = new URLSearchParams();
87
+ // Engine.IO v4 + WebSocket transport — required for Socket.IO to
88
+ // recognise this as a valid WebSocket upgrade
89
+ params.set('EIO', '4');
90
+ params.set('transport', 'websocket');
91
+ if (token) {
92
+ params.set('access_token', token);
93
+ }
94
+ return `${wsBase}/comms?${params.toString()}`;
95
+ }
96
+
97
+ /**
98
+ * Parse a single Engine.IO v4 / Socket.IO v4 text frame.
99
+ *
100
+ * Engine.IO v4 packet types:
101
+ * 0 — open (server → client, contains SID + ping config)
102
+ * 1 — close
103
+ * 2 — ping (bidirectional; responder replies with 3)
104
+ * 3 — pong
105
+ * 4 — message (wraps a Socket.IO packet)
106
+ *
107
+ * Socket.IO v4 packet types (inside Engine.IO message 4):
108
+ * 0 — connect
109
+ * 1 — disconnect
110
+ * 2 — event (e.g. 42["debug", {...}])
111
+ * 3 — ack
112
+ * 4 — connect_error
113
+ *
114
+ * Returns an object with shape:
115
+ * - { type: 'pong' } when the caller should reply with `3`
116
+ * - { type: 'connected' } on Socket.IO connect ack (`40`)
117
+ * - { type: 'event', topic: string, payload: any } on `42[...]`
118
+ * - { type: 'open', sid: string } on Engine.IO open (`0{...}`)
119
+ * - null when the frame should be silently dropped
120
+ *
121
+ * @param {string} frame - Raw text frame from WebSocket
122
+ * @returns {{ type: 'pong' } | { type: 'connected' } | { type: 'event', topic: string, payload: any } | { type: 'open', sid: string } | null}
123
+ */
124
+ function parseSocketIOFrame(frame) {
125
+ // Engine.IO open packet (server sends first)
126
+ if (frame.startsWith('0') && frame.length > 1) {
127
+ try {
128
+ const openData = JSON.parse(frame.substring(1));
129
+ return { type: 'open', sid: openData.sid || 'unknown' };
130
+ } catch {
131
+ // Malformed open packet — ignore
132
+ }
133
+ }
134
+
135
+ // Engine.IO ping → respond with pong
136
+ if (frame === '2') {
137
+ return { type: 'pong' };
138
+ }
139
+
140
+ // Engine.IO message wrapping Socket.IO connect ack
141
+ if (frame === '40') {
142
+ return { type: 'connected' };
143
+ }
144
+
145
+ // Engine.IO message wrapping Socket.IO event: 42["topic", data]
146
+ if (frame.startsWith('42')) {
147
+ try {
148
+ const parsed = JSON.parse(frame.substring(2));
149
+ if (Array.isArray(parsed) && parsed.length >= 1) {
150
+ return {
151
+ type: 'event',
152
+ topic: parsed[0],
153
+ payload: parsed.length >= 2 ? parsed[1] : null,
154
+ };
155
+ }
156
+ } catch {
157
+ // Malformed JSON — ignore
158
+ }
159
+ }
160
+
161
+ // Plain JSON array of events (Socket.IO v2 raw format):
162
+ // [{"topic":"debug","data":{...}}, ...]
163
+ // Used by some Node-RED configurations where Engine.IO framing
164
+ // is stripped after the initial handshake.
165
+ if (frame.startsWith('[')) {
166
+ try {
167
+ const parsed = JSON.parse(frame);
168
+ if (Array.isArray(parsed)) {
169
+ const events = [];
170
+ for (const item of parsed) {
171
+ if (item && typeof item === 'object' && item.topic) {
172
+ events.push({
173
+ type: 'event',
174
+ topic: item.topic,
175
+ // Node-RED wraps the debug payload inside a `data` property
176
+ payload: item.data !== undefined ? item.data : item,
177
+ });
178
+ }
179
+ }
180
+ if (events.length > 0) {
181
+ // Return first event inline; caller processes the rest via
182
+ // the returned `_batch` property.
183
+ const [first, ...rest] = events;
184
+ first._batch = rest;
185
+ return first;
186
+ }
187
+ }
188
+ } catch {
189
+ // Malformed JSON — ignore
190
+ }
191
+ }
192
+
193
+ // Unknown or unhandled Engine.IO frame type
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Socket.IO v4 / Engine.IO v4 comms client for a single Node-RED instance.
199
+ *
200
+ * Emits the following events:
201
+ * - 'debug' ({ id, name, msg, format, path, timestamp }) — debug message received
202
+ * - 'connected' () — WebSocket connection established, handshake complete, and subscribed to debug events
203
+ * - 'disconnected' () — WebSocket connection lost
204
+ * - 'error' (Error) — non-fatal error
205
+ */
206
+ export class CommsClient extends EventEmitter {
207
+ #baseUrl;
208
+ #username;
209
+ #password;
210
+ #token = null;
211
+ #wsUrl;
212
+ #ws = null;
213
+ #buffer = [];
214
+ #maxSize;
215
+ #reconnectDelay = INITIAL_RECONNECT_DELAY;
216
+ #reconnectTimer = null;
217
+ #intentionalClose = false;
218
+ #connected = false;
219
+
220
+ /**
221
+ * @param {object} config
222
+ * @param {string} config.baseUrl - Node-RED instance URL (e.g. "http://localhost:1880")
223
+ * @param {string} [config.username] - Username for credentials auth
224
+ * @param {string} [config.password] - Password for credentials auth
225
+ * @param {string} [config.token] - Pre-obtained bearer access token (takes precedence)
226
+ */
227
+ constructor({ baseUrl, username, password, token } = {}) {
228
+ super();
229
+ if (!baseUrl) {
230
+ throw new Error('CommsClient requires a baseUrl. Set the NODERED_URL environment variable or provide baseUrl in the server configuration.');
231
+ }
232
+ this.#baseUrl = baseUrl;
233
+ this.#username = username || null;
234
+ this.#password = password || null;
235
+ // Pre-obtained token takes precedence; otherwise will be fetched in connect()
236
+ this.#token = token || null;
237
+ this.#wsUrl = buildWsUrl(baseUrl, this.#token);
238
+ this.#maxSize = parseBufferSize();
239
+ }
240
+
241
+ /**
242
+ * Open the WebSocket connection and begin buffering messages.
243
+ *
244
+ * If username/password are configured and no pre-obtained token was
245
+ * provided, this method will first fetch a session token from the
246
+ * Node-RED HTTP auth flow before opening the WebSocket.
247
+ *
248
+ * Safe to call multiple times — subsequent calls are no-ops if already
249
+ * connected or connecting.
250
+ *
251
+ * @returns {Promise<void>}
252
+ */
253
+ async connect() {
254
+ if (this.#ws && (this.#ws.readyState === WebSocket.OPEN || this.#ws.readyState === WebSocket.CONNECTING)) {
255
+ return;
256
+ }
257
+
258
+ this.#intentionalClose = false;
259
+
260
+ // If no pre-obtained token but credentials are provided, fetch one
261
+ if (!this.#token && this.#username && this.#password) {
262
+ try {
263
+ this.#token = await getToken(this.#baseUrl, this.#username, this.#password);
264
+ this.#wsUrl = buildWsUrl(this.#baseUrl, this.#token);
265
+ } catch (err) {
266
+ console.error(`[CommsClient] Auth token fetch failed: ${err.message}`);
267
+ this.emit('error', err);
268
+ this.#scheduleReconnect();
269
+ return;
270
+ }
271
+ }
272
+
273
+ try {
274
+ this.#ws = new WebSocket(this.#wsUrl);
275
+ } catch (err) {
276
+ console.error(`[CommsClient] WebSocket constructor error: ${err.message}`);
277
+ this.#scheduleReconnect();
278
+ return;
279
+ }
280
+
281
+ this.#ws.on('open', () => {
282
+ this.#send('40');
283
+ });
284
+
285
+ this.#ws.on('message', (data) => {
286
+ const raw = typeof data === 'string' ? data : data.toString();
287
+
288
+ const parsed = parseSocketIOFrame(raw);
289
+
290
+ if (!parsed) {
291
+ return;
292
+ }
293
+
294
+ switch (parsed.type) {
295
+ case 'open':
296
+ break;
297
+
298
+ case 'pong':
299
+ this.#send('3');
300
+ break;
301
+
302
+ case 'connected':
303
+ this.#connected = true;
304
+ this.#reconnectDelay = INITIAL_RECONNECT_DELAY;
305
+ this.#send('42["subscribe","debug"]');
306
+ this.emit('connected');
307
+ break;
308
+
309
+ case 'event':
310
+ // Process the primary event
311
+ this.#processEvent(parsed);
312
+ // Process any batched events (plain JSON array format)
313
+ if (parsed._batch && Array.isArray(parsed._batch)) {
314
+ for (const batched of parsed._batch) {
315
+ this.#processEvent(batched);
316
+ }
317
+ }
318
+ break;
319
+ }
320
+ });
321
+
322
+ this.#ws.on('close', (code, reason) => {
323
+ this.#ws = null;
324
+ const wasConnected = this.#connected;
325
+ this.#connected = false;
326
+
327
+ if (wasConnected) {
328
+ this.emit('disconnected');
329
+ }
330
+
331
+ if (!this.#intentionalClose) {
332
+ this.#scheduleReconnect();
333
+ }
334
+ });
335
+
336
+ this.#ws.on('error', (err) => {
337
+ console.error(`[CommsClient] ❌ WebSocket ERROR: ${err.message} (stack: ${err.stack ? err.stack.substring(0, 200) : 'n/a'})`);
338
+ this.emit('error', err);
339
+ // The 'close' event will fire after 'error', triggering reconnect
340
+ });
341
+
342
+ this.#ws.on('unexpected-response', (req, res) => {
343
+ console.error(
344
+ `[CommsClient] ❌ Unexpected HTTP response: ${res.statusCode} ${res.statusMessage} ` +
345
+ `headers=${JSON.stringify(res.headers)}`,
346
+ );
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Gracefully close the WebSocket connection.
352
+ * No auto-reconnect will be attempted after this.
353
+ */
354
+ disconnect() {
355
+ this.#intentionalClose = true;
356
+ if (this.#reconnectTimer) {
357
+ clearTimeout(this.#reconnectTimer);
358
+ this.#reconnectTimer = null;
359
+ }
360
+ if (this.#ws) {
361
+ this.#ws.close(1000, 'Client disconnect');
362
+ this.#ws = null;
363
+ }
364
+ this.#connected = false;
365
+ }
366
+
367
+ /**
368
+ * Returns a shallow copy of the current ring buffer.
369
+ *
370
+ * @returns {object[]}
371
+ */
372
+ getMessages() {
373
+ return [...this.#buffer];
374
+ }
375
+
376
+ /**
377
+ * Returns the current buffer capacity.
378
+ *
379
+ * @returns {number}
380
+ */
381
+ get bufferSize() {
382
+ return this.#maxSize;
383
+ }
384
+
385
+ /**
386
+ * Whether the WebSocket is currently connected and handshake is complete.
387
+ *
388
+ * @returns {boolean}
389
+ */
390
+ get isConnected() {
391
+ return this.#connected;
392
+ }
393
+
394
+ // ── Private helpers ──────────────────────────────────────────────
395
+
396
+ /**
397
+ * Process a single parsed event.
398
+ * Routes debug events to the ring buffer; logs non-debug events.
399
+ *
400
+ * @param {{ type: 'event', topic: string, payload: any }} parsed
401
+ */
402
+ #processEvent(parsed) {
403
+ if (parsed.topic === 'debug') {
404
+ const msg = this.#normalizeDebugMessage(parsed.payload);
405
+ this.#appendToBuffer(msg);
406
+ this.emit('debug', msg);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Send a raw text frame over the WebSocket.
412
+ * Silently no-ops if the socket is not open.
413
+ *
414
+ * @param {string} data
415
+ */
416
+ #send(data) {
417
+ if (this.#ws && this.#ws.readyState === WebSocket.OPEN) {
418
+ this.#ws.send(data);
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Schedule a reconnect attempt with exponential backoff.
424
+ *
425
+ * On reconnect, the previously-obtained token (if fetched via
426
+ * credentials flow) is invalidated so a fresh one is acquired.
427
+ */
428
+ #scheduleReconnect() {
429
+ if (this.#intentionalClose) {
430
+ return;
431
+ }
432
+
433
+ // Invalidate token obtained via credentials flow so a fresh one is fetched
434
+ if (this.#username && this.#password) {
435
+ this.#token = null;
436
+ }
437
+
438
+ const delay = this.#reconnectDelay;
439
+
440
+ this.#reconnectTimer = setTimeout(() => {
441
+ this.#reconnectTimer = null;
442
+ this.#reconnectDelay = Math.min(
443
+ this.#reconnectDelay * RECONNECT_MULTIPLIER,
444
+ MAX_RECONNECT_DELAY,
445
+ );
446
+ this.connect().catch((err) => {
447
+ console.error(`[CommsClient] Reconnect failed: ${err.message}`);
448
+ });
449
+ }, delay);
450
+ }
451
+
452
+ /**
453
+ * Normalize a raw debug payload into a consistent message object.
454
+ *
455
+ * Node-RED emits:
456
+ * { id, name, msg, format, path, timestamp }
457
+ *
458
+ * We ensure timestamp is a number (ms) and add `_receivedAt` for
459
+ * ordering guarantees within the buffer.
460
+ *
461
+ * @param {any} raw
462
+ * @returns {object}
463
+ */
464
+ #normalizeDebugMessage(raw) {
465
+ const data = raw && typeof raw === 'object' ? raw : {};
466
+ return {
467
+ id: data.id || null,
468
+ name: data.name || null,
469
+ msg: data.msg !== undefined ? data.msg : null,
470
+ format: data.format || null,
471
+ path: data.path || null,
472
+ timestamp: typeof data.timestamp === 'number' ? data.timestamp : Date.now(),
473
+ _receivedAt: Date.now(),
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Append a message to the ring buffer, evicting oldest if full.
479
+ *
480
+ * @param {object} message
481
+ */
482
+ #appendToBuffer(message) {
483
+ const wasFull = this.#buffer.length >= this.#maxSize;
484
+ this.#buffer.push(message);
485
+ if (wasFull) {
486
+ this.#buffer.shift();
487
+ }
488
+ }
489
+
490
+ /**
491
+ * TEST ONLY: Add a message directly to the ring buffer without
492
+ * going through the WebSocket path. Used by unit tests to verify
493
+ * buffer eviction behavior.
494
+ *
495
+ * @param {object} message
496
+ */
497
+ _testAddMessage(message) {
498
+ this.#appendToBuffer(this.#normalizeDebugMessage(message));
499
+ }
500
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Color and Style Mapping
3
+ *
4
+ * Maps Node-RED node types to their standard fill colors and defines
5
+ * dirty/disabled highlighting styles for all output formats.
6
+ *
7
+ * Color values match Node-RED's editor palette (view.js / flow.scss).
8
+ *
9
+ * @module renderer/colors
10
+ */
11
+
12
+ /**
13
+ * Map of Node-RED core node types to their fill colors.
14
+ * Colors sourced from Node-RED's editor palette definitions.
15
+ */
16
+ const NODE_COLORS = {
17
+ // Common
18
+ 'inject': '#a6bbcf',
19
+ 'debug': '#87a980',
20
+ 'complete': '#c0c0c0',
21
+ 'catch': '#c0c0c0',
22
+ 'status': '#c0c0c0',
23
+ 'comment': '#ffffff',
24
+ 'unknown': '#c0c0c0',
25
+
26
+ // Function
27
+ 'function': '#fdd0a2',
28
+ 'switch': '#d8bfd8',
29
+ 'change': '#e2d6b8',
30
+ 'range': '#d8bfd8',
31
+ 'template': '#d8bfd8',
32
+ 'delay': '#fdd0a2',
33
+ 'trigger': '#fdd0a2',
34
+ 'exec': '#fdd0a2',
35
+ 'rbe': '#fdd0a2',
36
+
37
+ // Network
38
+ 'mqtt in': '#d8bfd8',
39
+ 'mqtt out': '#d8bfd8',
40
+ 'http in': '#d8bfd8',
41
+ 'http response': '#d8bfd8',
42
+ 'http request': '#e2d6b8',
43
+ 'websocket in': '#d8bfd8',
44
+ 'websocket out': '#d8bfd8',
45
+ 'tcp in': '#d8bfd8',
46
+ 'tcp out': '#d8bfd8',
47
+ 'tcp request': '#e2d6b8',
48
+ 'udp in': '#d8bfd8',
49
+ 'udp out': '#d8bfd8',
50
+
51
+ // Sequence
52
+ 'split': '#d8bfd8',
53
+ 'join': '#d8bfd8',
54
+ 'batch': '#d8bfd8',
55
+ 'sort': '#d8bfd8',
56
+
57
+ // Parser
58
+ 'csv': '#d8bfd8',
59
+ 'html': '#d8bfd8',
60
+ 'json': '#d8bfd8',
61
+ 'xml': '#d8bfd8',
62
+ 'yaml': '#d8bfd8',
63
+
64
+ // Storage
65
+ 'file in': '#87a980',
66
+ 'file out': '#87a980',
67
+ 'file': '#87a980',
68
+ 'watch': '#87a980',
69
+
70
+ // Dashboard (common widgets)
71
+ 'ui_button': '#d8bfd8',
72
+ 'ui_text': '#d8bfd8',
73
+ 'ui_gauge': '#d8bfd8',
74
+ 'ui_chart': '#d8bfd8',
75
+
76
+ // Link
77
+ 'link in': '#c0c0c0',
78
+ 'link out': '#c0c0c0',
79
+ 'link call': '#c0c0c0',
80
+ };
81
+
82
+ /**
83
+ * Default fallback color for unknown/custom node types.
84
+ */
85
+ const DEFAULT_COLOR = '#cccccc';
86
+
87
+ /**
88
+ * Dirty highlight style definitions for each format.
89
+ */
90
+ const DIRTY_STYLES = {
91
+ /** SVG stroke style for dirty nodes */
92
+ svg: 'stroke:#ff8c00;stroke-width:2.5;stroke-dasharray:none;',
93
+ /** HTML CSS class content for dirty nodes */
94
+ html: 'filter: drop-shadow(0 0 4px #ff8c00);',
95
+ /** Mermaid classDef for dirty nodes */
96
+ mermaid: 'classDef dirty stroke:#ff8c00,stroke-width:3px',
97
+ };
98
+
99
+ /**
100
+ * Disabled node style definitions for SVG.
101
+ */
102
+ const DISABLED_STYLE = 'stroke-dasharray:5,5;opacity:0.5;';
103
+
104
+ /**
105
+ * Get the fill color for a node type.
106
+ *
107
+ * @param {string} type - Node type string (e.g., 'inject', 'function')
108
+ * @returns {string} CSS color value
109
+ */
110
+ export function getNodeColor(type) {
111
+ return NODE_COLORS[type] || DEFAULT_COLOR;
112
+ }
113
+
114
+ /**
115
+ * Get the SVG style attributes for a node including dirty/disabled state.
116
+ *
117
+ * @param {object} node - IR node with dirty/d properties
118
+ * @param {string} baseColor - Fill color for the node
119
+ * @returns {string} SVG style attribute string
120
+ */
121
+ export function getNodeStyle(node, baseColor) {
122
+ const parts = [`fill:${baseColor}`];
123
+
124
+ if (node.dirty) {
125
+ parts.push(DIRTY_STYLES.svg);
126
+ }
127
+
128
+ if (node.d) {
129
+ parts.push(DISABLED_STYLE);
130
+ }
131
+
132
+ return parts.join(';');
133
+ }
134
+
135
+ /**
136
+ * Get the HTML CSS class string for a node.
137
+ *
138
+ * @param {object} node - IR node with dirty/d properties
139
+ * @returns {string} CSS class string
140
+ */
141
+ export function getNodeCSSClass(node) {
142
+ const classes = ['nr-node'];
143
+ if (node.dirty) classes.push('nr-node-dirty');
144
+ if (node.d) classes.push('nr-node-disabled');
145
+ return classes.join(' ');
146
+ }
147
+
148
+ /**
149
+ * Get the Mermaid class suffix for a node.
150
+ *
151
+ * @param {object} node - IR node with dirty/d properties
152
+ * @returns {string} Mermaid class suffix (e.g., ':::dirty:::disabled')
153
+ */
154
+ export function getMermaidClass(node) {
155
+ const parts = [];
156
+ if (node.dirty) parts.push('dirty');
157
+ if (node.d) parts.push('disabled');
158
+ return parts.length > 0 ? ':::' + parts.join(':::') : '';
159
+ }
160
+
161
+ export { NODE_COLORS, DEFAULT_COLOR, DIRTY_STYLES, DISABLED_STYLE };