@aaqu/fromcubes-react 0.0.0-stage → 0.1.0
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 +190 -0
- package/README.md +405 -2
- package/THIRD-PARTY-NOTICES.md +164 -0
- 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/editor/ark-pixel.LICENSE.txt +96 -0
- package/nodes/editor/ark-pixel.woff2 +0 -0
- package/nodes/editor/file-tree.js +403 -0
- package/nodes/editor/format.js +182 -0
- package/nodes/editor/icons.svg +46 -0
- package/nodes/editor/mark.svg +5 -0
- package/nodes/editor/provider-settings.js +192 -0
- package/nodes/editor/workspace.css +653 -0
- package/nodes/editor/workspace.js +1565 -0
- package/nodes/icons/fromcubes-component.svg +27 -0
- package/nodes/icons/fromcubes-react.svg +25 -0
- package/nodes/icons/fromcubes-utility.svg +21 -0
- package/nodes/lib/admin-api.js +238 -0
- package/nodes/lib/admin-security.js +161 -0
- package/nodes/lib/api.js +29 -0
- package/nodes/lib/helpers.js +990 -0
- package/nodes/lib/hooks.js +145 -0
- package/nodes/lib/page-builder.js +499 -0
- package/nodes/lib/portal-page-route.js +131 -0
- package/nodes/lib/providers.js +536 -0
- package/nodes/lib/registry-nodes.js +331 -0
- package/nodes/lib/router.js +103 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +2539 -0
- package/nodes/portal-react.js +1652 -0
- package/package.json +76 -4
|
@@ -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,103 @@
|
|
|
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.login → user-cast fallback (O(N) scan)
|
|
13
|
+
* 4. msg._client.authenticated → auth-cast (every session with a portal user)
|
|
14
|
+
* 5. otherwise → broadcast — unless the node-level
|
|
15
|
+
* `ctx.authOnly` flag is set, which turns an untargeted msg into an
|
|
16
|
+
* auth-cast; `_client = { authenticated: false }` forces broadcast then.
|
|
17
|
+
*
|
|
18
|
+
* Returns a shallow summary `{ mode, delivered }` for observability/tests.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} RouteContext
|
|
23
|
+
* @property {Map<string, import("ws").WebSocket>} clients portalClient → ws
|
|
24
|
+
* @property {Map<string, Set<import("ws").WebSocket>>} userIndex userId → ws set
|
|
25
|
+
* @property {(ws: any, frame: string, msg: Object) => boolean} sendTo
|
|
26
|
+
* @property {boolean} [authOnly] Untargeted msgs default to auth-cast, not broadcast.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} RouteResult
|
|
31
|
+
* @property {"unicast"|"user-cast"|"auth-cast"|"broadcast"} mode
|
|
32
|
+
* @property {number} delivered
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pure router — chooses delivery mode based on `msg._client` and dispatches
|
|
37
|
+
* via `ctx.sendTo` (which is responsible for hook checks and send-failure
|
|
38
|
+
* handling). Has no side-effects other than calling `sendTo`.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} msg
|
|
41
|
+
* @param {RouteContext} ctx
|
|
42
|
+
* @returns {RouteResult}
|
|
43
|
+
*/
|
|
44
|
+
function route(msg, ctx) {
|
|
45
|
+
const { clients, userIndex, sendTo } = ctx;
|
|
46
|
+
const target = msg && msg._client;
|
|
47
|
+
const frame = JSON.stringify({ type: "data", payload: msg.payload });
|
|
48
|
+
|
|
49
|
+
let delivered = 0;
|
|
50
|
+
|
|
51
|
+
if (target && target.portalClient) {
|
|
52
|
+
if (sendTo(clients.get(target.portalClient), frame, msg)) delivered++;
|
|
53
|
+
return { mode: "unicast", delivered };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (target && target.userId) {
|
|
57
|
+
const set = userIndex.get(target.userId);
|
|
58
|
+
if (set) {
|
|
59
|
+
set.forEach((ws) => {
|
|
60
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return { mode: "user-cast", delivered };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (target && target.login) {
|
|
67
|
+
clients.forEach((ws) => {
|
|
68
|
+
if (ws._portalUser && ws._portalUser.login === target.login) {
|
|
69
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return { mode: "user-cast", delivered };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Auth-cast: every session that arrived with x-portal-user-* identity.
|
|
76
|
+
// Anonymous sessions (no proxy headers) are skipped.
|
|
77
|
+
const authCast = () => {
|
|
78
|
+
clients.forEach((ws) => {
|
|
79
|
+
if (ws._portalUser) {
|
|
80
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return { mode: "auth-cast", delivered };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Truthy check on purpose — a sloppy `authenticated: "yes"` must narrow
|
|
87
|
+
// delivery, not silently widen it to a broadcast.
|
|
88
|
+
if (target && target.authenticated) return authCast();
|
|
89
|
+
|
|
90
|
+
// Authenticated-only delivery (node setting): an untargeted msg defaults
|
|
91
|
+
// to auth-cast instead of broadcast. The explicit escape hatch
|
|
92
|
+
// `_client = { authenticated: false }` keeps a true broadcast reachable.
|
|
93
|
+
const explicitPublic = !!(target && target.authenticated === false);
|
|
94
|
+
if (ctx.authOnly && !explicitPublic) return authCast();
|
|
95
|
+
|
|
96
|
+
// Broadcast.
|
|
97
|
+
clients.forEach((ws) => {
|
|
98
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
99
|
+
});
|
|
100
|
+
return { mode: "broadcast", delivered };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
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 };
|