@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30
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/README.md +244 -76
- package/examples/D3 Poland Map.json +90 -0
- package/examples/Live Chart.json +101 -0
- package/examples/PixiJS Sprites.json +114 -0
- package/examples/Sensor Portal.json +93 -0
- package/examples/Shared Components.json +68 -0
- package/examples/Three.js Scene.json +118 -0
- package/examples/Utility Hooks.json +94 -0
- package/examples/WebGPU Shader.json +110 -0
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/assets.js +342 -0
- package/nodes/lib/helpers.js +724 -0
- package/nodes/lib/hooks.js +120 -0
- package/nodes/lib/page-builder.js +480 -0
- package/nodes/lib/portal-page-route.js +133 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +80 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +1862 -287
- package/nodes/portal-react.js +1563 -471
- package/package.json +42 -12
- package/examples/sensor-portal-flow.json +0 -71
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public portal page route factory.
|
|
3
|
+
*
|
|
4
|
+
* Owns only the HTTP response branching for a portal endpoint:
|
|
5
|
+
* building/teardown, hard build error, degraded last-good build, and fresh
|
|
6
|
+
* successful build. Runtime lifecycle, WebSocket handling, and pageState
|
|
7
|
+
* mutation stay in portal-react.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function buildBuildingPage(wsPath) {
|
|
11
|
+
return `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${wsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withNoStore(res) {
|
|
15
|
+
return res.set("Cache-Control", "no-store");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {string} opts.endpoint
|
|
21
|
+
* @param {Object<string, Object>} opts.pageState
|
|
22
|
+
* @param {string} opts.wsPath
|
|
23
|
+
* @param {string} opts.pageTitle
|
|
24
|
+
* @param {string} opts.adminRoot
|
|
25
|
+
* @param {Function} opts.buildPage
|
|
26
|
+
* @param {Function} opts.buildErrorPage
|
|
27
|
+
* @param {Function} opts.extractPortalUser
|
|
28
|
+
* @returns {Function}
|
|
29
|
+
*/
|
|
30
|
+
function createPortalPageHandler(opts) {
|
|
31
|
+
const {
|
|
32
|
+
endpoint,
|
|
33
|
+
pageState,
|
|
34
|
+
wsPath,
|
|
35
|
+
pageTitle,
|
|
36
|
+
adminRoot,
|
|
37
|
+
buildPage,
|
|
38
|
+
buildErrorPage,
|
|
39
|
+
extractPortalUser,
|
|
40
|
+
} = opts;
|
|
41
|
+
|
|
42
|
+
return async function portalPageHandler(req, res) {
|
|
43
|
+
let cssTimer = null;
|
|
44
|
+
try {
|
|
45
|
+
const state = pageState[endpoint];
|
|
46
|
+
if (!state || state.building || !state.compiled) {
|
|
47
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
48
|
+
withNoStore(res)
|
|
49
|
+
.type("text/html")
|
|
50
|
+
.send(buildBuildingPage(bWsPath));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
withNoStore(res);
|
|
55
|
+
if (state.compiled.error) {
|
|
56
|
+
if (state.lastGood) {
|
|
57
|
+
const user = state.portalAuth
|
|
58
|
+
? extractPortalUser(req.headers)
|
|
59
|
+
: null;
|
|
60
|
+
res
|
|
61
|
+
.type("text/html")
|
|
62
|
+
.send(
|
|
63
|
+
buildPage(
|
|
64
|
+
state.lastGood.pageTitle,
|
|
65
|
+
state.lastGood.compiledJs,
|
|
66
|
+
state.wsPath,
|
|
67
|
+
state.lastGood.customHead,
|
|
68
|
+
state.lastGood.cssHash,
|
|
69
|
+
user,
|
|
70
|
+
state.showWsStatus,
|
|
71
|
+
adminRoot,
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
res
|
|
78
|
+
.status(500)
|
|
79
|
+
.type("text/html")
|
|
80
|
+
.send(
|
|
81
|
+
buildErrorPage(
|
|
82
|
+
state.pageTitle,
|
|
83
|
+
state.compiled.error,
|
|
84
|
+
state.wsPath,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { cssHash } = await Promise.race([
|
|
91
|
+
state.cssReady,
|
|
92
|
+
new Promise((_, reject) => {
|
|
93
|
+
cssTimer = setTimeout(
|
|
94
|
+
() => reject(new Error("CSS generation timeout")),
|
|
95
|
+
15000,
|
|
96
|
+
);
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
// Clear the losing timer — without this every request leaves a 15 s
|
|
100
|
+
// timer pending after the CSS promise wins the race.
|
|
101
|
+
clearTimeout(cssTimer);
|
|
102
|
+
const user = state.portalAuth ? extractPortalUser(req.headers) : null;
|
|
103
|
+
res
|
|
104
|
+
.type("text/html")
|
|
105
|
+
.send(
|
|
106
|
+
buildPage(
|
|
107
|
+
state.pageTitle,
|
|
108
|
+
state.compiled.js,
|
|
109
|
+
state.wsPath,
|
|
110
|
+
state.customHead,
|
|
111
|
+
cssHash,
|
|
112
|
+
user,
|
|
113
|
+
state.showWsStatus,
|
|
114
|
+
adminRoot,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
clearTimeout(cssTimer);
|
|
119
|
+
res
|
|
120
|
+
.status(500)
|
|
121
|
+
.type("text/html")
|
|
122
|
+
.send(
|
|
123
|
+
buildErrorPage(
|
|
124
|
+
pageTitle,
|
|
125
|
+
"Page build failed: " + e.message,
|
|
126
|
+
wsPath,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { createPortalPageHandler };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
const MAX_UTIL_CODE_BYTES = 1_000_000;
|
|
2
|
+
|
|
3
|
+
const DECL_RE = /^(?:export\s+)?(?:async\s+)?(function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
|
|
7
|
+
* names. Used for selective inclusion, symbol collision checks, and the
|
|
8
|
+
* editor Utilities dialog. Oversize input is ignored so the regex cannot be
|
|
9
|
+
* weaponized with multi-MB code.
|
|
10
|
+
*
|
|
11
|
+
* Multi-declarator statements (`const a = 1, b = 2`) yield every name —
|
|
12
|
+
* initializer-internal commas are skipped via bracket/quote depth tracking.
|
|
13
|
+
* Destructuring declarations are not supported (never were).
|
|
14
|
+
*
|
|
15
|
+
* @param {string} code
|
|
16
|
+
* @returns {Set<string>}
|
|
17
|
+
*/
|
|
18
|
+
function extractUtilitySymbols(code) {
|
|
19
|
+
const names = new Set();
|
|
20
|
+
if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
|
|
21
|
+
DECL_RE.lastIndex = 0;
|
|
22
|
+
let m;
|
|
23
|
+
while ((m = DECL_RE.exec(code))) {
|
|
24
|
+
names.add(m[2]);
|
|
25
|
+
const kind = m[1];
|
|
26
|
+
if (kind === "const" || kind === "let" || kind === "var") {
|
|
27
|
+
collectExtraDeclarators(code, DECL_RE.lastIndex, names);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return names;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Walk a `const`/`let`/`var` statement from just past its first declared
|
|
35
|
+
* name and collect the names of any further comma-separated declarators.
|
|
36
|
+
* Commas inside `()`, `[]`, `{}` or string/template literals belong to the
|
|
37
|
+
* initializer expression and are ignored. Scanning stops at the first `;`
|
|
38
|
+
* (or unbalanced closer) at depth 0.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} code
|
|
41
|
+
* @param {number} from Index right after the first declarator name.
|
|
42
|
+
* @param {Set<string>} names Collector — extra names are added in place.
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
function collectExtraDeclarators(code, from, names) {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let i = from;
|
|
48
|
+
const n = code.length;
|
|
49
|
+
while (i < n) {
|
|
50
|
+
const ch = code[i];
|
|
51
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
52
|
+
i++;
|
|
53
|
+
while (i < n && code[i] !== ch) {
|
|
54
|
+
if (code[i] === "\\") i++;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
} else if (ch === "(" || ch === "[" || ch === "{") {
|
|
58
|
+
depth++;
|
|
59
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
60
|
+
if (depth === 0) return; // end of enclosing block / malformed
|
|
61
|
+
depth--;
|
|
62
|
+
} else if (ch === ";" && depth === 0) {
|
|
63
|
+
return;
|
|
64
|
+
} else if (ch === "," && depth === 0) {
|
|
65
|
+
const dm = code.slice(i + 1).match(/^\s*([A-Za-z_$][\w$]*)/);
|
|
66
|
+
if (dm) names.add(dm[1]);
|
|
67
|
+
}
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register fc-portal-component and fc-portal-utility config nodes.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} RED
|
|
76
|
+
* @param {Object} deps
|
|
77
|
+
* @returns {void}
|
|
78
|
+
*/
|
|
79
|
+
function registerRegistryNodes(RED, deps) {
|
|
80
|
+
const {
|
|
81
|
+
registry,
|
|
82
|
+
utilities,
|
|
83
|
+
compNameOwners,
|
|
84
|
+
utilSymbolOwners,
|
|
85
|
+
isSafeName,
|
|
86
|
+
quickCheckSyntax,
|
|
87
|
+
shortStatus,
|
|
88
|
+
scheduleRebuildUsing,
|
|
89
|
+
} = deps;
|
|
90
|
+
|
|
91
|
+
// Name each component/utility node currently registers: { nodeId: name }.
|
|
92
|
+
// Registry entries survive a plain redeploy (close(removed=false) keeps
|
|
93
|
+
// them so an unchanged deploy stays a no-op) — this map is what lets a
|
|
94
|
+
// RENAME on redeploy free the old entry instead of leaking it.
|
|
95
|
+
if (!RED.settings.portalReactNodeNames) {
|
|
96
|
+
RED.settings.portalReactNodeNames = {};
|
|
97
|
+
}
|
|
98
|
+
const nodeNames = RED.settings.portalReactNodeNames;
|
|
99
|
+
|
|
100
|
+
function PortalComponentNode(config) {
|
|
101
|
+
RED.nodes.createNode(this, config);
|
|
102
|
+
const node = this;
|
|
103
|
+
const compName = (config.compName || "").trim();
|
|
104
|
+
|
|
105
|
+
if (!isSafeName(compName)) {
|
|
106
|
+
node.error("Invalid component name: " + compName);
|
|
107
|
+
node.status({ fill: "red", shape: "dot", text: "invalid name" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Renamed on redeploy → free the previous entry owned by this node.
|
|
112
|
+
const prevOwnedComp = nodeNames[node.id];
|
|
113
|
+
if (prevOwnedComp && prevOwnedComp !== compName) {
|
|
114
|
+
if (compNameOwners[prevOwnedComp] === node.id) {
|
|
115
|
+
delete compNameOwners[prevOwnedComp];
|
|
116
|
+
}
|
|
117
|
+
if (registry[prevOwnedComp]) {
|
|
118
|
+
delete registry[prevOwnedComp];
|
|
119
|
+
scheduleRebuildUsing(prevOwnedComp);
|
|
120
|
+
}
|
|
121
|
+
delete nodeNames[node.id];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const existingOwner = compNameOwners[compName];
|
|
125
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
126
|
+
node.error(
|
|
127
|
+
`Component name "${compName}" is already used by another node`,
|
|
128
|
+
);
|
|
129
|
+
node.status({
|
|
130
|
+
fill: "red",
|
|
131
|
+
shape: "ring",
|
|
132
|
+
text: shortStatus("dup: " + compName),
|
|
133
|
+
});
|
|
134
|
+
node.on("close", function (_removed, done) {
|
|
135
|
+
done();
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const utilSymOwner = utilSymbolOwners[compName];
|
|
141
|
+
if (utilSymOwner) {
|
|
142
|
+
node.error(
|
|
143
|
+
`Component name "${compName}" conflicts with a top-level symbol declared in utility "${utilSymOwner}"`,
|
|
144
|
+
);
|
|
145
|
+
node.status({
|
|
146
|
+
fill: "red",
|
|
147
|
+
shape: "ring",
|
|
148
|
+
text: shortStatus("dup sym: " + compName),
|
|
149
|
+
});
|
|
150
|
+
node.on("close", function (_removed, done) {
|
|
151
|
+
done();
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
compNameOwners[compName] = node.id;
|
|
156
|
+
nodeNames[node.id] = compName;
|
|
157
|
+
|
|
158
|
+
const newCode = config.compCode || "";
|
|
159
|
+
const prevCode = registry[compName]?.code;
|
|
160
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
161
|
+
registry[compName] = { code: newCode, error: syntaxErr };
|
|
162
|
+
|
|
163
|
+
if (syntaxErr) {
|
|
164
|
+
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
165
|
+
const short = syntaxErr.split("\n")[0];
|
|
166
|
+
node.status({
|
|
167
|
+
fill: "red",
|
|
168
|
+
shape: "dot",
|
|
169
|
+
text: shortStatus("syntax: " + short),
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (prevCode !== newCode) scheduleRebuildUsing(compName);
|
|
176
|
+
|
|
177
|
+
node.on("close", function (removed, done) {
|
|
178
|
+
// Plain redeploy (removed=false): keep the registry entry. The new
|
|
179
|
+
// instance re-registers in the same deploy pass and can compare
|
|
180
|
+
// prevCode — an unchanged component deploy stays a true no-op.
|
|
181
|
+
// Only delete/disable (removed=true) drops the entry.
|
|
182
|
+
if (removed) {
|
|
183
|
+
if (compNameOwners[compName] === node.id) {
|
|
184
|
+
delete compNameOwners[compName];
|
|
185
|
+
}
|
|
186
|
+
if (nodeNames[node.id] === compName) {
|
|
187
|
+
delete nodeNames[node.id];
|
|
188
|
+
}
|
|
189
|
+
delete registry[compName];
|
|
190
|
+
scheduleRebuildUsing(compName);
|
|
191
|
+
}
|
|
192
|
+
done();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
196
|
+
|
|
197
|
+
function PortalUtilityNode(config) {
|
|
198
|
+
RED.nodes.createNode(this, config);
|
|
199
|
+
const node = this;
|
|
200
|
+
const utilName = (config.utilName || "").trim();
|
|
201
|
+
|
|
202
|
+
if (!isSafeName(utilName)) {
|
|
203
|
+
node.error("Invalid utility name: " + utilName);
|
|
204
|
+
node.status({ fill: "red", shape: "dot", text: "invalid name" });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Renamed on redeploy → free the previous entry + its symbol ownership.
|
|
209
|
+
const prevOwnedUtil = nodeNames[node.id];
|
|
210
|
+
if (prevOwnedUtil && prevOwnedUtil !== utilName) {
|
|
211
|
+
if (compNameOwners[prevOwnedUtil] === node.id) {
|
|
212
|
+
delete compNameOwners[prevOwnedUtil];
|
|
213
|
+
}
|
|
214
|
+
const oldSyms = extractUtilitySymbols(utilities[prevOwnedUtil]?.code || "");
|
|
215
|
+
for (const s of oldSyms) {
|
|
216
|
+
if (utilSymbolOwners[s] === prevOwnedUtil) delete utilSymbolOwners[s];
|
|
217
|
+
}
|
|
218
|
+
if (utilities[prevOwnedUtil]) {
|
|
219
|
+
delete utilities[prevOwnedUtil];
|
|
220
|
+
scheduleRebuildUsing(prevOwnedUtil);
|
|
221
|
+
for (const s of oldSyms) scheduleRebuildUsing(s);
|
|
222
|
+
}
|
|
223
|
+
delete nodeNames[node.id];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const existingOwner = compNameOwners[utilName];
|
|
227
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
228
|
+
node.error(
|
|
229
|
+
`Name "${utilName}" is already used by another component or utility`,
|
|
230
|
+
);
|
|
231
|
+
node.status({
|
|
232
|
+
fill: "red",
|
|
233
|
+
shape: "ring",
|
|
234
|
+
text: shortStatus("dup: " + utilName),
|
|
235
|
+
});
|
|
236
|
+
node.on("close", function (_removed, done) {
|
|
237
|
+
done();
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
compNameOwners[utilName] = node.id;
|
|
242
|
+
nodeNames[node.id] = utilName;
|
|
243
|
+
|
|
244
|
+
const newCode = config.utilCode || "";
|
|
245
|
+
const prevCode = utilities[utilName]?.code;
|
|
246
|
+
const prevSyms = extractUtilitySymbols(prevCode || "");
|
|
247
|
+
const newSyms = extractUtilitySymbols(newCode);
|
|
248
|
+
|
|
249
|
+
for (const s of prevSyms) {
|
|
250
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const conflicts = [];
|
|
254
|
+
for (const s of newSyms) {
|
|
255
|
+
if (Object.prototype.hasOwnProperty.call(registry, s)) {
|
|
256
|
+
conflicts.push(`${s} (component)`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const symOwner = utilSymbolOwners[s];
|
|
260
|
+
if (symOwner && symOwner !== utilName) {
|
|
261
|
+
conflicts.push(`${s} (utility ${symOwner})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
266
|
+
const dupErr =
|
|
267
|
+
conflicts.length > 0
|
|
268
|
+
? "duplicate symbols: " + conflicts.join(", ")
|
|
269
|
+
: null;
|
|
270
|
+
const combinedErr = syntaxErr || dupErr;
|
|
271
|
+
|
|
272
|
+
utilities[utilName] = { code: newCode, error: combinedErr };
|
|
273
|
+
|
|
274
|
+
if (combinedErr) {
|
|
275
|
+
const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
|
|
276
|
+
node.error(`Utility "${utilName}": ${msgs}`);
|
|
277
|
+
if (syntaxErr) {
|
|
278
|
+
const short = syntaxErr.split("\n")[0];
|
|
279
|
+
node.status({
|
|
280
|
+
fill: "red",
|
|
281
|
+
shape: "dot",
|
|
282
|
+
text: shortStatus("syntax: " + short),
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
const firstSym = conflicts[0].split(" ")[0];
|
|
286
|
+
node.status({
|
|
287
|
+
fill: "red",
|
|
288
|
+
shape: "ring",
|
|
289
|
+
text: shortStatus("dup sym: " + firstSym),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
for (const s of newSyms) utilSymbolOwners[s] = utilName;
|
|
294
|
+
node.status({
|
|
295
|
+
fill: "green",
|
|
296
|
+
shape: "dot",
|
|
297
|
+
text: shortStatus(utilName),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (prevCode !== newCode) {
|
|
302
|
+
scheduleRebuildUsing(utilName);
|
|
303
|
+
for (const s of newSyms) scheduleRebuildUsing(s);
|
|
304
|
+
for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
node.on("close", function (removed, done) {
|
|
308
|
+
// Plain redeploy (removed=false): keep the utility entry + symbol
|
|
309
|
+
// ownership so an unchanged deploy stays a no-op (see component close).
|
|
310
|
+
if (removed) {
|
|
311
|
+
if (compNameOwners[utilName] === node.id) {
|
|
312
|
+
delete compNameOwners[utilName];
|
|
313
|
+
}
|
|
314
|
+
if (nodeNames[node.id] === utilName) {
|
|
315
|
+
delete nodeNames[node.id];
|
|
316
|
+
}
|
|
317
|
+
for (const s of Object.keys(utilSymbolOwners)) {
|
|
318
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
319
|
+
}
|
|
320
|
+
const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
|
|
321
|
+
delete utilities[utilName];
|
|
322
|
+
scheduleRebuildUsing(utilName);
|
|
323
|
+
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
324
|
+
}
|
|
325
|
+
done();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = { extractUtilitySymbols, registerRegistryNodes };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** @module nodes/lib/router */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure routing function for portal-react WS outbound messages.
|
|
5
|
+
*
|
|
6
|
+
* Split out from portal-react.js so it can be unit-tested without a full
|
|
7
|
+
* Node-RED runtime.
|
|
8
|
+
*
|
|
9
|
+
* Routing modes, in priority order:
|
|
10
|
+
* 1. msg._client.portalClient → unicast to that one session
|
|
11
|
+
* 2. msg._client.userId → user-cast (O(1) via userIndex)
|
|
12
|
+
* 3. msg._client.username → user-cast fallback (O(N) scan)
|
|
13
|
+
* 4. otherwise → broadcast
|
|
14
|
+
*
|
|
15
|
+
* Returns a shallow summary `{ mode, delivered }` for observability/tests.
|
|
16
|
+
* The caller is responsible for any side-effects keyed off the mode
|
|
17
|
+
* (e.g. caching the last broadcast payload for new-client recovery).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} RouteContext
|
|
22
|
+
* @property {Map<string, import("ws").WebSocket>} clients portalClient → ws
|
|
23
|
+
* @property {Map<string, Set<import("ws").WebSocket>>} userIndex userId → ws set
|
|
24
|
+
* @property {(ws: any, frame: string, msg: Object) => boolean} sendTo
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} RouteResult
|
|
29
|
+
* @property {"unicast"|"user-cast"|"broadcast"} mode
|
|
30
|
+
* @property {number} delivered
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pure router — chooses delivery mode based on `msg._client` and dispatches
|
|
35
|
+
* via `ctx.sendTo` (which is responsible for hook checks and send-failure
|
|
36
|
+
* handling). Has no side-effects other than calling `sendTo`.
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} msg
|
|
39
|
+
* @param {RouteContext} ctx
|
|
40
|
+
* @returns {RouteResult}
|
|
41
|
+
*/
|
|
42
|
+
function route(msg, ctx) {
|
|
43
|
+
const { clients, userIndex, sendTo } = ctx;
|
|
44
|
+
const target = msg && msg._client;
|
|
45
|
+
const frame = JSON.stringify({ type: "data", payload: msg.payload });
|
|
46
|
+
|
|
47
|
+
let delivered = 0;
|
|
48
|
+
|
|
49
|
+
if (target && target.portalClient) {
|
|
50
|
+
if (sendTo(clients.get(target.portalClient), frame, msg)) delivered++;
|
|
51
|
+
return { mode: "unicast", delivered };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (target && target.userId) {
|
|
55
|
+
const set = userIndex.get(target.userId);
|
|
56
|
+
if (set) {
|
|
57
|
+
set.forEach((ws) => {
|
|
58
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { mode: "user-cast", delivered };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (target && target.username) {
|
|
65
|
+
clients.forEach((ws) => {
|
|
66
|
+
if (ws._portalUser && ws._portalUser.username === target.username) {
|
|
67
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return { mode: "user-cast", delivered };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Broadcast.
|
|
74
|
+
clients.forEach((ws) => {
|
|
75
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
76
|
+
});
|
|
77
|
+
return { mode: "broadcast", delivered };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { route };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const PING_INTERVAL_MS = 30_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared WebSocket heartbeat manager. One interval walks all registered
|
|
5
|
+
* WebSocket.Server instances, replacing per-client timers.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} RED
|
|
8
|
+
* @returns {{ registerPingedServer: Function, unregisterPingedServer: Function }}
|
|
9
|
+
*/
|
|
10
|
+
function createWsHeartbeat(RED) {
|
|
11
|
+
if (!RED.settings.portalReactPingedServers) {
|
|
12
|
+
RED.settings.portalReactPingedServers = new Set();
|
|
13
|
+
}
|
|
14
|
+
const pingedServers = RED.settings.portalReactPingedServers;
|
|
15
|
+
|
|
16
|
+
if (!RED.settings.portalReactPingTick) {
|
|
17
|
+
RED.settings.portalReactPingTick = { iv: null };
|
|
18
|
+
}
|
|
19
|
+
const pingTick = RED.settings.portalReactPingTick;
|
|
20
|
+
|
|
21
|
+
function pingSweep() {
|
|
22
|
+
for (const srv of pingedServers) {
|
|
23
|
+
try {
|
|
24
|
+
srv.clients.forEach((ws) => {
|
|
25
|
+
if (ws._isAlive === false) {
|
|
26
|
+
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
ws._isAlive = false;
|
|
30
|
+
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
31
|
+
});
|
|
32
|
+
} catch (e) {
|
|
33
|
+
RED.log.trace("[portal-react] ping sweep: " + e.message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registerPingedServer(wsServer) {
|
|
39
|
+
if (pingedServers.has(wsServer)) return;
|
|
40
|
+
pingedServers.add(wsServer);
|
|
41
|
+
if (!pingTick.iv) {
|
|
42
|
+
pingTick.iv = setInterval(pingSweep, PING_INTERVAL_MS);
|
|
43
|
+
pingTick.iv.unref?.();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unregisterPingedServer(wsServer) {
|
|
48
|
+
if (!pingedServers.delete(wsServer)) return;
|
|
49
|
+
if (pingedServers.size === 0 && pingTick.iv) {
|
|
50
|
+
clearInterval(pingTick.iv);
|
|
51
|
+
pingTick.iv = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { registerPingedServer, unregisterPingedServer };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { createWsHeartbeat };
|