@theotherwillembotha/node-red-cluster 0.0.55
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 +15 -0
- package/README.md +337 -0
- package/build/GenerateNodes.js +19 -0
- package/build/Nodes.html +653 -0
- package/build/Nodes.js +22 -0
- package/build/Plugins.js +70 -0
- package/build/cluster/node/ClusterConfigNode.js +89 -0
- package/build/cluster/node/ClusterPublishNode.js +118 -0
- package/build/cluster/node/ClusterSubscribeNode.js +104 -0
- package/build/cluster/service/ClusterClient.js +149 -0
- package/build/cluster/service/ClusterService.js +160 -0
- package/build/icons/cluster.png +0 -0
- package/build/index.js +19 -0
- package/documentation/ClusterConfigNode.png +0 -0
- package/documentation/ClusterPublishNode.png +0 -0
- package/documentation/ClusterSubscribeNode.png +0 -0
- package/documentation/cluster_example_publish.png +0 -0
- package/documentation/cluster_example_subscribe.png +0 -0
- package/package.json +66 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ClusterService = void 0;
|
|
4
|
+
const node_red_plugincore_1 = require("@theotherwillembotha/node-red-plugincore");
|
|
5
|
+
const transport_node_1 = require("@nats-io/transport-node");
|
|
6
|
+
const ClusterClient_1 = require("./ClusterClient");
|
|
7
|
+
class ClusterService extends node_red_plugincore_1.BaseService {
|
|
8
|
+
static { this._clients = {}; }
|
|
9
|
+
constructor() {
|
|
10
|
+
super("ClusterService");
|
|
11
|
+
}
|
|
12
|
+
// ── BaseService lifecycle ────────────────────────────────────────────────
|
|
13
|
+
async init(red) {
|
|
14
|
+
console.log("STARTING: ClusterService");
|
|
15
|
+
const writePermission = red.auth.needsPermission("inject.write");
|
|
16
|
+
red.httpAdmin.get("/cluster/status/:configId", writePermission, (req, res) => this._handleStatus(req, res));
|
|
17
|
+
red.httpAdmin.get("/cluster/members/:configId", writePermission, (req, res) => this._handleMembers(req, res));
|
|
18
|
+
red.httpAdmin.get("/cluster/discovery/:configId", writePermission, (req, res) => this._handleDiscovery(req, res));
|
|
19
|
+
red.httpAdmin.post("/cluster/testconnection", writePermission, (req, res) => this._handleTestConnection(req, res));
|
|
20
|
+
}
|
|
21
|
+
deinit(_red) {
|
|
22
|
+
console.log("STOPPING: ClusterService");
|
|
23
|
+
Object.values(ClusterService._clients).forEach(client => {
|
|
24
|
+
client.stop().catch(err => console.error("ClusterService: error stopping client:", err));
|
|
25
|
+
});
|
|
26
|
+
ClusterService._clients = {};
|
|
27
|
+
}
|
|
28
|
+
// ── Static client management ─────────────────────────────────────────────
|
|
29
|
+
static ensureClient(configId, params) {
|
|
30
|
+
let client = ClusterService._clients[configId];
|
|
31
|
+
if (!client) {
|
|
32
|
+
client = new ClusterClient_1.ClusterClient(params);
|
|
33
|
+
ClusterService._clients[configId] = client;
|
|
34
|
+
client.start().catch(err => {
|
|
35
|
+
console.error(`ClusterService: failed to start client for ${configId}:`, err);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
client.updateParams(params);
|
|
40
|
+
}
|
|
41
|
+
return client;
|
|
42
|
+
}
|
|
43
|
+
static getClient(configId) {
|
|
44
|
+
return ClusterService._clients[configId] ?? null;
|
|
45
|
+
}
|
|
46
|
+
static removeClient(configId) {
|
|
47
|
+
const client = ClusterService._clients[configId];
|
|
48
|
+
if (client) {
|
|
49
|
+
client.stop().catch(err => console.error(`ClusterService: error stopping client ${configId}:`, err));
|
|
50
|
+
delete ClusterService._clients[configId];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ── HTTP admin handlers ──────────────────────────────────────────────────
|
|
54
|
+
_handleStatus(req, res) {
|
|
55
|
+
const client = ClusterService._clients[req.params.configId];
|
|
56
|
+
if (!client) {
|
|
57
|
+
res.status(404).json({ error: "No client for that config node" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.json({ status: client.status(), params: client.params() });
|
|
61
|
+
}
|
|
62
|
+
async _handleMembers(req, res) {
|
|
63
|
+
const client = ClusterService._clients[req.params.configId];
|
|
64
|
+
if (!client) {
|
|
65
|
+
res.status(404).json({ error: "No client for that config node" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const kv = client.kvBucket();
|
|
69
|
+
if (!kv || client.status() !== "connected") {
|
|
70
|
+
res.status(503).json({ error: "Not connected", status: client.status() });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const members = [];
|
|
75
|
+
const iter = await kv.keys("instances.>");
|
|
76
|
+
for await (const key of iter) {
|
|
77
|
+
members.push(key.replace(/^instances\./, ""));
|
|
78
|
+
}
|
|
79
|
+
res.json({ members });
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// No keys found is not an error return empty list
|
|
83
|
+
res.json({ members: [] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async _handleDiscovery(req, res) {
|
|
87
|
+
const client = ClusterService._clients[req.params.configId];
|
|
88
|
+
if (!client) {
|
|
89
|
+
res.status(404).json({ error: "No client for that config node" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const kv = client.kvBucket();
|
|
93
|
+
if (!kv || client.status() !== "connected") {
|
|
94
|
+
res.status(503).json({ error: "Not connected", status: client.status() });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const instances = {};
|
|
98
|
+
const endpoints = {};
|
|
99
|
+
try {
|
|
100
|
+
const instanceIter = await kv.keys("instances.>");
|
|
101
|
+
for await (const key of instanceIter) {
|
|
102
|
+
const entry = await kv.get(key);
|
|
103
|
+
if (entry && entry.operation === "PUT") {
|
|
104
|
+
const instanceId = key.replace(/^instances\./, "");
|
|
105
|
+
try {
|
|
106
|
+
instances[instanceId] = JSON.parse(entry.string());
|
|
107
|
+
}
|
|
108
|
+
catch (_) { }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (_) { /* no instance keys */ }
|
|
113
|
+
try {
|
|
114
|
+
const endpointIter = await kv.keys("endpoints.>");
|
|
115
|
+
for await (const key of endpointIter) {
|
|
116
|
+
const entry = await kv.get(key);
|
|
117
|
+
if (entry && entry.operation === "PUT") {
|
|
118
|
+
// key: "endpoints.<instanceId>.<subject...>"
|
|
119
|
+
const withoutPrefix = key.replace(/^endpoints\./, "");
|
|
120
|
+
const dotIdx = withoutPrefix.indexOf(".");
|
|
121
|
+
if (dotIdx === -1)
|
|
122
|
+
continue;
|
|
123
|
+
const instanceId = withoutPrefix.substring(0, dotIdx);
|
|
124
|
+
const subject = withoutPrefix.substring(dotIdx + 1);
|
|
125
|
+
if (!endpoints[instanceId])
|
|
126
|
+
endpoints[instanceId] = [];
|
|
127
|
+
endpoints[instanceId].push(subject);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (_) { /* no endpoint keys */ }
|
|
132
|
+
res.json({ instances, endpoints });
|
|
133
|
+
}
|
|
134
|
+
// ── Test connection ──────────────────────────────────────────────────────
|
|
135
|
+
async _handleTestConnection(req, res) {
|
|
136
|
+
const { natsAddress, natsUser, natsPass } = req.body;
|
|
137
|
+
const nats = await this._testNats(natsAddress, natsUser, natsPass);
|
|
138
|
+
res.json({ nats });
|
|
139
|
+
}
|
|
140
|
+
async _testNats(address, user, pass) {
|
|
141
|
+
try {
|
|
142
|
+
const opts = { servers: address, maxReconnectAttempts: 0, timeout: 5000 };
|
|
143
|
+
if (user)
|
|
144
|
+
opts.user = user;
|
|
145
|
+
if (pass)
|
|
146
|
+
opts.pass = pass;
|
|
147
|
+
const nc = await (0, transport_node_1.connect)(opts);
|
|
148
|
+
await nc.drain();
|
|
149
|
+
return { ok: true, message: "Connected successfully" };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
return { ok: false, error: err.message || "Connection failed" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// ── ServiceDescriptor ────────────────────────────────────────────────────
|
|
156
|
+
static getServiceDescriptor() {
|
|
157
|
+
return new node_red_plugincore_1.ServiceDescriptor("@theotherwillembotha/clusterservice", "ClusterService", "integration-plugin", "./cluster/service/ClusterService", ClusterService);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
exports.ClusterService = ClusterService;
|
|
Binary file
|
package/build/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./cluster/node/ClusterConfigNode"), exports);
|
|
18
|
+
__exportStar(require("./cluster/node/ClusterPublishNode"), exports);
|
|
19
|
+
__exportStar(require("./cluster/node/ClusterSubscribeNode"), exports);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@theotherwillembotha/node-red-cluster",
|
|
3
|
+
"version": "0.0.55",
|
|
4
|
+
"description": "Node-RED clustering plugin - connect multiple Node-RED instances into a coordinated cluster using NATS JetStream for message transport and NATS KV for shared state.",
|
|
5
|
+
"main": "./build/index.js",
|
|
6
|
+
"exports": "./build/index.js",
|
|
7
|
+
"author": "Willem Botha (@theotherwillembotha)",
|
|
8
|
+
"license": "ISC",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"node-red",
|
|
11
|
+
"cluster",
|
|
12
|
+
"distributed",
|
|
13
|
+
"nats",
|
|
14
|
+
"jetstream",
|
|
15
|
+
"messaging"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
19
|
+
"build": "npm run clean && tsc && npm run generate-nodes && npm run copy-files",
|
|
20
|
+
"clean": "rm -rf ./build",
|
|
21
|
+
"generate-nodes": "node build/GenerateNodes.js",
|
|
22
|
+
"copy-files": "cp -r ./icons ./build/"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"build/",
|
|
26
|
+
"documentation/",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/theotherwillembotha/nodered_cluster.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/theotherwillembotha/nodered_cluster/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/theotherwillembotha/nodered_cluster#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18",
|
|
40
|
+
"nodered": ">=4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@nats-io/jetstream": "^3.4.0",
|
|
44
|
+
"@nats-io/kv": "^3.4.0",
|
|
45
|
+
"@nats-io/nats-core": "^3.4.0",
|
|
46
|
+
"@nats-io/transport-node": "^3.4.0",
|
|
47
|
+
"@theotherwillembotha/node-red-plugincore": "^0.0.55"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@theotherwillembotha/node-red-plugincore": "../nodered_plugincore",
|
|
51
|
+
"@types/node": "^25.9.1",
|
|
52
|
+
"@types/node-red": "~1.3.5"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"node-red": {
|
|
58
|
+
"version": ">=4.0.0",
|
|
59
|
+
"nodes": {
|
|
60
|
+
"cluster": "./build/Nodes.js"
|
|
61
|
+
},
|
|
62
|
+
"plugins": {
|
|
63
|
+
"cluster": "./build/Plugins.js"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|