@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +1 -1
- package/README.md +154 -79
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +3 -3
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +314 -0
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +347 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +1143 -196
- package/nodes/portal-react.js +911 -353
- package/package.json +21 -11
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
package/nodes/portal-react.js
CHANGED
|
@@ -1,29 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @aaqu/fromcubes-portal-react
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Node-RED node that serves React apps from configurable HTTP endpoints
|
|
5
|
+
* with live WebSocket data binding. JSX is transpiled server-side via esbuild
|
|
6
|
+
* at deploy time — browsers receive pre-compiled JS.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
|
-
const fs = require("fs");
|
|
11
10
|
const path = require("path");
|
|
12
|
-
const esbuild = require("esbuild");
|
|
13
|
-
|
|
14
|
-
const reactBundle = fs.readFileSync(
|
|
15
|
-
path.join(__dirname, "vendor", "react-19.production.min.js"),
|
|
16
|
-
"utf8",
|
|
17
|
-
);
|
|
18
|
-
const reactHash = crypto
|
|
19
|
-
.createHash("sha256")
|
|
20
|
-
.update(reactBundle)
|
|
21
|
-
.digest("hex")
|
|
22
|
-
.slice(0, 10);
|
|
23
11
|
|
|
24
12
|
module.exports = function (RED) {
|
|
25
13
|
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
26
14
|
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
15
|
+
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
27
16
|
|
|
28
17
|
// ── Shared state ──────────────────────────────────────────────
|
|
29
18
|
// Component registry: populated by fc-portal-component canvas nodes at deploy time
|
|
@@ -32,12 +21,6 @@ module.exports = function (RED) {
|
|
|
32
21
|
}
|
|
33
22
|
const registry = RED.settings.fcPortalRegistry;
|
|
34
23
|
|
|
35
|
-
// CSS cache: hash → css string
|
|
36
|
-
if (!RED.settings.fcCssCache) {
|
|
37
|
-
RED.settings.fcCssCache = {};
|
|
38
|
-
}
|
|
39
|
-
const cssCache = RED.settings.fcCssCache;
|
|
40
|
-
|
|
41
24
|
// Active upgrade handlers per node id (for cleanup on redeploy)
|
|
42
25
|
if (!RED.settings.fcUpgradeHandlers) {
|
|
43
26
|
RED.settings.fcUpgradeHandlers = {};
|
|
@@ -62,84 +45,145 @@ module.exports = function (RED) {
|
|
|
62
45
|
}
|
|
63
46
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
64
47
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 = {};
|
|
69
52
|
}
|
|
53
|
+
const portalNeeded = RED.settings.fcPortalNeeded;
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
async function getTwCompiled() {
|
|
76
|
-
if (twCompiled) return twCompiled;
|
|
77
|
-
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
78
|
-
loadStylesheet: async (id, base) => {
|
|
79
|
-
let resolved;
|
|
80
|
-
if (id === "tailwindcss") {
|
|
81
|
-
resolved = require.resolve("tailwindcss/index.css");
|
|
82
|
-
} else {
|
|
83
|
-
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
84
|
-
}
|
|
85
|
-
return {
|
|
86
|
-
content: fs.readFileSync(resolved, "utf8"),
|
|
87
|
-
base: path.dirname(resolved),
|
|
88
|
-
};
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
return twCompiled;
|
|
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 = {};
|
|
92
59
|
}
|
|
60
|
+
const portalCode = RED.settings.fcPortalCode;
|
|
93
61
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
stdin: {
|
|
98
|
-
contents: jsx,
|
|
99
|
-
resolveDir: path.join(__dirname, "../../.."),
|
|
100
|
-
loader: "jsx",
|
|
101
|
-
},
|
|
102
|
-
bundle: true,
|
|
103
|
-
format: "iife",
|
|
104
|
-
write: false,
|
|
105
|
-
target: ["es2020"],
|
|
106
|
-
jsx: "transform",
|
|
107
|
-
jsxFactory: "React.createElement",
|
|
108
|
-
jsxFragment: "React.Fragment",
|
|
109
|
-
external: ["react", "react-dom"],
|
|
110
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
111
|
-
});
|
|
112
|
-
return { js: buildResult.outputFiles[0].text, error: null };
|
|
113
|
-
} catch (e) {
|
|
114
|
-
return { js: null, error: e.message };
|
|
115
|
-
}
|
|
62
|
+
// Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
|
|
63
|
+
if (!RED.settings.fcEndpointOwners) {
|
|
64
|
+
RED.settings.fcEndpointOwners = {};
|
|
116
65
|
}
|
|
66
|
+
const endpointOwners = RED.settings.fcEndpointOwners;
|
|
117
67
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const compiled = await getTwCompiled();
|
|
122
|
-
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
123
|
-
const css = compiled.build(candidates);
|
|
124
|
-
cssCache[key] = css;
|
|
125
|
-
return css;
|
|
68
|
+
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
69
|
+
if (!RED.settings.fcCompNameOwners) {
|
|
70
|
+
RED.settings.fcCompNameOwners = {};
|
|
126
71
|
}
|
|
72
|
+
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
127
73
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
74
|
+
// Per-portal config signature — detects no-op Full-deploy reconstructions so unchanged
|
|
75
|
+
// portals skip rebuild entirely (Node-RED closes/reopens every node on Full deploy).
|
|
76
|
+
if (!RED.settings.fcPortalSig) {
|
|
77
|
+
RED.settings.fcPortalSig = {};
|
|
78
|
+
}
|
|
79
|
+
const portalSig = RED.settings.fcPortalSig;
|
|
80
|
+
|
|
81
|
+
// Debounced selective rebuild: coalesces multiple component changes into one pass.
|
|
82
|
+
// Yields event loop between builds so HTTP server stays responsive.
|
|
83
|
+
let _rebuildTimer = null;
|
|
84
|
+
const _dirtyComps = new Set();
|
|
85
|
+
const _dirtyPortals = new Set();
|
|
86
|
+
|
|
87
|
+
// Startup gate: on first process start, Node-RED constructs portal/component nodes
|
|
88
|
+
// sequentially over a window longer than the 50ms debounce. Without gating, an early
|
|
89
|
+
// flush rebuilds a portal, then a late component registration triggers a second
|
|
90
|
+
// rebuild. Hold all flushes until `flows:started` (or a 2s failsafe) so startup
|
|
91
|
+
// collapses to exactly one rebuild pass.
|
|
92
|
+
let _startupPhase = true;
|
|
93
|
+
function _endStartupPhase() {
|
|
94
|
+
if (!_startupPhase) return;
|
|
95
|
+
_startupPhase = false;
|
|
96
|
+
if (_dirtyPortals.size > 0 || _dirtyComps.size > 0) {
|
|
97
|
+
if (_rebuildTimer) { clearTimeout(_rebuildTimer); _rebuildTimer = null; }
|
|
98
|
+
_flushRebuild();
|
|
99
|
+
}
|
|
134
100
|
}
|
|
101
|
+
try {
|
|
102
|
+
if (RED.events && typeof RED.events.once === "function") {
|
|
103
|
+
RED.events.once("flows:started", _endStartupPhase);
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { RED.log.trace("[portal-react] events.once: " + e.message); }
|
|
106
|
+
// Failsafe: if flows:started never arrives (module loaded mid-run, test harness, etc.)
|
|
107
|
+
setTimeout(_endStartupPhase, 2000).unref?.();
|
|
108
|
+
|
|
109
|
+
function _armRebuild() {
|
|
110
|
+
if (_startupPhase) return; // gated — _endStartupPhase will flush
|
|
111
|
+
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
112
|
+
_rebuildTimer = setTimeout(_flushRebuild, 50);
|
|
113
|
+
_rebuildTimer.unref?.();
|
|
114
|
+
}
|
|
115
|
+
function scheduleRebuildSelf(nodeId) {
|
|
116
|
+
if (!nodeId) return;
|
|
117
|
+
_dirtyPortals.add(nodeId);
|
|
118
|
+
_armRebuild();
|
|
119
|
+
}
|
|
120
|
+
function scheduleRebuildUsing(compName) {
|
|
121
|
+
if (!compName) return;
|
|
122
|
+
_dirtyComps.add(compName);
|
|
123
|
+
_armRebuild();
|
|
124
|
+
}
|
|
125
|
+
function _flushRebuild() {
|
|
126
|
+
_rebuildTimer = null;
|
|
127
|
+
const dirty = new Set(_dirtyComps);
|
|
128
|
+
const selfIds = new Set(_dirtyPortals);
|
|
129
|
+
_dirtyComps.clear();
|
|
130
|
+
_dirtyPortals.clear();
|
|
131
|
+
|
|
132
|
+
const targetIds = new Set(selfIds);
|
|
133
|
+
if (dirty.size > 0) {
|
|
134
|
+
for (const nodeId of Object.keys(rebuildCallbacks)) {
|
|
135
|
+
if (targetIds.has(nodeId)) continue;
|
|
136
|
+
const used = portalNeeded[nodeId];
|
|
137
|
+
const raw = portalCode[nodeId] || "";
|
|
138
|
+
for (const name of dirty) {
|
|
139
|
+
if ((used && used.has(name)) || raw.includes(name)) {
|
|
140
|
+
targetIds.add(nodeId);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
135
146
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
const fns = [...targetIds].map((id) => rebuildCallbacks[id]).filter(Boolean);
|
|
148
|
+
let i = 0;
|
|
149
|
+
function next() {
|
|
150
|
+
if (i >= fns.length) return;
|
|
151
|
+
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
152
|
+
i++;
|
|
153
|
+
if (i < fns.length) setImmediate(next);
|
|
154
|
+
}
|
|
155
|
+
next();
|
|
141
156
|
}
|
|
142
157
|
|
|
158
|
+
// ── Load modules ─────────────────────────────────────────────
|
|
159
|
+
const helpers = require("./lib/helpers")(RED);
|
|
160
|
+
const {
|
|
161
|
+
hash,
|
|
162
|
+
transpile,
|
|
163
|
+
quickCheckSyntax,
|
|
164
|
+
generateCSS,
|
|
165
|
+
extractPortalUser,
|
|
166
|
+
removeRoute,
|
|
167
|
+
isSafeName,
|
|
168
|
+
validateSubPath,
|
|
169
|
+
userDir,
|
|
170
|
+
readCachedJS,
|
|
171
|
+
writeCachedJS,
|
|
172
|
+
readCachedCSS,
|
|
173
|
+
writeCachedCSS,
|
|
174
|
+
deleteCacheFiles,
|
|
175
|
+
isHashInUse,
|
|
176
|
+
} = helpers;
|
|
177
|
+
const { buildPage, buildErrorPage } = require("./lib/page-builder");
|
|
178
|
+
const hooks = require("./lib/hooks")(RED);
|
|
179
|
+
const router = require("./lib/router");
|
|
180
|
+
|
|
181
|
+
// Per-process cache of the last broadcast payload per endpoint.
|
|
182
|
+
// Lets a freshly-connected client see the most recent broadcast value
|
|
183
|
+
// (similar to dashboard2's lastMsg recovery). Sent as a distinct
|
|
184
|
+
// `recovery` WS frame so React can opt out via useNodeRed({ ignoreRecovery: true }).
|
|
185
|
+
const lastBroadcastCache = new Map();
|
|
186
|
+
|
|
143
187
|
// ── Canvas node: shared component ─────────────────────────────
|
|
144
188
|
|
|
145
189
|
function PortalComponentNode(config) {
|
|
@@ -153,31 +197,49 @@ module.exports = function (RED) {
|
|
|
153
197
|
return;
|
|
154
198
|
}
|
|
155
199
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
node.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
200
|
+
// Duplicate component name check
|
|
201
|
+
const existingOwner = compNameOwners[compName];
|
|
202
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
203
|
+
node.error(
|
|
204
|
+
`Component name "${compName}" is already used by another node`,
|
|
205
|
+
);
|
|
206
|
+
node.status({
|
|
207
|
+
fill: "red",
|
|
208
|
+
shape: "ring",
|
|
209
|
+
text: "duplicate: " + compName,
|
|
210
|
+
});
|
|
211
|
+
node.on("close", function (_removed, done) {
|
|
212
|
+
if (done) done();
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
compNameOwners[compName] = node.id;
|
|
217
|
+
|
|
218
|
+
const newCode = config.compCode || "";
|
|
219
|
+
const prevCode = registry[compName]?.code;
|
|
220
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
221
|
+
registry[compName] = { code: newCode, error: syntaxErr };
|
|
222
|
+
|
|
223
|
+
if (syntaxErr) {
|
|
224
|
+
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
225
|
+
const short = syntaxErr.split("\n")[0].slice(0, 40);
|
|
226
|
+
node.status({ fill: "red", shape: "dot", text: "syntax: " + short });
|
|
227
|
+
} else {
|
|
228
|
+
node.status({ fill: "green", shape: "dot", text: compName });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Only rebuild portals that reference this component, and only if the code actually changed.
|
|
232
|
+
if (prevCode !== newCode) {
|
|
233
|
+
scheduleRebuildUsing(compName);
|
|
234
|
+
}
|
|
178
235
|
|
|
179
236
|
node.on("close", function (removed, done) {
|
|
237
|
+
if (compNameOwners[compName] === node.id) {
|
|
238
|
+
delete compNameOwners[compName];
|
|
239
|
+
}
|
|
180
240
|
delete registry[compName];
|
|
241
|
+
// Portals depending on this component must rebuild (topology changed or name resolution breaks).
|
|
242
|
+
scheduleRebuildUsing(compName);
|
|
181
243
|
if (done) done();
|
|
182
244
|
});
|
|
183
245
|
}
|
|
@@ -191,130 +253,580 @@ module.exports = function (RED) {
|
|
|
191
253
|
const nodeId = node.id;
|
|
192
254
|
|
|
193
255
|
// Config
|
|
194
|
-
const
|
|
256
|
+
const subPathResult = validateSubPath(config.subPath);
|
|
257
|
+
const legacyEndpoint =
|
|
258
|
+
typeof config.endpoint === "string" && config.endpoint.trim().length > 0
|
|
259
|
+
? config.endpoint.trim()
|
|
260
|
+
: null;
|
|
261
|
+
|
|
262
|
+
if (!subPathResult.ok) {
|
|
263
|
+
// No valid subPath. If there's a legacy endpoint, hard-fail with a
|
|
264
|
+
// migration message; otherwise fail on the sub-path error.
|
|
265
|
+
if (legacyEndpoint) {
|
|
266
|
+
node.error(
|
|
267
|
+
`Legacy 'endpoint' field detected ("${legacyEndpoint}"). ` +
|
|
268
|
+
"Open the node, set a Sub-path (served under /fromcubes/<sub-path>), and redeploy.",
|
|
269
|
+
);
|
|
270
|
+
node.status({ fill: "red", shape: "ring", text: "legacy endpoint" });
|
|
271
|
+
} else {
|
|
272
|
+
node.error("Invalid sub-path: " + subPathResult.error);
|
|
273
|
+
node.status({ fill: "red", shape: "ring", text: "bad sub-path" });
|
|
274
|
+
}
|
|
275
|
+
node.on("close", function (_removed, done) {
|
|
276
|
+
if (done) done();
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const subPath = subPathResult.value;
|
|
281
|
+
const endpoint = "/fromcubes/" + subPath;
|
|
282
|
+
|
|
195
283
|
const componentCode = config.componentCode || "";
|
|
196
284
|
const pageTitle = config.pageTitle || "Portal";
|
|
197
285
|
const customHead = config.customHead || "";
|
|
286
|
+
const portalAuth = config.portalAuth === true;
|
|
287
|
+
const showWsStatus = config.showWsStatus === true;
|
|
288
|
+
const libs = config.libs || [];
|
|
289
|
+
|
|
290
|
+
// ── Duplicate endpoint check ──
|
|
291
|
+
const existingOwner = endpointOwners[endpoint];
|
|
292
|
+
if (existingOwner && existingOwner !== nodeId) {
|
|
293
|
+
node.error(
|
|
294
|
+
`Endpoint "${endpoint}" is already used by another portal node`,
|
|
295
|
+
);
|
|
296
|
+
node.status({
|
|
297
|
+
fill: "red",
|
|
298
|
+
shape: "ring",
|
|
299
|
+
text: "duplicate: " + endpoint,
|
|
300
|
+
});
|
|
301
|
+
node.on("close", function (_removed, done) {
|
|
302
|
+
if (done) done();
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
endpointOwners[endpoint] = nodeId;
|
|
198
307
|
|
|
199
308
|
// State
|
|
200
|
-
const clients = new
|
|
201
|
-
|
|
309
|
+
const clients = new Map(); // portalClient → ws
|
|
310
|
+
const userIndex = new Map(); // userId → Set<ws> (O(1) user-cast)
|
|
202
311
|
let wsServer = null;
|
|
203
312
|
let isClosing = false;
|
|
313
|
+
let lastJsxHash = null;
|
|
314
|
+
|
|
315
|
+
if (libs.length > 0) {
|
|
316
|
+
node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
|
|
317
|
+
} else {
|
|
318
|
+
node.status({ fill: "yellow", shape: "ring", text: "starting..." });
|
|
319
|
+
}
|
|
204
320
|
|
|
205
|
-
const wsPath = endpoint + "/_ws";
|
|
321
|
+
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
322
|
+
|
|
323
|
+
function updateStatus() {
|
|
324
|
+
if (isClosing) return;
|
|
325
|
+
const st = pageState[endpoint];
|
|
326
|
+
const n = clients.size;
|
|
327
|
+
const clientTail = n > 0 ? ` [${n} client${n !== 1 ? "s" : ""}]` : "";
|
|
328
|
+
|
|
329
|
+
// Preserve build-error state — don't clobber with client count until JSX is fixed.
|
|
330
|
+
// Show "(serving last good)" suffix in degraded mode (ring shape) so it is
|
|
331
|
+
// obvious the portal still works for connected clients despite the broken build.
|
|
332
|
+
if (st && st.compiled && st.compiled.error) {
|
|
333
|
+
let base;
|
|
334
|
+
if (st.errorSource) base = "broken: " + st.errorSource;
|
|
335
|
+
else if (st.errorKind === "missing-return") base = "missing return";
|
|
336
|
+
else if (st.errorKind === "rebuild") base = "rebuild error";
|
|
337
|
+
else base = "transpile error";
|
|
338
|
+
if (st.lastGood) {
|
|
339
|
+
node.status({
|
|
340
|
+
fill: "red",
|
|
341
|
+
shape: "ring",
|
|
342
|
+
text: base + " (serving last good)" + clientTail,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
node.status({ fill: "red", shape: "dot", text: base + clientTail });
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
// Preserve building state — same reason.
|
|
350
|
+
if (st && st.building) {
|
|
351
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Build succeeded but a connected browser threw at runtime
|
|
355
|
+
// (e.g. ReferenceError to a missing component / undefined identifier).
|
|
356
|
+
if (st && st.runtimeError) {
|
|
357
|
+
node.status({
|
|
358
|
+
fill: "red",
|
|
359
|
+
shape: "ring",
|
|
360
|
+
text: "runtime error" + clientTail,
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
node.status({
|
|
365
|
+
fill: n > 0 ? "green" : "grey",
|
|
366
|
+
shape: n > 0 ? "dot" : "ring",
|
|
367
|
+
text: `${endpoint}${clientTail || " [0 clients]"}`,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
206
370
|
|
|
207
371
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
208
372
|
|
|
209
373
|
function rebuild() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`
|
|
223
|
-
)
|
|
224
|
-
.join("\n\n");
|
|
225
|
-
|
|
226
|
-
const fullJsx = [
|
|
227
|
-
"// ── React shorthand ──",
|
|
228
|
-
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
229
|
-
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
230
|
-
"",
|
|
231
|
-
"// ── useNodeRed hook ──",
|
|
232
|
-
`function useNodeRed() {
|
|
233
|
-
const [data, setData] = React.useState(window.__NR._lastData);
|
|
234
|
-
React.useEffect(() => {
|
|
235
|
-
return window.__NR.subscribe(setData);
|
|
236
|
-
}, []);
|
|
237
|
-
const send = React.useCallback((payload, topic) => {
|
|
238
|
-
window.__NR.send(payload, topic);
|
|
239
|
-
}, []);
|
|
240
|
-
return { data, send };
|
|
241
|
-
}`,
|
|
242
|
-
"",
|
|
243
|
-
"// ── Library components ──",
|
|
244
|
-
libraryJsx,
|
|
245
|
-
"",
|
|
246
|
-
"// ── View component ──",
|
|
247
|
-
componentCode,
|
|
248
|
-
"",
|
|
249
|
-
"// ── Mount ──",
|
|
250
|
-
"ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
251
|
-
].join("\n");
|
|
374
|
+
try {
|
|
375
|
+
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
376
|
+
const prevState = pageState[endpoint];
|
|
377
|
+
const prevHash = prevState?.jsxHash;
|
|
378
|
+
if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
|
|
379
|
+
deleteCacheFiles(prevHash);
|
|
380
|
+
}
|
|
381
|
+
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
382
|
+
updateStatus();
|
|
383
|
+
clients.forEach((ws) => {
|
|
384
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (e) { RED.log.trace("[portal-react] ws send building: " + e.message); }
|
|
385
|
+
});
|
|
252
386
|
|
|
253
|
-
|
|
387
|
+
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
388
|
+
const allEntries = Object.entries(registry);
|
|
389
|
+
const needed = new Set();
|
|
390
|
+
|
|
391
|
+
function addWithDeps(name) {
|
|
392
|
+
if (needed.has(name)) return;
|
|
393
|
+
const entry = registry[name];
|
|
394
|
+
if (!entry) return;
|
|
395
|
+
needed.add(name);
|
|
396
|
+
for (const [other] of allEntries) {
|
|
397
|
+
if (other !== name && entry.code.includes(other)) {
|
|
398
|
+
addWithDeps(other);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
254
402
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
403
|
+
for (const [name] of allEntries) {
|
|
404
|
+
if (componentCode.includes(name)) {
|
|
405
|
+
addWithDeps(name);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
261
408
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
409
|
+
// Remember which components this portal depends on, so component changes
|
|
410
|
+
// can target only affected portals.
|
|
411
|
+
portalNeeded[nodeId] = new Set(needed);
|
|
412
|
+
|
|
413
|
+
// Topological sort only needed components
|
|
414
|
+
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
415
|
+
entries.sort((a, b) => {
|
|
416
|
+
const aUsesB = a[1].code.includes(b[0]);
|
|
417
|
+
const bUsesA = b[1].code.includes(a[0]);
|
|
418
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
419
|
+
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
420
|
+
return 0;
|
|
421
|
+
});
|
|
422
|
+
const libraryJsx = entries
|
|
423
|
+
.map(
|
|
424
|
+
([name, c]) =>
|
|
425
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
426
|
+
)
|
|
427
|
+
.join("\n\n");
|
|
428
|
+
|
|
429
|
+
// Extract import statements from library/user code so they appear at top level
|
|
430
|
+
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
431
|
+
const libImports = libraryJsx.match(importRe) || [];
|
|
432
|
+
const userImports = componentCode.match(importRe) || [];
|
|
433
|
+
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
434
|
+
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
435
|
+
|
|
436
|
+
// Warn about import * (prevents tree-shaking)
|
|
437
|
+
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
438
|
+
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
439
|
+
for (const imp of [...libImports, ...userImports]) {
|
|
440
|
+
const m = imp.match(starRe);
|
|
441
|
+
if (!m) continue;
|
|
442
|
+
const [, localName, modulePath] = m;
|
|
443
|
+
const propRe = new RegExp(
|
|
444
|
+
`\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
|
|
445
|
+
"g",
|
|
446
|
+
);
|
|
447
|
+
const props = new Set();
|
|
448
|
+
let pm;
|
|
449
|
+
while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
|
|
450
|
+
if (props.size > 0) {
|
|
451
|
+
const named = [...props].sort().join(", ");
|
|
452
|
+
node.warn(
|
|
453
|
+
`"import * as ${localName}" bundles entire ${modulePath} library. ` +
|
|
454
|
+
`For smaller builds use: import { ${named} } from '${modulePath}'`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const fullJsx = [
|
|
460
|
+
"// ── Imports ──",
|
|
461
|
+
'import React from "react";',
|
|
462
|
+
'import ReactDOM from "react-dom";',
|
|
463
|
+
'import { createRoot } from "react-dom/client";',
|
|
464
|
+
...libImports,
|
|
465
|
+
...userImports,
|
|
466
|
+
"",
|
|
467
|
+
"// ── React shorthand ──",
|
|
468
|
+
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
469
|
+
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
470
|
+
"",
|
|
471
|
+
"// ── useNodeRed hook ──",
|
|
472
|
+
[
|
|
473
|
+
"function useNodeRed(opts) {",
|
|
474
|
+
" // opts.ignoreRecovery = true → ignore the cached last-broadcast",
|
|
475
|
+
" // frame the server sends on connect; data stays undefined until",
|
|
476
|
+
" // a fresh broadcast arrives. Latched once globally — strictest",
|
|
477
|
+
" // call wins (any caller asking to ignore disables recovery for all).",
|
|
478
|
+
" if (opts && opts.ignoreRecovery) window.__NR._ignoreRecovery = true;",
|
|
479
|
+
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
480
|
+
" React.useEffect(() => window.__NR.subscribe(setData), []);",
|
|
481
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
482
|
+
" window.__NR.send(payload, topic);",
|
|
483
|
+
" }, []);",
|
|
484
|
+
" const user = window.__NR._user || null;",
|
|
485
|
+
" const portalClient = window.__NR._portalClient;",
|
|
486
|
+
" return { data, send, user, portalClient };",
|
|
487
|
+
"}",
|
|
488
|
+
].join("\n"),
|
|
489
|
+
"",
|
|
490
|
+
"// ── Library components ──",
|
|
491
|
+
cleanLibJsx,
|
|
492
|
+
"",
|
|
493
|
+
"// ── View component ──",
|
|
494
|
+
cleanCompCode,
|
|
495
|
+
"",
|
|
496
|
+
"// ── Mount ──",
|
|
497
|
+
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
498
|
+
].join("\n");
|
|
499
|
+
|
|
500
|
+
const jsxHash = hash(fullJsx);
|
|
501
|
+
|
|
502
|
+
// ── Check: any used component has its own syntax error ──
|
|
503
|
+
let errorSource = null;
|
|
504
|
+
for (const name of needed) {
|
|
505
|
+
if (registry[name]?.error) {
|
|
506
|
+
errorSource = name;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── Check: missing return in App ──
|
|
512
|
+
let missingReturn = false;
|
|
513
|
+
const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
|
|
514
|
+
if (appFnMatch) {
|
|
515
|
+
let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
|
|
516
|
+
let hasReturn = false;
|
|
517
|
+
while (i < cleanCompCode.length && depth > 0) {
|
|
518
|
+
const ch = cleanCompCode[i];
|
|
519
|
+
if (ch === "{") depth++;
|
|
520
|
+
else if (ch === "}") depth--;
|
|
521
|
+
else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
|
|
522
|
+
i++;
|
|
523
|
+
}
|
|
524
|
+
missingReturn = !hasReturn;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── Resolve compiled (success or unified error) ──
|
|
528
|
+
let compiled;
|
|
529
|
+
let cacheHit = false;
|
|
530
|
+
let errorKind = null; // 'component' | 'missing-return' | 'transpile'
|
|
531
|
+
if (errorSource) {
|
|
532
|
+
compiled = {
|
|
533
|
+
js: null,
|
|
534
|
+
error: `Component "${errorSource}" has a syntax error:\n\n${registry[errorSource].error}`,
|
|
535
|
+
};
|
|
536
|
+
errorKind = "component";
|
|
537
|
+
} else if (missingReturn) {
|
|
538
|
+
compiled = {
|
|
539
|
+
js: null,
|
|
540
|
+
error:
|
|
541
|
+
"App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
542
|
+
};
|
|
543
|
+
errorKind = "missing-return";
|
|
544
|
+
} else {
|
|
545
|
+
compiled = readCachedJS(jsxHash);
|
|
546
|
+
cacheHit = !!compiled;
|
|
547
|
+
if (!compiled) {
|
|
548
|
+
compiled = transpile(fullJsx);
|
|
549
|
+
if (!compiled.error) {
|
|
550
|
+
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (compiled.error) errorKind = "transpile";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (compiled.error) {
|
|
557
|
+
node.error(
|
|
558
|
+
(errorKind === "component"
|
|
559
|
+
? `Component "${errorSource}" syntax error: `
|
|
560
|
+
: errorKind === "missing-return"
|
|
561
|
+
? "App component has no return statement: "
|
|
562
|
+
: "JSX transpile error: ") + compiled.error,
|
|
563
|
+
);
|
|
564
|
+
// Status + WS frames handled below (lastGood-aware).
|
|
565
|
+
} else {
|
|
566
|
+
updateStatus();
|
|
567
|
+
if (compiled.metafile) {
|
|
568
|
+
const output = Object.values(compiled.metafile.outputs)[0];
|
|
569
|
+
const sizes = output
|
|
570
|
+
? Object.entries(output.inputs)
|
|
571
|
+
.map(([name, info]) => ({
|
|
572
|
+
name: name
|
|
573
|
+
.replace(/^.*node_modules\//, "")
|
|
574
|
+
.replace(/\.(js|mjs|cjs|ts|tsx)$/, ""),
|
|
575
|
+
bytes: info.bytesInOutput,
|
|
576
|
+
}))
|
|
577
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
578
|
+
.slice(0, 5)
|
|
579
|
+
: [];
|
|
580
|
+
const totalKB = Math.round(compiled.js.length / 1024);
|
|
581
|
+
const top = sizes
|
|
582
|
+
.map((s) => `${s.name} ${Math.round(s.bytes / 1024)}`)
|
|
583
|
+
.join(" · ");
|
|
584
|
+
node.log(
|
|
585
|
+
`[${node.id}] ${cacheHit ? "cached" : "built"} ${totalKB}KB · ${top}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
591
|
+
|
|
592
|
+
// ── CSS: disk cache → in-memory → generate ──
|
|
593
|
+
const cssReady = !compiled.error
|
|
594
|
+
? (() => {
|
|
595
|
+
const cachedCSS = readCachedCSS(jsxHash);
|
|
596
|
+
if (cachedCSS) return Promise.resolve(cachedCSS);
|
|
597
|
+
if (prevState?.jsxHash === jsxHash && prevState?.css) {
|
|
598
|
+
return Promise.resolve({
|
|
599
|
+
css: prevState.css,
|
|
600
|
+
cssHash: prevState.cssHash,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return generateCSS(fullJsx).then(({ css, cssHash }) => {
|
|
604
|
+
writeCachedCSS(jsxHash, css);
|
|
605
|
+
return { css, cssHash };
|
|
606
|
+
});
|
|
607
|
+
})().catch((err) => {
|
|
269
608
|
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
270
|
-
return "";
|
|
609
|
+
return { css: "", cssHash: "" };
|
|
271
610
|
})
|
|
272
|
-
|
|
611
|
+
: Promise.resolve({ css: "", cssHash: "" });
|
|
612
|
+
|
|
613
|
+
lastJsxHash = jsxHash;
|
|
614
|
+
|
|
615
|
+
// Preserve last successful build so that on transpile errors we keep
|
|
616
|
+
// serving the previous working JS instead of throwing clients to an error page.
|
|
617
|
+
const lastGood = compiled.error
|
|
618
|
+
? prevState?.lastGood || null
|
|
619
|
+
: null; // will be populated after cssReady resolves on success
|
|
620
|
+
|
|
621
|
+
pageState[endpoint] = {
|
|
622
|
+
compiled,
|
|
623
|
+
contentHash,
|
|
624
|
+
cssReady,
|
|
625
|
+
jsxHash,
|
|
626
|
+
css: null,
|
|
627
|
+
cssHash: "",
|
|
628
|
+
pageTitle,
|
|
629
|
+
wsPath,
|
|
630
|
+
customHead,
|
|
631
|
+
portalAuth,
|
|
632
|
+
showWsStatus,
|
|
633
|
+
errorSource,
|
|
634
|
+
errorKind,
|
|
635
|
+
lastGood,
|
|
636
|
+
};
|
|
273
637
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
638
|
+
if (compiled.error) {
|
|
639
|
+
// Status text (red) handled centrally by updateStatus — it formats
|
|
640
|
+
// base + "(serving last good)" + client-count suffix consistently
|
|
641
|
+
// across build and connect/disconnect events.
|
|
642
|
+
updateStatus();
|
|
643
|
+
const frame = lastGood
|
|
644
|
+
? JSON.stringify({ type: "error", message: compiled.error, degraded: true })
|
|
645
|
+
: JSON.stringify({ type: "error", message: compiled.error });
|
|
646
|
+
clients.forEach((ws) => {
|
|
647
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (e) { RED.log.trace("[portal-react] ws send error: " + e.message); }
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Notify all connected browsers that build finished — triggers reload or overlay cleanup
|
|
652
|
+
if (!compiled.error && contentHash) {
|
|
653
|
+
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
654
|
+
clients.forEach((ws) => {
|
|
655
|
+
try { if (ws.readyState === 1) ws.send(versionFrame); } catch (e) { RED.log.trace("[portal-react] ws send version: " + e.message); }
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
cssReady.then(({ css, cssHash }) => {
|
|
660
|
+
const state = pageState[endpoint];
|
|
661
|
+
if (state && state.jsxHash === jsxHash) {
|
|
662
|
+
state.css = css;
|
|
663
|
+
state.cssHash = cssHash;
|
|
664
|
+
// Snapshot current good build so future failed builds can fall back.
|
|
665
|
+
if (!state.compiled.error && state.compiled.js) {
|
|
666
|
+
state.lastGood = {
|
|
667
|
+
compiledJs: state.compiled.js,
|
|
668
|
+
contentHash: state.contentHash,
|
|
669
|
+
cssHash,
|
|
670
|
+
pageTitle: state.pageTitle,
|
|
671
|
+
customHead: state.customHead,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
updateStatus();
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
} catch (e) {
|
|
678
|
+
node.error("Rebuild failed: " + e.message);
|
|
679
|
+
// Surface as a regular build error so the lastGood/degraded path,
|
|
680
|
+
// status formatting and FE error frame all run uniformly.
|
|
681
|
+
const prev = pageState[endpoint];
|
|
682
|
+
pageState[endpoint] = {
|
|
683
|
+
compiled: { js: null, error: "Internal rebuild error: " + e.message },
|
|
684
|
+
contentHash: "",
|
|
685
|
+
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
686
|
+
jsxHash: "",
|
|
687
|
+
css: null,
|
|
688
|
+
cssHash: "",
|
|
689
|
+
pageTitle,
|
|
690
|
+
wsPath,
|
|
691
|
+
customHead,
|
|
692
|
+
portalAuth,
|
|
693
|
+
showWsStatus,
|
|
694
|
+
errorSource: null,
|
|
695
|
+
errorKind: "rebuild",
|
|
696
|
+
lastGood: prev?.lastGood || null,
|
|
697
|
+
};
|
|
698
|
+
updateStatus();
|
|
699
|
+
const frame = JSON.stringify({
|
|
700
|
+
type: "error",
|
|
701
|
+
message: "Internal rebuild error: " + e.message,
|
|
702
|
+
degraded: !!prev?.lastGood,
|
|
703
|
+
});
|
|
704
|
+
clients.forEach((ws) => {
|
|
705
|
+
try { if (ws.readyState === 1) ws.send(frame); } catch (err) { RED.log.trace("[portal-react] ws send rebuild err: " + err.message); }
|
|
706
|
+
});
|
|
707
|
+
}
|
|
281
708
|
}
|
|
282
709
|
|
|
283
710
|
// Register rebuild callback so library components can trigger re-transpile
|
|
284
711
|
rebuildCallbacks[nodeId] = rebuild;
|
|
285
|
-
|
|
286
|
-
|
|
712
|
+
// Remember raw user JSX so selective rebuild can detect references to new components
|
|
713
|
+
portalCode[nodeId] = componentCode;
|
|
714
|
+
|
|
715
|
+
// No-op redeploy detection: if nothing in the portal's config changed AND a valid
|
|
716
|
+
// build already exists for this endpoint, skip rebuild. Node-RED Full deploy
|
|
717
|
+
// reconstructs every node even when unchanged — without this check every portal
|
|
718
|
+
// would rebuild on every Full deploy.
|
|
719
|
+
const sig = hash(
|
|
720
|
+
[
|
|
721
|
+
componentCode,
|
|
722
|
+
JSON.stringify(libs),
|
|
723
|
+
pageTitle,
|
|
724
|
+
customHead,
|
|
725
|
+
String(portalAuth),
|
|
726
|
+
String(showWsStatus),
|
|
727
|
+
].join("\0"),
|
|
728
|
+
);
|
|
729
|
+
const prevSig = portalSig[nodeId];
|
|
730
|
+
const existing = pageState[endpoint];
|
|
731
|
+
const hasValidBuild =
|
|
732
|
+
!!existing && !existing.building && !existing.compiled?.error;
|
|
733
|
+
portalSig[nodeId] = sig;
|
|
734
|
+
|
|
735
|
+
if (prevSig !== sig || !hasValidBuild) {
|
|
736
|
+
scheduleRebuildSelf(nodeId);
|
|
737
|
+
} else {
|
|
738
|
+
node.log(`[${nodeId}] unchanged — skipping rebuild`);
|
|
739
|
+
updateStatus();
|
|
740
|
+
}
|
|
287
741
|
setImmediate(() => {
|
|
288
|
-
rebuild();
|
|
289
|
-
|
|
290
742
|
// Register route only once per endpoint (persists across deploys)
|
|
291
743
|
if (!registeredRoutes[endpoint]) {
|
|
292
744
|
RED.httpNode.get(endpoint, async function (_req, res) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
745
|
+
try {
|
|
746
|
+
const state = pageState[endpoint];
|
|
747
|
+
if (!state || state.building) {
|
|
748
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
749
|
+
res
|
|
750
|
+
.set("Cache-Control", "no-store")
|
|
751
|
+
.type("text/html")
|
|
752
|
+
.send(
|
|
753
|
+
`<!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(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
|
|
754
|
+
);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
res.set("Cache-Control", "no-store");
|
|
758
|
+
if (state.compiled.error) {
|
|
759
|
+
if (state.lastGood) {
|
|
760
|
+
// Degraded: serve previous good build, banner-only error UI.
|
|
761
|
+
const user = state.portalAuth
|
|
762
|
+
? extractPortalUser(_req.headers)
|
|
763
|
+
: null;
|
|
764
|
+
res
|
|
765
|
+
.type("text/html")
|
|
766
|
+
.send(
|
|
767
|
+
buildPage(
|
|
768
|
+
state.lastGood.pageTitle,
|
|
769
|
+
state.lastGood.compiledJs,
|
|
770
|
+
state.wsPath,
|
|
771
|
+
state.lastGood.customHead,
|
|
772
|
+
state.lastGood.cssHash,
|
|
773
|
+
user,
|
|
774
|
+
state.showWsStatus,
|
|
775
|
+
adminRoot,
|
|
776
|
+
),
|
|
777
|
+
);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
res
|
|
781
|
+
.status(500)
|
|
782
|
+
.type("text/html")
|
|
783
|
+
.send(
|
|
784
|
+
buildErrorPage(
|
|
785
|
+
state.pageTitle,
|
|
786
|
+
state.compiled.error,
|
|
787
|
+
state.wsPath,
|
|
788
|
+
),
|
|
789
|
+
);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const { cssHash } = await Promise.race([
|
|
793
|
+
state.cssReady,
|
|
794
|
+
new Promise((_, reject) =>
|
|
795
|
+
setTimeout(
|
|
796
|
+
() => reject(new Error("CSS generation timeout")),
|
|
797
|
+
15000,
|
|
798
|
+
),
|
|
799
|
+
),
|
|
800
|
+
]);
|
|
801
|
+
const user = state.portalAuth
|
|
802
|
+
? extractPortalUser(_req.headers)
|
|
803
|
+
: null;
|
|
804
|
+
res
|
|
805
|
+
.type("text/html")
|
|
806
|
+
.send(
|
|
807
|
+
buildPage(
|
|
808
|
+
state.pageTitle,
|
|
809
|
+
state.compiled.js,
|
|
810
|
+
state.wsPath,
|
|
811
|
+
state.customHead,
|
|
812
|
+
cssHash,
|
|
813
|
+
user,
|
|
814
|
+
state.showWsStatus,
|
|
815
|
+
adminRoot,
|
|
816
|
+
),
|
|
817
|
+
);
|
|
818
|
+
} catch (e) {
|
|
300
819
|
res
|
|
301
820
|
.status(500)
|
|
302
821
|
.type("text/html")
|
|
303
|
-
.send(
|
|
304
|
-
|
|
822
|
+
.send(
|
|
823
|
+
buildErrorPage(
|
|
824
|
+
pageTitle,
|
|
825
|
+
"Page build failed: " + e.message,
|
|
826
|
+
wsPath,
|
|
827
|
+
),
|
|
828
|
+
);
|
|
305
829
|
}
|
|
306
|
-
const cssHash = await state.cssHashReady;
|
|
307
|
-
res
|
|
308
|
-
.type("text/html")
|
|
309
|
-
.send(
|
|
310
|
-
buildPage(
|
|
311
|
-
state.pageTitle,
|
|
312
|
-
state.compiled.js,
|
|
313
|
-
state.wsPath,
|
|
314
|
-
state.customHead,
|
|
315
|
-
cssHash,
|
|
316
|
-
),
|
|
317
|
-
);
|
|
318
830
|
});
|
|
319
831
|
registeredRoutes[endpoint] = true;
|
|
320
832
|
}
|
|
@@ -341,6 +853,12 @@ module.exports = function (RED) {
|
|
|
341
853
|
pathname = request.url;
|
|
342
854
|
}
|
|
343
855
|
if (pathname === wsPath) {
|
|
856
|
+
// Plugin hook: plugins may reject the connection before upgrade.
|
|
857
|
+
// Default (no plugins) = allowed, matches dashboard behavior.
|
|
858
|
+
if (!hooks.allow("onIsValidConnection", request)) {
|
|
859
|
+
try { socket.destroy(); } catch (e) { RED.log.trace("[portal-react] socket destroy: " + e.message); }
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
344
862
|
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
345
863
|
wsServer.emit("connection", ws, request);
|
|
346
864
|
});
|
|
@@ -350,42 +868,131 @@ module.exports = function (RED) {
|
|
|
350
868
|
RED.server.on("upgrade", onUpgrade);
|
|
351
869
|
upgradeHandlers[nodeId] = onUpgrade;
|
|
352
870
|
|
|
353
|
-
wsServer.on("connection", (ws) => {
|
|
871
|
+
wsServer.on("connection", (ws, request) => {
|
|
354
872
|
if (isClosing) {
|
|
355
873
|
ws.close();
|
|
356
874
|
return;
|
|
357
875
|
}
|
|
358
|
-
|
|
876
|
+
const portalClient = crypto.randomUUID();
|
|
877
|
+
ws._portalClient = portalClient;
|
|
878
|
+
if (portalAuth) {
|
|
879
|
+
ws._portalUser = extractPortalUser(request.headers);
|
|
880
|
+
}
|
|
881
|
+
clients.set(portalClient, ws);
|
|
882
|
+
|
|
883
|
+
// Index by userId for O(1) user-cast routing
|
|
884
|
+
const userId = ws._portalUser && ws._portalUser.userId;
|
|
885
|
+
if (userId) {
|
|
886
|
+
let set = userIndex.get(userId);
|
|
887
|
+
if (!set) {
|
|
888
|
+
set = new Set();
|
|
889
|
+
userIndex.set(userId, set);
|
|
890
|
+
}
|
|
891
|
+
set.add(ws);
|
|
892
|
+
}
|
|
893
|
+
|
|
359
894
|
updateStatus();
|
|
360
895
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
896
|
+
// Send content version for deploy-reload detection.
|
|
897
|
+
// In degraded mode (current build failed but lastGood served), advertise
|
|
898
|
+
// the lastGood hash so the freshly reloaded client matches the JS we sent.
|
|
899
|
+
const cs = pageState[endpoint];
|
|
900
|
+
const contentHash =
|
|
901
|
+
cs?.compiled?.error && cs?.lastGood
|
|
902
|
+
? cs.lastGood.contentHash
|
|
903
|
+
: cs?.contentHash || "";
|
|
904
|
+
wsSend(ws, { type: "version", hash: contentHash });
|
|
905
|
+
|
|
906
|
+
// Send assigned portalClient to browser
|
|
907
|
+
wsSend(ws, { type: "hello", portalClient });
|
|
908
|
+
|
|
909
|
+
// Degraded warning — show banner, not full overlay.
|
|
910
|
+
if (cs?.compiled?.error && cs?.lastGood) {
|
|
911
|
+
wsSend(ws, {
|
|
912
|
+
type: "error",
|
|
913
|
+
message: cs.compiled.error,
|
|
914
|
+
degraded: true,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Send the cached last broadcast (if any) as a distinct
|
|
919
|
+
// `recovery` frame. The browser uses this to seed `data` on a
|
|
920
|
+
// fresh connection. React components can opt out via
|
|
921
|
+
// useNodeRed({ ignoreRecovery: true }).
|
|
922
|
+
if (lastBroadcastCache.has(endpoint)) {
|
|
923
|
+
wsSend(ws, { type: "recovery", payload: lastBroadcastCache.get(endpoint) });
|
|
364
924
|
}
|
|
365
925
|
|
|
926
|
+
// Heartbeat — detect dead sockets via WS ping/pong. Browser
|
|
927
|
+
// auto-replies to ping frames, no client JS needed.
|
|
928
|
+
ws._isAlive = true;
|
|
929
|
+
ws.on("pong", () => { ws._isAlive = true; });
|
|
930
|
+
ws._pingIv = setInterval(() => {
|
|
931
|
+
if (ws._isAlive === false) {
|
|
932
|
+
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
ws._isAlive = false;
|
|
936
|
+
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
937
|
+
}, 30000);
|
|
938
|
+
|
|
366
939
|
ws.on("message", (raw) => {
|
|
367
940
|
try {
|
|
368
941
|
const msg = JSON.parse(raw.toString());
|
|
942
|
+
if (msg.type === "runtime_error") {
|
|
943
|
+
// Browser caught an exception while running the bundle —
|
|
944
|
+
// surface it on node status so the editor shows red even
|
|
945
|
+
// when the build itself succeeded (e.g. ReferenceError to
|
|
946
|
+
// an undefined identifier or missing component).
|
|
947
|
+
const st = pageState[endpoint];
|
|
948
|
+
if (st && !(st.compiled && st.compiled.error)) {
|
|
949
|
+
st.runtimeError = String(msg.message || "")
|
|
950
|
+
.split("\n")[0]
|
|
951
|
+
.slice(0, 200);
|
|
952
|
+
node.error("Runtime error in browser: " + st.runtimeError);
|
|
953
|
+
updateStatus();
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
369
957
|
if (msg.type === "output") {
|
|
370
|
-
|
|
958
|
+
let out = {
|
|
371
959
|
payload: msg.payload,
|
|
372
960
|
topic: msg.topic || "",
|
|
373
|
-
}
|
|
961
|
+
};
|
|
962
|
+
// Server-side identity injection — the client cannot forge
|
|
963
|
+
// _client because we build it from ws state, not from the
|
|
964
|
+
// inbound frame.
|
|
965
|
+
const client = { portalClient: ws._portalClient };
|
|
966
|
+
if (portalAuth && ws._portalUser) {
|
|
967
|
+
Object.assign(client, ws._portalUser);
|
|
968
|
+
}
|
|
969
|
+
out._client = client;
|
|
970
|
+
// Transform hook — plugins may mutate / drop the msg.
|
|
971
|
+
// A hook returning null signals "drop this message".
|
|
972
|
+
out = hooks.transform("onInbound", out, ws);
|
|
973
|
+
if (out) node.send(out);
|
|
974
|
+
return;
|
|
374
975
|
}
|
|
375
976
|
} catch (e) {
|
|
376
977
|
node.warn("Bad WS message: " + e.message);
|
|
377
978
|
}
|
|
378
979
|
});
|
|
379
980
|
|
|
380
|
-
|
|
381
|
-
|
|
981
|
+
const detach = () => {
|
|
982
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
983
|
+
clients.delete(portalClient);
|
|
984
|
+
if (userId) {
|
|
985
|
+
const set = userIndex.get(userId);
|
|
986
|
+
if (set) {
|
|
987
|
+
set.delete(ws);
|
|
988
|
+
if (set.size === 0) userIndex.delete(userId);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
382
991
|
updateStatus();
|
|
383
|
-
}
|
|
992
|
+
};
|
|
384
993
|
|
|
385
|
-
ws.on("
|
|
386
|
-
|
|
387
|
-
updateStatus();
|
|
388
|
-
});
|
|
994
|
+
ws.on("close", detach);
|
|
995
|
+
ws.on("error", detach);
|
|
389
996
|
});
|
|
390
997
|
} catch (e) {
|
|
391
998
|
node.error("WebSocket setup failed: " + e.message);
|
|
@@ -393,12 +1000,28 @@ module.exports = function (RED) {
|
|
|
393
1000
|
|
|
394
1001
|
// ── Input handler ─────────────────────────────────────────
|
|
395
1002
|
|
|
1003
|
+
// sendTo: single point where every outbound frame passes through
|
|
1004
|
+
// the onCanSendTo hook. Strict-by-default — no opt-in per widget
|
|
1005
|
+
// type like dashboard's acceptsClientConfig.
|
|
1006
|
+
function sendTo(ws, frame, msg) {
|
|
1007
|
+
if (!ws || ws.readyState !== 1) return false;
|
|
1008
|
+
if (!hooks.allow("onCanSendTo", ws, msg)) return false;
|
|
1009
|
+
try {
|
|
1010
|
+
ws.send(frame);
|
|
1011
|
+
return true;
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
RED.log.trace("[portal-react] ws send frame: " + e.message);
|
|
1014
|
+
return false;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
396
1018
|
node.on("input", (msg, send, done) => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
1019
|
+
const result = router.route(msg, { clients, userIndex, sendTo });
|
|
1020
|
+
// Cache the latest broadcast payload so freshly-connected clients
|
|
1021
|
+
// can recover it via the `recovery` frame on connect.
|
|
1022
|
+
if (result.mode === "broadcast") {
|
|
1023
|
+
lastBroadcastCache.set(endpoint, msg.payload);
|
|
1024
|
+
}
|
|
402
1025
|
updateStatus();
|
|
403
1026
|
if (done) done();
|
|
404
1027
|
});
|
|
@@ -414,11 +1037,13 @@ module.exports = function (RED) {
|
|
|
414
1037
|
delete upgradeHandlers[nodeId];
|
|
415
1038
|
}
|
|
416
1039
|
|
|
417
|
-
// Close all WS clients
|
|
1040
|
+
// Close all WS clients — clear heartbeat interval BEFORE ws.close()
|
|
1041
|
+
// so pending pings do not leak if the 'close' event is delayed.
|
|
418
1042
|
clients.forEach((ws) => {
|
|
1043
|
+
if (ws._pingIv) { clearInterval(ws._pingIv); ws._pingIv = null; }
|
|
419
1044
|
try {
|
|
420
1045
|
ws.close(1001, "node redeployed");
|
|
421
|
-
} catch (
|
|
1046
|
+
} catch (e) { RED.log.trace("[portal-react] ws close client: " + e.message); }
|
|
422
1047
|
});
|
|
423
1048
|
clients.clear();
|
|
424
1049
|
|
|
@@ -426,15 +1051,48 @@ module.exports = function (RED) {
|
|
|
426
1051
|
if (wsServer) {
|
|
427
1052
|
try {
|
|
428
1053
|
wsServer.close();
|
|
429
|
-
} catch (
|
|
1054
|
+
} catch (e) { RED.log.trace("[portal-react] wsServer close: " + e.message); }
|
|
430
1055
|
wsServer = null;
|
|
431
1056
|
}
|
|
432
1057
|
|
|
433
|
-
// Unregister rebuild callback
|
|
1058
|
+
// Unregister rebuild callback + selective-rebuild metadata
|
|
434
1059
|
delete rebuildCallbacks[nodeId];
|
|
1060
|
+
delete portalNeeded[nodeId];
|
|
1061
|
+
delete portalCode[nodeId];
|
|
1062
|
+
|
|
1063
|
+
// Release endpoint ownership
|
|
1064
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
1065
|
+
delete endpointOwners[endpoint];
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Drop the recovery cache only on full node removal — on a
|
|
1069
|
+
// redeploy we keep it so reconnecting clients still recover.
|
|
1070
|
+
if (removed) {
|
|
1071
|
+
lastBroadcastCache.delete(endpoint);
|
|
1072
|
+
delete portalSig[nodeId];
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Clear the userIndex — WS clients are already closed above, but
|
|
1076
|
+
// the Map itself should not outlive the node instance.
|
|
1077
|
+
userIndex.clear();
|
|
1078
|
+
|
|
1079
|
+
// Break references to large objects / Promises in pageState even on
|
|
1080
|
+
// redeploy. Next rebuild overwrites pageState[endpoint] anyway, but
|
|
1081
|
+
// between close and the new build these would retain closures over
|
|
1082
|
+
// the old clients/userIndex/rebuild scope.
|
|
1083
|
+
const st = pageState[endpoint];
|
|
1084
|
+
if (st) {
|
|
1085
|
+
st.cssReady = null;
|
|
1086
|
+
st.compiled = null;
|
|
1087
|
+
st.css = null;
|
|
1088
|
+
}
|
|
435
1089
|
|
|
436
1090
|
// Clean up route only when node is fully removed (not redeployed)
|
|
437
1091
|
if (removed) {
|
|
1092
|
+
// Delete disk cache if no other endpoint uses this hash
|
|
1093
|
+
if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
|
|
1094
|
+
deleteCacheFiles(lastJsxHash);
|
|
1095
|
+
}
|
|
438
1096
|
delete pageState[endpoint];
|
|
439
1097
|
removeRoute(RED.httpNode._router, endpoint);
|
|
440
1098
|
delete registeredRoutes[endpoint];
|
|
@@ -448,22 +1106,15 @@ module.exports = function (RED) {
|
|
|
448
1106
|
function wsSend(ws, obj) {
|
|
449
1107
|
try {
|
|
450
1108
|
if (ws.readyState === 1) ws.send(JSON.stringify(obj));
|
|
451
|
-
} catch (
|
|
1109
|
+
} catch (e) { RED.log.trace("[portal-react] wsSend: " + e.message); }
|
|
452
1110
|
}
|
|
453
1111
|
|
|
454
|
-
function updateStatus() {
|
|
455
|
-
if (isClosing) return;
|
|
456
|
-
const n = clients.size;
|
|
457
|
-
node.status({
|
|
458
|
-
fill: n > 0 ? "green" : "grey",
|
|
459
|
-
shape: n > 0 ? "dot" : "ring",
|
|
460
|
-
text: `${endpoint} [${n} client${n !== 1 ? "s" : ""}]`,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
1112
|
}); // end setImmediate
|
|
464
1113
|
}
|
|
465
1114
|
|
|
466
|
-
RED.nodes.registerType("portal-react", PortalReactNode
|
|
1115
|
+
RED.nodes.registerType("portal-react", PortalReactNode, {
|
|
1116
|
+
dynamicModuleList: "libs",
|
|
1117
|
+
});
|
|
467
1118
|
|
|
468
1119
|
// ── Serve Monaco editor files locally ────────────────────────
|
|
469
1120
|
const express = require("express");
|
|
@@ -485,9 +1136,16 @@ module.exports = function (RED) {
|
|
|
485
1136
|
res.json(twClassesCache);
|
|
486
1137
|
});
|
|
487
1138
|
|
|
488
|
-
// ── Vendor CSS endpoint (per
|
|
1139
|
+
// ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
|
|
489
1140
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
490
|
-
const
|
|
1141
|
+
const reqHash = req.params.hash;
|
|
1142
|
+
let css = null;
|
|
1143
|
+
for (const ep in pageState) {
|
|
1144
|
+
if (pageState[ep]?.cssHash === reqHash) {
|
|
1145
|
+
css = pageState[ep].css;
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
491
1149
|
if (!css) {
|
|
492
1150
|
res.status(404).send("Not found");
|
|
493
1151
|
return;
|
|
@@ -499,15 +1157,9 @@ module.exports = function (RED) {
|
|
|
499
1157
|
res.send(css);
|
|
500
1158
|
});
|
|
501
1159
|
|
|
502
|
-
// ──
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
"Content-Type": "application/javascript",
|
|
506
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
507
|
-
ETag: `"${reactHash}"`,
|
|
508
|
-
});
|
|
509
|
-
res.send(reactBundle);
|
|
510
|
-
});
|
|
1160
|
+
// ── Public assets folder ─────────────────────────────────────
|
|
1161
|
+
const { registerAssets } = require("./lib/assets");
|
|
1162
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
|
|
511
1163
|
|
|
512
1164
|
// ── Admin API for component registry ──────────────────────────
|
|
513
1165
|
|
|
@@ -516,10 +1168,15 @@ module.exports = function (RED) {
|
|
|
516
1168
|
});
|
|
517
1169
|
|
|
518
1170
|
RED.httpAdmin.post("/portal-react/registry", (req, res) => {
|
|
519
|
-
const { name, code
|
|
1171
|
+
const { name, code } = req.body || {};
|
|
520
1172
|
if (!isSafeName(name))
|
|
521
1173
|
return res.status(400).json({ error: "invalid name" });
|
|
522
|
-
|
|
1174
|
+
const newCode = code || "";
|
|
1175
|
+
const prevCode = registry[name]?.code;
|
|
1176
|
+
registry[name] = { code: newCode };
|
|
1177
|
+
if (prevCode !== newCode) {
|
|
1178
|
+
scheduleRebuildUsing(name);
|
|
1179
|
+
}
|
|
523
1180
|
res.json({ ok: true });
|
|
524
1181
|
});
|
|
525
1182
|
|
|
@@ -527,110 +1184,11 @@ module.exports = function (RED) {
|
|
|
527
1184
|
const name = req.params.name;
|
|
528
1185
|
if (!isSafeName(name))
|
|
529
1186
|
return res.status(400).json({ error: "invalid name" });
|
|
1187
|
+
const existed = Object.prototype.hasOwnProperty.call(registry, name);
|
|
530
1188
|
delete registry[name];
|
|
1189
|
+
if (existed) {
|
|
1190
|
+
scheduleRebuildUsing(name);
|
|
1191
|
+
}
|
|
531
1192
|
res.json({ ok: true });
|
|
532
1193
|
});
|
|
533
|
-
|
|
534
|
-
// ── Page builders ─────────────────────────────────────────────
|
|
535
|
-
|
|
536
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash) {
|
|
537
|
-
return `<!DOCTYPE html>
|
|
538
|
-
<html lang="en">
|
|
539
|
-
<head>
|
|
540
|
-
<meta charset="UTF-8">
|
|
541
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
542
|
-
<title>${esc(title)}</title>
|
|
543
|
-
<script src="${adminRoot}/portal-react/vendor/react.min.js?v=${reactHash}"><\/script>
|
|
544
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
545
|
-
${escScript(customHead)}
|
|
546
|
-
<style>
|
|
547
|
-
@layer base{
|
|
548
|
-
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
549
|
-
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0}
|
|
550
|
-
#root{min-height:100vh}
|
|
551
|
-
}
|
|
552
|
-
#__cs{position:fixed;bottom:6px;right:6px;padding:3px 8px;font-size:10px;border-radius:3px;z-index:99999;background:#111;border:1px solid #333;opacity:.7;transition:opacity .2s}
|
|
553
|
-
#__cs:hover{opacity:1}
|
|
554
|
-
#__cs.ok{color:#4ade80}
|
|
555
|
-
#__cs.err{color:#f87171}
|
|
556
|
-
</style>
|
|
557
|
-
</head>
|
|
558
|
-
<body>
|
|
559
|
-
<div id="root"></div>
|
|
560
|
-
<div id="__cs" class="err">disconnected</div>
|
|
561
|
-
<script>
|
|
562
|
-
window.__NR={
|
|
563
|
-
_ws:null,_listeners:new Set(),_lastData:null,_retries:0,_wasConnected:false,
|
|
564
|
-
connect(){
|
|
565
|
-
const p=location.protocol==='https:'?'wss:':'ws:';
|
|
566
|
-
const ws=new WebSocket(p+'//'+location.host+'${wsPath}');
|
|
567
|
-
this._ws=ws;
|
|
568
|
-
const s=document.getElementById('__cs');
|
|
569
|
-
ws.onopen=()=>{
|
|
570
|
-
if(this._wasConnected){location.reload();return;}
|
|
571
|
-
s.textContent='connected';s.className='ok';this._retries=0;this._wasConnected=true;
|
|
572
|
-
};
|
|
573
|
-
ws.onmessage=(e)=>{
|
|
574
|
-
try{const m=JSON.parse(e.data);if(m.type==='data'){this._lastData=m.payload;this._listeners.forEach(fn=>fn(m.payload));}}
|
|
575
|
-
catch(err){console.error('WS parse',err);}
|
|
576
|
-
};
|
|
577
|
-
ws.onclose=(e)=>{
|
|
578
|
-
s.textContent='disconnected';s.className='err';
|
|
579
|
-
this._ws=null;
|
|
580
|
-
const delay=Math.min(500*Math.pow(2,this._retries),8000);
|
|
581
|
-
this._retries++;
|
|
582
|
-
setTimeout(()=>this.connect(),delay);
|
|
583
|
-
};
|
|
584
|
-
ws.onerror=()=>ws.close();
|
|
585
|
-
},
|
|
586
|
-
subscribe(fn){
|
|
587
|
-
this._listeners.add(fn);
|
|
588
|
-
if(this._lastData!==null)fn(this._lastData);
|
|
589
|
-
return()=>this._listeners.delete(fn);
|
|
590
|
-
},
|
|
591
|
-
send(payload,topic){
|
|
592
|
-
if(this._ws&&this._ws.readyState===1)
|
|
593
|
-
this._ws.send(JSON.stringify({type:'output',payload,topic:topic||''}));
|
|
594
|
-
}
|
|
595
|
-
};
|
|
596
|
-
window.__NR.connect();
|
|
597
|
-
<\/script>
|
|
598
|
-
<script>
|
|
599
|
-
${escScript(transpiledJs)}
|
|
600
|
-
<\/script>
|
|
601
|
-
</body>
|
|
602
|
-
</html>`;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function buildErrorPage(title, error) {
|
|
606
|
-
return `<!DOCTYPE html>
|
|
607
|
-
<html lang="en">
|
|
608
|
-
<head>
|
|
609
|
-
<meta charset="UTF-8">
|
|
610
|
-
<title>${esc(title)} — Error</title>
|
|
611
|
-
<style>
|
|
612
|
-
body{font-family:monospace;background:#1a0000;color:#f87171;padding:40px;line-height:1.6}
|
|
613
|
-
h1{color:#ff4444;margin-bottom:16px}
|
|
614
|
-
pre{background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5}
|
|
615
|
-
</style>
|
|
616
|
-
</head>
|
|
617
|
-
<body>
|
|
618
|
-
<h1>JSX Transpile Error</h1>
|
|
619
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
620
|
-
<pre>${esc(error)}</pre>
|
|
621
|
-
</body>
|
|
622
|
-
</html>`;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function esc(s) {
|
|
626
|
-
return String(s)
|
|
627
|
-
.replace(/&/g, "&")
|
|
628
|
-
.replace(/</g, "<")
|
|
629
|
-
.replace(/>/g, ">")
|
|
630
|
-
.replace(/"/g, """);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function escScript(s) {
|
|
634
|
-
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
635
|
-
}
|
|
636
1194
|
};
|