@aaqu/fromcubes-portal-react 0.1.0-alpha.17 → 0.1.0-alpha.19
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/nodes/lib/helpers.js +0 -14
- package/nodes/portal-react.js +130 -43
- package/package.json +1 -1
package/nodes/lib/helpers.js
CHANGED
|
@@ -121,18 +121,6 @@ function removeRoute(router, path) {
|
|
|
121
121
|
);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
function esc(s) {
|
|
125
|
-
return String(s)
|
|
126
|
-
.replace(/&/g, "&")
|
|
127
|
-
.replace(/</g, "<")
|
|
128
|
-
.replace(/>/g, ">")
|
|
129
|
-
.replace(/"/g, """);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function escScript(s) {
|
|
133
|
-
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
134
|
-
}
|
|
135
|
-
|
|
136
124
|
module.exports = function (RED) {
|
|
137
125
|
return createHelpers(RED);
|
|
138
126
|
};
|
|
@@ -307,8 +295,6 @@ function createHelpers(RED) {
|
|
|
307
295
|
removeRoute,
|
|
308
296
|
isSafeName,
|
|
309
297
|
validateSubPath,
|
|
310
|
-
esc,
|
|
311
|
-
escScript,
|
|
312
298
|
pkgRoot,
|
|
313
299
|
userDir,
|
|
314
300
|
cacheDir,
|
package/nodes/portal-react.js
CHANGED
|
@@ -45,6 +45,20 @@ module.exports = function (RED) {
|
|
|
45
45
|
}
|
|
46
46
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
47
47
|
|
|
48
|
+
// Per-portal set of component names the portal depends on (needed set from last rebuild,
|
|
49
|
+
// including transitive deps). Lets component changes target only portals that use them.
|
|
50
|
+
if (!RED.settings.fcPortalNeeded) {
|
|
51
|
+
RED.settings.fcPortalNeeded = {};
|
|
52
|
+
}
|
|
53
|
+
const portalNeeded = RED.settings.fcPortalNeeded;
|
|
54
|
+
|
|
55
|
+
// Per-portal raw user JSX code string. Used as fallback to detect references to
|
|
56
|
+
// newly-added components that haven't been in a `needed` set yet.
|
|
57
|
+
if (!RED.settings.fcPortalCode) {
|
|
58
|
+
RED.settings.fcPortalCode = {};
|
|
59
|
+
}
|
|
60
|
+
const portalCode = RED.settings.fcPortalCode;
|
|
61
|
+
|
|
48
62
|
// Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
|
|
49
63
|
if (!RED.settings.fcEndpointOwners) {
|
|
50
64
|
RED.settings.fcEndpointOwners = {};
|
|
@@ -57,23 +71,77 @@ module.exports = function (RED) {
|
|
|
57
71
|
}
|
|
58
72
|
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
59
73
|
|
|
60
|
-
// Debounced rebuild
|
|
61
|
-
// Yields event loop between builds so HTTP server stays responsive
|
|
74
|
+
// Debounced selective rebuild: coalesces multiple component changes into one pass.
|
|
75
|
+
// Yields event loop between builds so HTTP server stays responsive.
|
|
62
76
|
let _rebuildTimer = null;
|
|
63
|
-
|
|
77
|
+
const _dirtyComps = new Set();
|
|
78
|
+
let _rebuildAllPending = false;
|
|
79
|
+
|
|
80
|
+
// Startup gate: on first process start, Node-RED constructs portal/component nodes
|
|
81
|
+
// sequentially over a window longer than the 50ms debounce. Without gating, an early
|
|
82
|
+
// flush rebuilds a portal, then a late component registration triggers a second
|
|
83
|
+
// rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
|
|
84
|
+
// collapses to exactly one rebuild pass.
|
|
85
|
+
let _startupPhase = true;
|
|
86
|
+
function _endStartupPhase() {
|
|
87
|
+
if (!_startupPhase) return;
|
|
88
|
+
_startupPhase = false;
|
|
89
|
+
if (_rebuildAllPending || _dirtyComps.size > 0) {
|
|
90
|
+
if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
|
|
91
|
+
_flushRebuild();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
if (RED.events && typeof RED.events.once === "function") {
|
|
96
|
+
RED.events.once("flows:started", _endStartupPhase);
|
|
97
|
+
}
|
|
98
|
+
} catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
|
|
99
|
+
// Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
|
|
100
|
+
setTimeout(_endStartupPhase, 2000).unref?.();
|
|
101
|
+
|
|
102
|
+
function _armRebuild() {
|
|
103
|
+
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
64
104
|
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
65
|
-
_rebuildTimer = setTimeout(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
105
|
+
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
106
|
+
}
|
|
107
|
+
function scheduleRebuildAll() {
|
|
108
|
+
_rebuildAllPending = true;
|
|
109
|
+
_armRebuild();
|
|
110
|
+
}
|
|
111
|
+
function scheduleRebuildUsing(compName) {
|
|
112
|
+
if (!compName) return;
|
|
113
|
+
_dirtyComps.add(compName);
|
|
114
|
+
_armRebuild();
|
|
115
|
+
}
|
|
116
|
+
function _flushRebuild() {
|
|
117
|
+
_rebuildTimer = null;
|
|
118
|
+
const all = _rebuildAllPending;
|
|
119
|
+
const dirty = new Set(_dirtyComps);
|
|
120
|
+
_rebuildAllPending = false;
|
|
121
|
+
_dirtyComps.clear();
|
|
122
|
+
|
|
123
|
+
const targetIds = new Set();
|
|
124
|
+
for (const nodeId of Object.keys(rebuildCallbacks)) {
|
|
125
|
+
if (all) { targetIds.add(nodeId); continue; }
|
|
126
|
+
const used = portalNeeded[nodeId];
|
|
127
|
+
const raw = portalCode[nodeId] || "";
|
|
128
|
+
for (const name of dirty) {
|
|
129
|
+
if ((used && used.has(name)) || raw.includes(name)) {
|
|
130
|
+
targetIds.add(nodeId);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
74
133
|
}
|
|
75
|
-
|
|
76
|
-
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
137
|
+
let i = 0;
|
|
138
|
+
function next() {
|
|
139
|
+
if (i >= fns.length) return;
|
|
140
|
+
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
141
|
+
i++;
|
|
142
|
+
if (i < fns.length) setImmediate(next);
|
|
143
|
+
}
|
|
144
|
+
next();
|
|
77
145
|
}
|
|
78
146
|
|
|
79
147
|
// ── Load modules ─────────────────────────────────────────────
|
|
@@ -135,20 +203,24 @@ module.exports = function (RED) {
|
|
|
135
203
|
}
|
|
136
204
|
compNameOwners[compName] = node.id;
|
|
137
205
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
206
|
+
const newCode = config.compCode || "";
|
|
207
|
+
const prevCode = registry[compName]?.code;
|
|
208
|
+
registry[compName] = { code: newCode };
|
|
141
209
|
|
|
142
210
|
node.status({ fill: "green", shape: "dot", text: compName });
|
|
143
211
|
|
|
144
|
-
//
|
|
145
|
-
|
|
212
|
+
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
213
|
+
if (prevCode !== newCode) {
|
|
214
|
+
scheduleRebuildUsing(compName);
|
|
215
|
+
}
|
|
146
216
|
|
|
147
217
|
node.on("close", function (removed, done) {
|
|
148
218
|
if (compNameOwners[compName] === node.id) {
|
|
149
219
|
delete compNameOwners[compName];
|
|
150
220
|
}
|
|
151
221
|
delete registry[compName];
|
|
222
|
+
// Portals depending on this component must rebuild (topology changed or name resolution breaks).
|
|
223
|
+
scheduleRebuildUsing(compName);
|
|
152
224
|
if (done) done();
|
|
153
225
|
});
|
|
154
226
|
}
|
|
@@ -243,7 +315,7 @@ module.exports = function (RED) {
|
|
|
243
315
|
}
|
|
244
316
|
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
245
317
|
clients.forEach((ws) => {
|
|
246
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (
|
|
318
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
|
|
247
319
|
});
|
|
248
320
|
|
|
249
321
|
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
@@ -268,6 +340,10 @@ module.exports = function (RED) {
|
|
|
268
340
|
}
|
|
269
341
|
}
|
|
270
342
|
|
|
343
|
+
// Remember which components this portal depends on, so component changes
|
|
344
|
+
// can target only affected portals.
|
|
345
|
+
portalNeeded[nodeId] = new Set(needed);
|
|
346
|
+
|
|
271
347
|
// Topological sort only needed components
|
|
272
348
|
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
273
349
|
entries.sort((a, b) => {
|
|
@@ -388,7 +464,7 @@ module.exports = function (RED) {
|
|
|
388
464
|
showWsStatus,
|
|
389
465
|
};
|
|
390
466
|
clients.forEach((ws) => {
|
|
391
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (
|
|
467
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (e) { RED.log.trace("[portal-react] ws send error frame: " + e.message); }
|
|
392
468
|
});
|
|
393
469
|
return;
|
|
394
470
|
}
|
|
@@ -413,28 +489,29 @@ module.exports = function (RED) {
|
|
|
413
489
|
);
|
|
414
490
|
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
415
491
|
clients.forEach((ws) => {
|
|
416
|
-
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (
|
|
492
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (e) { RED.log.trace("[portal-react] ws send transpile error: " + e.message); }
|
|
417
493
|
});
|
|
418
494
|
} else {
|
|
419
|
-
|
|
420
|
-
fill: "green",
|
|
421
|
-
shape: "dot",
|
|
422
|
-
text: `built • ${endpoint}`,
|
|
423
|
-
});
|
|
495
|
+
updateStatus();
|
|
424
496
|
if (compiled.metafile) {
|
|
425
497
|
const output = Object.values(compiled.metafile.outputs)[0];
|
|
426
498
|
const sizes = output
|
|
427
499
|
? Object.entries(output.inputs)
|
|
428
500
|
.map(([name, info]) => ({
|
|
429
|
-
name: name
|
|
501
|
+
name: name
|
|
502
|
+
.replace(/^.*node_modules\//, "")
|
|
503
|
+
.replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
|
|
430
504
|
bytes: info.bytesInOutput,
|
|
431
505
|
}))
|
|
432
506
|
.sort((a, b) => b.bytes - a.bytes)
|
|
433
507
|
.slice(0, 5)
|
|
434
508
|
: [];
|
|
435
|
-
const totalKB = (compiled.js.length / 1024)
|
|
509
|
+
const totalKB = Math.round(compiled.js.length / 1024);
|
|
510
|
+
const top = sizes
|
|
511
|
+
.map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
|
|
512
|
+
.join(" · ");
|
|
436
513
|
node.log(
|
|
437
|
-
`
|
|
514
|
+
`[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
|
|
438
515
|
);
|
|
439
516
|
}
|
|
440
517
|
}
|
|
@@ -482,7 +559,7 @@ module.exports = function (RED) {
|
|
|
482
559
|
if (!compiled.error && contentHash) {
|
|
483
560
|
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
484
561
|
clients.forEach((ws) => {
|
|
485
|
-
try { if (ws.readyState === 1) ws.send(versionFrame); } catch (
|
|
562
|
+
try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
|
|
486
563
|
});
|
|
487
564
|
}
|
|
488
565
|
|
|
@@ -491,11 +568,7 @@ module.exports = function (RED) {
|
|
|
491
568
|
if (state && state.jsxHash === jsxHash) {
|
|
492
569
|
state.css = css;
|
|
493
570
|
state.cssHash = cssHash;
|
|
494
|
-
|
|
495
|
-
fill: "green",
|
|
496
|
-
shape: "dot",
|
|
497
|
-
text: `built • ${endpoint}`,
|
|
498
|
-
});
|
|
571
|
+
updateStatus();
|
|
499
572
|
}
|
|
500
573
|
});
|
|
501
574
|
} catch (e) {
|
|
@@ -506,6 +579,8 @@ module.exports = function (RED) {
|
|
|
506
579
|
|
|
507
580
|
// Register rebuild callback so library components can trigger re-transpile
|
|
508
581
|
rebuildCallbacks[nodeId] = rebuild;
|
|
582
|
+
// Remember raw user JSX so selective rebuild can detect references to new components
|
|
583
|
+
portalCode[nodeId] = componentCode;
|
|
509
584
|
|
|
510
585
|
// Initial build: debounced so all fc-portal-component nodes register first
|
|
511
586
|
scheduleRebuildAll();
|
|
@@ -607,7 +682,7 @@ module.exports = function (RED) {
|
|
|
607
682
|
// Plugin hook: plugins may reject the connection before upgrade.
|
|
608
683
|
// Default (no plugins) = allowed, matches dashboard behavior.
|
|
609
684
|
if (!hooks.allow("onIsValidConnection", request)) {
|
|
610
|
-
try { socket.destroy(); } catch (
|
|
685
|
+
try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
|
|
611
686
|
return;
|
|
612
687
|
}
|
|
613
688
|
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
@@ -716,7 +791,8 @@ module.exports = function (RED) {
|
|
|
716
791
|
try {
|
|
717
792
|
ws.send(frame);
|
|
718
793
|
return true;
|
|
719
|
-
} catch (
|
|
794
|
+
} catch (e) {
|
|
795
|
+
RED.log.trace("[portal-react] ws send frame: " + e.message);
|
|
720
796
|
return false;
|
|
721
797
|
}
|
|
722
798
|
}
|
|
@@ -747,7 +823,7 @@ module.exports = function (RED) {
|
|
|
747
823
|
clients.forEach((ws) => {
|
|
748
824
|
try {
|
|
749
825
|
ws.close(1001, "node redeployed");
|
|
750
|
-
} catch (
|
|
826
|
+
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
751
827
|
});
|
|
752
828
|
clients.clear();
|
|
753
829
|
|
|
@@ -755,12 +831,14 @@ module.exports = function (RED) {
|
|
|
755
831
|
if (wsServer) {
|
|
756
832
|
try {
|
|
757
833
|
wsServer.close();
|
|
758
|
-
} catch (
|
|
834
|
+
} catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
|
|
759
835
|
wsServer = null;
|
|
760
836
|
}
|
|
761
837
|
|
|
762
|
-
// Unregister rebuild callback
|
|
838
|
+
// Unregister rebuild callback + selective-rebuild metadata
|
|
763
839
|
delete rebuildCallbacks[nodeId];
|
|
840
|
+
delete portalNeeded[nodeId];
|
|
841
|
+
delete portalCode[nodeId];
|
|
764
842
|
|
|
765
843
|
// Release endpoint ownership
|
|
766
844
|
if (endpointOwners[endpoint] === nodeId) {
|
|
@@ -796,7 +874,7 @@ module.exports = function (RED) {
|
|
|
796
874
|
function wsSend(ws, obj) {
|
|
797
875
|
try {
|
|
798
876
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
799
|
-
} catch (
|
|
877
|
+
} catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
|
|
800
878
|
}
|
|
801
879
|
|
|
802
880
|
function updateStatus() {
|
|
@@ -870,7 +948,12 @@ module.exports = function (RED) {
|
|
|
870
948
|
const { name, code } = req.body || {};
|
|
871
949
|
if (!isSafeName(name))
|
|
872
950
|
return res.status(400).json({ error: "invalid name" });
|
|
873
|
-
|
|
951
|
+
const newCode = code || "";
|
|
952
|
+
const prevCode = registry[name]?.code;
|
|
953
|
+
registry[name] = { code: newCode };
|
|
954
|
+
if (prevCode !== newCode) {
|
|
955
|
+
scheduleRebuildUsing(name);
|
|
956
|
+
}
|
|
874
957
|
res.json({ ok: true });
|
|
875
958
|
});
|
|
876
959
|
|
|
@@ -878,7 +961,11 @@ module.exports = function (RED) {
|
|
|
878
961
|
const name = req.params.name;
|
|
879
962
|
if (!isSafeName(name))
|
|
880
963
|
return res.status(400).json({ error: "invalid name" });
|
|
964
|
+
const existed = Object.prototype.hasOwnProperty.call(registry, name);
|
|
881
965
|
delete registry[name];
|
|
966
|
+
if (existed) {
|
|
967
|
+
scheduleRebuildUsing(name);
|
|
968
|
+
}
|
|
882
969
|
res.json({ ok: true });
|
|
883
970
|
});
|
|
884
971
|
};
|