@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.
- package/LICENSE +201 -0
- package/README.md +162 -0
- package/index.js +133 -0
- package/package.json +58 -0
- package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
- package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
- package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
- package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
- package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
- package/resources/skills/nodered-mustache/SKILL.md +588 -0
- package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
- package/resources/skills/nodered-node-reference/examples/common.json +113 -0
- package/resources/skills/nodered-node-reference/examples/network.json +107 -0
- package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
- package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
- package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
- package/resources/skills/nodered-patterns/SKILL.md +414 -0
- package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
- package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
- package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
- package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
- package/resources/skills/nodered-subflows/SKILL.md +261 -0
- package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
- package/src/auth/api-key-verifier.js +36 -0
- package/src/auth/composite-verifier.js +59 -0
- package/src/auth/config.js +106 -0
- package/src/auth/oauth-clients-store.js +107 -0
- package/src/auth/oauth-provider.js +149 -0
- package/src/auth/oauth-token-store.js +312 -0
- package/src/nodered/auth.js +158 -0
- package/src/nodered/client.js +199 -0
- package/src/nodered/comms-client.js +500 -0
- package/src/renderer/colors.js +161 -0
- package/src/renderer/geometry.js +115 -0
- package/src/renderer/html-builder.js +571 -0
- package/src/renderer/index.js +51 -0
- package/src/renderer/ir-builder.js +161 -0
- package/src/renderer/layout.js +126 -0
- package/src/renderer/mermaid-builder.js +109 -0
- package/src/renderer/svg-builder.js +228 -0
- package/src/schemas/responses.js +283 -0
- package/src/server.js +844 -0
- package/src/skills/loader.js +84 -0
- package/src/staging-store.js +258 -0
- package/src/tools/add-nodes-to-group.js +216 -0
- package/src/tools/connect-nodes.js +115 -0
- package/src/tools/constants.js +45 -0
- package/src/tools/create-flow.js +87 -0
- package/src/tools/create-node.js +126 -0
- package/src/tools/create-subflow-instance.js +123 -0
- package/src/tools/create-subflow.js +101 -0
- package/src/tools/delete-context.js +60 -0
- package/src/tools/delete-flow.js +81 -0
- package/src/tools/delete-group.js +116 -0
- package/src/tools/delete-node.js +73 -0
- package/src/tools/delete-subflow.js +103 -0
- package/src/tools/deploy.js +94 -0
- package/src/tools/disconnect-nodes.js +158 -0
- package/src/tools/export-flow.js +161 -0
- package/src/tools/export-subflow.js +78 -0
- package/src/tools/flow-utils.js +376 -0
- package/src/tools/get-config-nodes.js +86 -0
- package/src/tools/get-context.js +76 -0
- package/src/tools/get-flow-diagram.js +99 -0
- package/src/tools/get-flow-nodes.js +116 -0
- package/src/tools/get-flows.js +74 -0
- package/src/tools/get-node-detail.js +77 -0
- package/src/tools/get-node-type-detail.js +92 -0
- package/src/tools/get-palette-nodes.js +63 -0
- package/src/tools/get-staging-status.js +34 -0
- package/src/tools/get-subflow-detail.js +110 -0
- package/src/tools/get-subflows.js +105 -0
- package/src/tools/import-flow.js +310 -0
- package/src/tools/inject-message.js +117 -0
- package/src/tools/install-node.js +31 -0
- package/src/tools/read-debug-messages.js +155 -0
- package/src/tools/refresh-staging.js +62 -0
- package/src/tools/remove-nodes-from-group.js +162 -0
- package/src/tools/render-staging.js +69 -0
- package/src/tools/response-utils.js +42 -0
- package/src/tools/search-nodes.js +134 -0
- package/src/tools/uninstall-node.js +31 -0
- package/src/tools/update-flow.js +95 -0
- package/src/tools/update-group.js +77 -0
- package/src/tools/update-node.js +132 -0
- package/src/tools/update-subflow.js +84 -0
- package/src/transport/http.js +252 -0
- package/src/transport/stdio.js +16 -0
- 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 };
|