@aaqu/fromcubes-react 0.0.0-stage → 0.2.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.
Files changed (37) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +435 -2
  3. package/THIRD-PARTY-NOTICES.md +164 -0
  4. package/examples/D3 Poland Map.json +90 -0
  5. package/examples/Live Chart.json +101 -0
  6. package/examples/PixiJS Sprites.json +114 -0
  7. package/examples/Sensor Portal.json +93 -0
  8. package/examples/Shared Components.json +68 -0
  9. package/examples/Three.js Scene.json +118 -0
  10. package/examples/Utility Hooks.json +94 -0
  11. package/examples/WebGPU Shader.json +110 -0
  12. package/nodes/editor/ark-pixel.LICENSE.txt +96 -0
  13. package/nodes/editor/ark-pixel.woff2 +0 -0
  14. package/nodes/editor/file-tree.js +403 -0
  15. package/nodes/editor/format.js +182 -0
  16. package/nodes/editor/icons.svg +46 -0
  17. package/nodes/editor/mark.svg +5 -0
  18. package/nodes/editor/provider-settings.js +192 -0
  19. package/nodes/editor/workspace.css +660 -0
  20. package/nodes/editor/workspace.js +1565 -0
  21. package/nodes/icons/fromcubes-component.svg +27 -0
  22. package/nodes/icons/fromcubes-react.svg +25 -0
  23. package/nodes/icons/fromcubes-utility.svg +21 -0
  24. package/nodes/lib/admin-api.js +238 -0
  25. package/nodes/lib/admin-security.js +161 -0
  26. package/nodes/lib/api.js +29 -0
  27. package/nodes/lib/helpers.js +990 -0
  28. package/nodes/lib/hooks.js +145 -0
  29. package/nodes/lib/page-builder.js +499 -0
  30. package/nodes/lib/portal-page-route.js +131 -0
  31. package/nodes/lib/providers.js +581 -0
  32. package/nodes/lib/registry-nodes.js +331 -0
  33. package/nodes/lib/router.js +103 -0
  34. package/nodes/lib/ws-heartbeat.js +58 -0
  35. package/nodes/portal-react.html +2539 -0
  36. package/nodes/portal-react.js +1652 -0
  37. package/package.json +73 -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 };