@aaqu/fromcubes-portal-react 0.1.0-alpha.6 → 0.1.0-alpha.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -30
- package/examples/chart-portal-flow.json +74 -0
- package/examples/d3-poland-flow.json +80 -0
- package/examples/sensor-portal-flow.json +2 -2
- package/examples/threejs-portal-flow.json +61 -0
- package/nodes/portal-react.html +356 -87
- package/nodes/portal-react.js +309 -127
- package/package.json +7 -8
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
package/nodes/portal-react.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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");
|
|
@@ -11,16 +11,6 @@ const fs = require("fs");
|
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const esbuild = require("esbuild");
|
|
13
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
|
-
|
|
24
14
|
module.exports = function (RED) {
|
|
25
15
|
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
26
16
|
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
@@ -63,6 +53,12 @@ module.exports = function (RED) {
|
|
|
63
53
|
}
|
|
64
54
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
65
55
|
|
|
56
|
+
// Vendor bundle cache: cacheKey → { js, hash }
|
|
57
|
+
if (!RED.settings.fcVendorCache) {
|
|
58
|
+
RED.settings.fcVendorCache = {};
|
|
59
|
+
}
|
|
60
|
+
const vendorCache = RED.settings.fcVendorCache;
|
|
61
|
+
|
|
66
62
|
// ── Helpers ───────────────────────────────────────────────────
|
|
67
63
|
|
|
68
64
|
function hash(str) {
|
|
@@ -92,12 +88,112 @@ module.exports = function (RED) {
|
|
|
92
88
|
return twCompiled;
|
|
93
89
|
}
|
|
94
90
|
|
|
95
|
-
|
|
91
|
+
// Package root — where react/react-dom live (this package's own node_modules)
|
|
92
|
+
const pkgRoot = path.join(__dirname, "..");
|
|
93
|
+
// userDir — where dynamicModuleList installs user packages
|
|
94
|
+
const userDir = RED.settings.userDir || path.join(__dirname, "../../..");
|
|
95
|
+
// esbuild resolveDir: package root (react is here); nodePaths adds userDir for user libs
|
|
96
|
+
const resolveDir = pkgRoot;
|
|
97
|
+
|
|
98
|
+
function getPackageName(moduleSpec) {
|
|
99
|
+
const m = moduleSpec.match(/^((?:@[^/]+\/)?[^/]+)/);
|
|
100
|
+
return m ? m[1] : moduleSpec;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getInstalledVersion(pkgName) {
|
|
104
|
+
try {
|
|
105
|
+
const pkgJson = require(
|
|
106
|
+
require.resolve(pkgName + "/package.json", { paths: [pkgRoot, userDir] }),
|
|
107
|
+
);
|
|
108
|
+
return pkgJson.version;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildVendorBundle(libs) {
|
|
115
|
+
const lines = [
|
|
116
|
+
'import React from "react";',
|
|
117
|
+
'import ReactDOM from "react-dom";',
|
|
118
|
+
'import { createRoot } from "react-dom/client";',
|
|
119
|
+
"window.React = React;",
|
|
120
|
+
"window.ReactDOM = ReactDOM;",
|
|
121
|
+
"window.ReactDOM.createRoot = createRoot;",
|
|
122
|
+
];
|
|
123
|
+
if (libs.length > 0) {
|
|
124
|
+
lines.push("if(!window.__pkg) window.__pkg = {};");
|
|
125
|
+
libs.forEach((lib, i) => {
|
|
126
|
+
lines.push(`import * as __p${i} from ${JSON.stringify(lib.module)};`);
|
|
127
|
+
lines.push(`window.__pkg[${JSON.stringify(lib.module)}] = __p${i};`);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const contents = lines.join("\n");
|
|
131
|
+
const buildResult = esbuild.buildSync({
|
|
132
|
+
stdin: { contents, resolveDir, loader: "js" },
|
|
133
|
+
bundle: true,
|
|
134
|
+
format: "iife",
|
|
135
|
+
minify: true,
|
|
136
|
+
write: false,
|
|
137
|
+
target: ["es2020"],
|
|
138
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
139
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
140
|
+
});
|
|
141
|
+
const js = buildResult.outputFiles[0].text;
|
|
142
|
+
const h = hash(js);
|
|
143
|
+
return { js, hash: h };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getVendorBundle(libs) {
|
|
147
|
+
// Build cache key from actual installed versions
|
|
148
|
+
const keyParts = ["react@" + getInstalledVersion("react")];
|
|
149
|
+
for (const lib of libs) {
|
|
150
|
+
keyParts.push(lib.module + "@" + getInstalledVersion(getPackageName(lib.module)));
|
|
151
|
+
}
|
|
152
|
+
keyParts.sort();
|
|
153
|
+
const cacheKey = hash(JSON.stringify(keyParts));
|
|
154
|
+
|
|
155
|
+
if (vendorCache[cacheKey]) return vendorCache[cacheKey];
|
|
156
|
+
|
|
157
|
+
const bundle = buildVendorBundle(libs);
|
|
158
|
+
vendorCache[cacheKey] = bundle;
|
|
159
|
+
return bundle;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function transpile(jsx, libs) {
|
|
163
|
+
const externalList = ["react", "react-dom", "react-dom/client"];
|
|
164
|
+
if (libs) {
|
|
165
|
+
libs.forEach((lib) => {
|
|
166
|
+
if (!externalList.includes(lib.module)) {
|
|
167
|
+
externalList.push(lib.module);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Auto-detect require() calls in JSX and add them as externals
|
|
173
|
+
const requireRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
174
|
+
let m;
|
|
175
|
+
while ((m = requireRe.exec(jsx)) !== null) {
|
|
176
|
+
if (!externalList.includes(m[1])) {
|
|
177
|
+
externalList.push(m[1]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const requireShim = [
|
|
182
|
+
"var require = (function() {",
|
|
183
|
+
' var _r = {"react":window.React, "react-dom":window.ReactDOM, "react-dom/client":window.ReactDOM};',
|
|
184
|
+
" return function(m) {",
|
|
185
|
+
" if (_r[m]) return _r[m];",
|
|
186
|
+
" if (window.__pkg && window.__pkg[m]) return window.__pkg[m];",
|
|
187
|
+
' throw new Error("Module not found: " + m);',
|
|
188
|
+
" };",
|
|
189
|
+
"})();",
|
|
190
|
+
].join("\n");
|
|
191
|
+
|
|
96
192
|
try {
|
|
97
193
|
const buildResult = esbuild.buildSync({
|
|
98
194
|
stdin: {
|
|
99
195
|
contents: jsx,
|
|
100
|
-
resolveDir
|
|
196
|
+
resolveDir,
|
|
101
197
|
loader: "jsx",
|
|
102
198
|
},
|
|
103
199
|
bundle: true,
|
|
@@ -107,8 +203,9 @@ module.exports = function (RED) {
|
|
|
107
203
|
jsx: "transform",
|
|
108
204
|
jsxFactory: "React.createElement",
|
|
109
205
|
jsxFragment: "React.Fragment",
|
|
110
|
-
external:
|
|
206
|
+
external: externalList,
|
|
111
207
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
208
|
+
banner: { js: requireShim },
|
|
112
209
|
});
|
|
113
210
|
return { js: buildResult.outputFiles[0].text, error: null };
|
|
114
211
|
} catch (e) {
|
|
@@ -137,10 +234,14 @@ module.exports = function (RED) {
|
|
|
137
234
|
function extractPortalUser(headers) {
|
|
138
235
|
const user = {};
|
|
139
236
|
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
140
|
-
if (headers["x-portal-user-name"])
|
|
141
|
-
|
|
142
|
-
if (headers["x-portal-user-
|
|
143
|
-
|
|
237
|
+
if (headers["x-portal-user-name"])
|
|
238
|
+
user.userName = headers["x-portal-user-name"];
|
|
239
|
+
if (headers["x-portal-user-username"])
|
|
240
|
+
user.username = headers["x-portal-user-username"];
|
|
241
|
+
if (headers["x-portal-user-email"])
|
|
242
|
+
user.email = headers["x-portal-user-email"];
|
|
243
|
+
if (headers["x-portal-user-role"])
|
|
244
|
+
user.role = headers["x-portal-user-role"];
|
|
144
245
|
if (headers["x-portal-user-groups"]) {
|
|
145
246
|
try {
|
|
146
247
|
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
@@ -209,11 +310,13 @@ module.exports = function (RED) {
|
|
|
209
310
|
const nodeId = node.id;
|
|
210
311
|
|
|
211
312
|
// Config
|
|
212
|
-
const endpoint = (config.endpoint || "/
|
|
313
|
+
const endpoint = (config.endpoint || "/fromcubes").replace(/\/+$/, "");
|
|
213
314
|
const componentCode = config.componentCode || "";
|
|
214
315
|
const pageTitle = config.pageTitle || "Portal";
|
|
215
316
|
const customHead = config.customHead || "";
|
|
216
317
|
const portalAuth = config.portalAuth === true;
|
|
318
|
+
const showWsStatus = config.showWsStatus === true;
|
|
319
|
+
const libs = config.libs || [];
|
|
217
320
|
|
|
218
321
|
// State
|
|
219
322
|
const clients = new Set();
|
|
@@ -226,19 +329,51 @@ module.exports = function (RED) {
|
|
|
226
329
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
227
330
|
|
|
228
331
|
function rebuild() {
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
332
|
+
// Build or get cached vendor bundle
|
|
333
|
+
let vendorBundle;
|
|
334
|
+
try {
|
|
335
|
+
vendorBundle = getVendorBundle(libs);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
node.error("Vendor bundle failed: " + e.message);
|
|
338
|
+
node.status({ fill: "red", shape: "dot", text: "vendor build error" });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
343
|
+
const allEntries = Object.entries(registry);
|
|
344
|
+
const needed = new Set();
|
|
345
|
+
|
|
346
|
+
function addWithDeps(name) {
|
|
347
|
+
if (needed.has(name)) return;
|
|
348
|
+
const entry = registry[name];
|
|
349
|
+
if (!entry) return;
|
|
350
|
+
needed.add(name);
|
|
351
|
+
for (const [other] of allEntries) {
|
|
352
|
+
if (other !== name && entry.code.includes(other)) {
|
|
353
|
+
addWithDeps(other);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const [name] of allEntries) {
|
|
359
|
+
if (componentCode.includes(name)) {
|
|
360
|
+
addWithDeps(name);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Topological sort only needed components
|
|
365
|
+
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
232
366
|
entries.sort((a, b) => {
|
|
233
367
|
const aUsesB = a[1].code.includes(b[0]);
|
|
234
368
|
const bUsesA = b[1].code.includes(a[0]);
|
|
235
|
-
if (aUsesB && !bUsesA) return 1;
|
|
369
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
236
370
|
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
237
371
|
return 0;
|
|
238
372
|
});
|
|
239
373
|
const libraryJsx = entries
|
|
240
|
-
.map(
|
|
241
|
-
|
|
374
|
+
.map(
|
|
375
|
+
([name, c]) =>
|
|
376
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
242
377
|
)
|
|
243
378
|
.join("\n\n");
|
|
244
379
|
|
|
@@ -248,17 +383,19 @@ module.exports = function (RED) {
|
|
|
248
383
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
249
384
|
"",
|
|
250
385
|
"// ── useNodeRed hook ──",
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
React.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
386
|
+
[
|
|
387
|
+
"function useNodeRed() {",
|
|
388
|
+
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
389
|
+
" React.useEffect(() => {",
|
|
390
|
+
" return window.__NR.subscribe(setData);",
|
|
391
|
+
" }, []);",
|
|
392
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
393
|
+
" window.__NR.send(payload, topic);",
|
|
394
|
+
" }, []);",
|
|
395
|
+
" const user = window.__NR._user || null;",
|
|
396
|
+
" return { data, send, user };",
|
|
397
|
+
"}",
|
|
398
|
+
].join("\n"),
|
|
262
399
|
"",
|
|
263
400
|
"// ── Library components ──",
|
|
264
401
|
libraryJsx,
|
|
@@ -270,7 +407,7 @@ module.exports = function (RED) {
|
|
|
270
407
|
"ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
271
408
|
].join("\n");
|
|
272
409
|
|
|
273
|
-
const compiled = transpile(fullJsx);
|
|
410
|
+
const compiled = transpile(fullJsx, libs);
|
|
274
411
|
|
|
275
412
|
if (compiled.error) {
|
|
276
413
|
node.error("JSX transpile error: " + compiled.error);
|
|
@@ -291,13 +428,18 @@ module.exports = function (RED) {
|
|
|
291
428
|
})
|
|
292
429
|
: Promise.resolve("");
|
|
293
430
|
|
|
431
|
+
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
432
|
+
|
|
294
433
|
pageState[endpoint] = {
|
|
295
434
|
compiled,
|
|
435
|
+
contentHash,
|
|
296
436
|
cssHashReady,
|
|
297
437
|
pageTitle,
|
|
298
438
|
wsPath,
|
|
299
439
|
customHead,
|
|
300
440
|
portalAuth,
|
|
441
|
+
showWsStatus,
|
|
442
|
+
vendorHash: vendorBundle.hash,
|
|
301
443
|
};
|
|
302
444
|
}
|
|
303
445
|
|
|
@@ -325,7 +467,9 @@ module.exports = function (RED) {
|
|
|
325
467
|
return;
|
|
326
468
|
}
|
|
327
469
|
const cssHash = await state.cssHashReady;
|
|
328
|
-
const user = state.portalAuth
|
|
470
|
+
const user = state.portalAuth
|
|
471
|
+
? extractPortalUser(_req.headers)
|
|
472
|
+
: null;
|
|
329
473
|
res
|
|
330
474
|
.type("text/html")
|
|
331
475
|
.send(
|
|
@@ -336,6 +480,8 @@ module.exports = function (RED) {
|
|
|
336
480
|
state.customHead,
|
|
337
481
|
cssHash,
|
|
338
482
|
user,
|
|
483
|
+
state.showWsStatus,
|
|
484
|
+
state.vendorHash,
|
|
339
485
|
),
|
|
340
486
|
);
|
|
341
487
|
});
|
|
@@ -389,6 +535,10 @@ module.exports = function (RED) {
|
|
|
389
535
|
wsSend(ws, { type: "data", payload: lastPayload });
|
|
390
536
|
}
|
|
391
537
|
|
|
538
|
+
// Send content version for deploy-reload detection
|
|
539
|
+
const contentHash = pageState[endpoint]?.contentHash || "";
|
|
540
|
+
wsSend(ws, { type: "version", hash: contentHash });
|
|
541
|
+
|
|
392
542
|
ws.on("message", (raw) => {
|
|
393
543
|
try {
|
|
394
544
|
const msg = JSON.parse(raw.toString());
|
|
@@ -493,7 +643,9 @@ module.exports = function (RED) {
|
|
|
493
643
|
}); // end setImmediate
|
|
494
644
|
}
|
|
495
645
|
|
|
496
|
-
RED.nodes.registerType("portal-react", PortalReactNode
|
|
646
|
+
RED.nodes.registerType("portal-react", PortalReactNode, {
|
|
647
|
+
dynamicModuleList: "libs",
|
|
648
|
+
});
|
|
497
649
|
|
|
498
650
|
// ── Serve Monaco editor files locally ────────────────────────
|
|
499
651
|
const express = require("express");
|
|
@@ -529,14 +681,21 @@ module.exports = function (RED) {
|
|
|
529
681
|
res.send(css);
|
|
530
682
|
});
|
|
531
683
|
|
|
532
|
-
// ── Vendor
|
|
533
|
-
RED.httpAdmin.get("/portal-react/vendor
|
|
684
|
+
// ── Vendor bundle endpoint (dynamic, hash-based) ───────────
|
|
685
|
+
RED.httpAdmin.get("/portal-react/vendor/:hash.js", (req, res) => {
|
|
686
|
+
const entry = Object.values(vendorCache).find(
|
|
687
|
+
(v) => v.hash === req.params.hash,
|
|
688
|
+
);
|
|
689
|
+
if (!entry) {
|
|
690
|
+
res.status(404).send("Not found");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
534
693
|
res.set({
|
|
535
694
|
"Content-Type": "application/javascript",
|
|
536
695
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
537
|
-
ETag: `"${
|
|
696
|
+
ETag: `"${req.params.hash}"`,
|
|
538
697
|
});
|
|
539
|
-
res.send(
|
|
698
|
+
res.send(entry.js);
|
|
540
699
|
});
|
|
541
700
|
|
|
542
701
|
// ── Admin API for component registry ──────────────────────────
|
|
@@ -563,93 +722,116 @@ module.exports = function (RED) {
|
|
|
563
722
|
|
|
564
723
|
// ── Page builders ─────────────────────────────────────────────
|
|
565
724
|
|
|
566
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user) {
|
|
725
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, vendorHash) {
|
|
567
726
|
return `<!DOCTYPE html>
|
|
568
|
-
<html lang="en">
|
|
569
|
-
<head>
|
|
570
|
-
<meta charset="UTF-8">
|
|
571
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
572
|
-
<title>${esc(title)}</title>
|
|
573
|
-
<script src="${adminRoot}/portal-react/vendor
|
|
574
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
575
|
-
${escScript(customHead)}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
#__cs:hover{opacity:1}
|
|
584
|
-
#__cs.ok{color
|
|
585
|
-
#__cs.err{color
|
|
586
|
-
</style
|
|
587
|
-
</head>
|
|
588
|
-
<body>
|
|
589
|
-
<div id="root"></div>
|
|
590
|
-
|
|
591
|
-
<script>
|
|
592
|
-
window.__NR={
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
727
|
+
<html lang="en">
|
|
728
|
+
<head>
|
|
729
|
+
<meta charset="UTF-8">
|
|
730
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
731
|
+
<title>${esc(title)}</title>
|
|
732
|
+
<script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
|
|
733
|
+
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
734
|
+
${escScript(customHead)}
|
|
735
|
+
${showWsStatus ? `<style>
|
|
736
|
+
#__cs {
|
|
737
|
+
position: fixed; bottom: 6px; right: 6px;
|
|
738
|
+
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
739
|
+
z-index: 99999; background: #111; border: 1px solid #333;
|
|
740
|
+
opacity: .7; transition: opacity .2s;
|
|
741
|
+
}
|
|
742
|
+
#__cs:hover { opacity: 1 }
|
|
743
|
+
#__cs.ok { color: #4ade80 }
|
|
744
|
+
#__cs.err { color: #f87171 }
|
|
745
|
+
</style>` : ""}
|
|
746
|
+
</head>
|
|
747
|
+
<body>
|
|
748
|
+
<div id="root"></div>
|
|
749
|
+
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
750
|
+
<script>
|
|
751
|
+
window.__NR = {
|
|
752
|
+
_ws: null,
|
|
753
|
+
_listeners: new Set(),
|
|
754
|
+
_lastData: null,
|
|
755
|
+
_retries: 0,
|
|
756
|
+
_wasConnected: false,
|
|
757
|
+
_version: null,
|
|
758
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
759
|
+
|
|
760
|
+
connect() {
|
|
761
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
762
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
763
|
+
this._ws = ws;
|
|
764
|
+
const s = document.getElementById('__cs');
|
|
765
|
+
|
|
766
|
+
ws.onopen = () => {
|
|
767
|
+
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
768
|
+
this._retries = 0;
|
|
769
|
+
this._wasConnected = true;
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
ws.onmessage = (e) => {
|
|
773
|
+
try {
|
|
774
|
+
const m = JSON.parse(e.data);
|
|
775
|
+
if (m.type === 'version') {
|
|
776
|
+
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
777
|
+
this._version = m.hash;
|
|
778
|
+
}
|
|
779
|
+
if (m.type === 'data') {
|
|
780
|
+
this._lastData = m.payload;
|
|
781
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
782
|
+
}
|
|
783
|
+
} catch (err) { console.error('WS parse', err); }
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
ws.onclose = () => {
|
|
787
|
+
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
788
|
+
this._ws = null;
|
|
789
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
790
|
+
this._retries++;
|
|
791
|
+
setTimeout(() => this.connect(), delay);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
ws.onerror = () => ws.close();
|
|
795
|
+
},
|
|
796
|
+
|
|
797
|
+
subscribe(fn) {
|
|
798
|
+
this._listeners.add(fn);
|
|
799
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
800
|
+
return () => this._listeners.delete(fn);
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
send(payload, topic) {
|
|
804
|
+
if (this._ws && this._ws.readyState === 1)
|
|
805
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
window.__NR.connect();
|
|
809
|
+
<\/script>
|
|
810
|
+
<script>
|
|
811
|
+
${escScript(transpiledJs)}
|
|
812
|
+
<\/script>
|
|
813
|
+
</body>
|
|
814
|
+
</html>`;
|
|
633
815
|
}
|
|
634
816
|
|
|
635
817
|
function buildErrorPage(title, error) {
|
|
636
818
|
return `<!DOCTYPE html>
|
|
637
|
-
<html lang="en">
|
|
638
|
-
<head>
|
|
639
|
-
<meta charset="UTF-8">
|
|
640
|
-
<title>${esc(title)} — Error</title>
|
|
641
|
-
<style>
|
|
642
|
-
body{font-family:monospace;background
|
|
643
|
-
h1{color
|
|
644
|
-
pre{background
|
|
645
|
-
</style>
|
|
646
|
-
</head>
|
|
647
|
-
<body>
|
|
648
|
-
<h1>JSX Transpile Error</h1>
|
|
649
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
650
|
-
<pre>${esc(error)}</pre>
|
|
651
|
-
</body>
|
|
652
|
-
</html>`;
|
|
819
|
+
<html lang="en">
|
|
820
|
+
<head>
|
|
821
|
+
<meta charset="UTF-8">
|
|
822
|
+
<title>${esc(title)} — Error</title>
|
|
823
|
+
<style>
|
|
824
|
+
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
825
|
+
h1 { color: #ff4444; margin-bottom: 16px }
|
|
826
|
+
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
827
|
+
</style>
|
|
828
|
+
</head>
|
|
829
|
+
<body>
|
|
830
|
+
<h1>JSX Transpile Error</h1>
|
|
831
|
+
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
832
|
+
<pre>${esc(error)}</pre>
|
|
833
|
+
</body>
|
|
834
|
+
</html>`;
|
|
653
835
|
}
|
|
654
836
|
|
|
655
837
|
function esc(s) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaqu/fromcubes-portal-react",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
4
|
"description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"nodes/",
|
|
21
21
|
"examples/",
|
|
22
|
-
"scripts/",
|
|
23
22
|
"README.md",
|
|
24
23
|
"LICENSE"
|
|
25
24
|
],
|
|
@@ -27,17 +26,17 @@
|
|
|
27
26
|
"node": ">=18"
|
|
28
27
|
},
|
|
29
28
|
"dependencies": {
|
|
30
|
-
"esbuild": "0.
|
|
29
|
+
"esbuild": "^0.27.4",
|
|
31
30
|
"monaco-editor": "^0.55.1",
|
|
32
|
-
"
|
|
31
|
+
"react": "^19.2.4",
|
|
32
|
+
"react-dom": "^19.2.4",
|
|
33
|
+
"tailwindcss": "^4.2.1"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"react-dom": "^19.0.0"
|
|
36
|
+
"node-red": "next",
|
|
37
|
+
"prettier": "^3.8.1"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "node scripts/bundle-react.js",
|
|
41
40
|
"start": "node-red"
|
|
42
41
|
},
|
|
43
42
|
"license": "Apache-2.0",
|