@aaqu/fromcubes-portal-react 0.1.0-alpha.7 → 0.1.0-alpha.9
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 +303 -129
- 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,113 @@ 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}.default || __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
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
140
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
141
|
+
});
|
|
142
|
+
const js = buildResult.outputFiles[0].text;
|
|
143
|
+
const h = hash(js);
|
|
144
|
+
return { js, hash: h };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function getVendorBundle(libs) {
|
|
148
|
+
// Build cache key from actual installed versions
|
|
149
|
+
const keyParts = ["react@" + getInstalledVersion("react")];
|
|
150
|
+
for (const lib of libs) {
|
|
151
|
+
keyParts.push(lib.module + "@" + getInstalledVersion(getPackageName(lib.module)));
|
|
152
|
+
}
|
|
153
|
+
keyParts.sort();
|
|
154
|
+
const cacheKey = hash(JSON.stringify(keyParts));
|
|
155
|
+
|
|
156
|
+
if (vendorCache[cacheKey]) return vendorCache[cacheKey];
|
|
157
|
+
|
|
158
|
+
const bundle = buildVendorBundle(libs);
|
|
159
|
+
vendorCache[cacheKey] = bundle;
|
|
160
|
+
return bundle;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function transpile(jsx, libs) {
|
|
164
|
+
const externalList = ["react", "react-dom", "react-dom/client"];
|
|
165
|
+
if (libs) {
|
|
166
|
+
libs.forEach((lib) => {
|
|
167
|
+
if (!externalList.includes(lib.module)) {
|
|
168
|
+
externalList.push(lib.module);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Auto-detect require() calls in JSX and add them as externals
|
|
174
|
+
const requireRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
175
|
+
let m;
|
|
176
|
+
while ((m = requireRe.exec(jsx)) !== null) {
|
|
177
|
+
if (!externalList.includes(m[1])) {
|
|
178
|
+
externalList.push(m[1]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const requireShim = [
|
|
183
|
+
"var require = (function() {",
|
|
184
|
+
' var _r = {"react":window.React, "react-dom":window.ReactDOM, "react-dom/client":window.ReactDOM};',
|
|
185
|
+
" return function(m) {",
|
|
186
|
+
" if (_r[m]) return _r[m];",
|
|
187
|
+
" if (window.__pkg && window.__pkg[m]) return window.__pkg[m];",
|
|
188
|
+
' throw new Error("Module not found: " + m);',
|
|
189
|
+
" };",
|
|
190
|
+
"})();",
|
|
191
|
+
].join("\n");
|
|
192
|
+
|
|
96
193
|
try {
|
|
97
194
|
const buildResult = esbuild.buildSync({
|
|
98
195
|
stdin: {
|
|
99
196
|
contents: jsx,
|
|
100
|
-
resolveDir
|
|
197
|
+
resolveDir,
|
|
101
198
|
loader: "jsx",
|
|
102
199
|
},
|
|
103
200
|
bundle: true,
|
|
@@ -107,8 +204,9 @@ module.exports = function (RED) {
|
|
|
107
204
|
jsx: "transform",
|
|
108
205
|
jsxFactory: "React.createElement",
|
|
109
206
|
jsxFragment: "React.Fragment",
|
|
110
|
-
external:
|
|
207
|
+
external: externalList,
|
|
111
208
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
209
|
+
banner: { js: requireShim },
|
|
112
210
|
});
|
|
113
211
|
return { js: buildResult.outputFiles[0].text, error: null };
|
|
114
212
|
} catch (e) {
|
|
@@ -137,10 +235,14 @@ module.exports = function (RED) {
|
|
|
137
235
|
function extractPortalUser(headers) {
|
|
138
236
|
const user = {};
|
|
139
237
|
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
|
-
|
|
238
|
+
if (headers["x-portal-user-name"])
|
|
239
|
+
user.userName = headers["x-portal-user-name"];
|
|
240
|
+
if (headers["x-portal-user-username"])
|
|
241
|
+
user.username = headers["x-portal-user-username"];
|
|
242
|
+
if (headers["x-portal-user-email"])
|
|
243
|
+
user.email = headers["x-portal-user-email"];
|
|
244
|
+
if (headers["x-portal-user-role"])
|
|
245
|
+
user.role = headers["x-portal-user-role"];
|
|
144
246
|
if (headers["x-portal-user-groups"]) {
|
|
145
247
|
try {
|
|
146
248
|
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
@@ -209,11 +311,13 @@ module.exports = function (RED) {
|
|
|
209
311
|
const nodeId = node.id;
|
|
210
312
|
|
|
211
313
|
// Config
|
|
212
|
-
const endpoint = (config.endpoint || "/
|
|
314
|
+
const endpoint = (config.endpoint || "/fromcubes").replace(/\/+$/, "");
|
|
213
315
|
const componentCode = config.componentCode || "";
|
|
214
316
|
const pageTitle = config.pageTitle || "Portal";
|
|
215
317
|
const customHead = config.customHead || "";
|
|
216
318
|
const portalAuth = config.portalAuth === true;
|
|
319
|
+
const showWsStatus = config.showWsStatus === true;
|
|
320
|
+
const libs = config.libs || [];
|
|
217
321
|
|
|
218
322
|
// State
|
|
219
323
|
const clients = new Set();
|
|
@@ -226,19 +330,51 @@ module.exports = function (RED) {
|
|
|
226
330
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
227
331
|
|
|
228
332
|
function rebuild() {
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
333
|
+
// Build or get cached vendor bundle
|
|
334
|
+
let vendorBundle;
|
|
335
|
+
try {
|
|
336
|
+
vendorBundle = getVendorBundle(libs);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
node.error("Vendor bundle failed: " + e.message);
|
|
339
|
+
node.status({ fill: "red", shape: "dot", text: "vendor build error" });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
344
|
+
const allEntries = Object.entries(registry);
|
|
345
|
+
const needed = new Set();
|
|
346
|
+
|
|
347
|
+
function addWithDeps(name) {
|
|
348
|
+
if (needed.has(name)) return;
|
|
349
|
+
const entry = registry[name];
|
|
350
|
+
if (!entry) return;
|
|
351
|
+
needed.add(name);
|
|
352
|
+
for (const [other] of allEntries) {
|
|
353
|
+
if (other !== name && entry.code.includes(other)) {
|
|
354
|
+
addWithDeps(other);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const [name] of allEntries) {
|
|
360
|
+
if (componentCode.includes(name)) {
|
|
361
|
+
addWithDeps(name);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Topological sort only needed components
|
|
366
|
+
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
232
367
|
entries.sort((a, b) => {
|
|
233
368
|
const aUsesB = a[1].code.includes(b[0]);
|
|
234
369
|
const bUsesA = b[1].code.includes(a[0]);
|
|
235
|
-
if (aUsesB && !bUsesA) return 1;
|
|
370
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
236
371
|
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
237
372
|
return 0;
|
|
238
373
|
});
|
|
239
374
|
const libraryJsx = entries
|
|
240
|
-
.map(
|
|
241
|
-
|
|
375
|
+
.map(
|
|
376
|
+
([name, c]) =>
|
|
377
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
242
378
|
)
|
|
243
379
|
.join("\n\n");
|
|
244
380
|
|
|
@@ -248,17 +384,19 @@ module.exports = function (RED) {
|
|
|
248
384
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
249
385
|
"",
|
|
250
386
|
"// ── useNodeRed hook ──",
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
React.
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
387
|
+
[
|
|
388
|
+
"function useNodeRed() {",
|
|
389
|
+
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
390
|
+
" React.useEffect(() => {",
|
|
391
|
+
" return window.__NR.subscribe(setData);",
|
|
392
|
+
" }, []);",
|
|
393
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
394
|
+
" window.__NR.send(payload, topic);",
|
|
395
|
+
" }, []);",
|
|
396
|
+
" const user = window.__NR._user || null;",
|
|
397
|
+
" return { data, send, user };",
|
|
398
|
+
"}",
|
|
399
|
+
].join("\n"),
|
|
262
400
|
"",
|
|
263
401
|
"// ── Library components ──",
|
|
264
402
|
libraryJsx,
|
|
@@ -270,7 +408,7 @@ module.exports = function (RED) {
|
|
|
270
408
|
"ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
271
409
|
].join("\n");
|
|
272
410
|
|
|
273
|
-
const compiled = transpile(fullJsx);
|
|
411
|
+
const compiled = transpile(fullJsx, libs);
|
|
274
412
|
|
|
275
413
|
if (compiled.error) {
|
|
276
414
|
node.error("JSX transpile error: " + compiled.error);
|
|
@@ -301,6 +439,8 @@ module.exports = function (RED) {
|
|
|
301
439
|
wsPath,
|
|
302
440
|
customHead,
|
|
303
441
|
portalAuth,
|
|
442
|
+
showWsStatus,
|
|
443
|
+
vendorHash: vendorBundle.hash,
|
|
304
444
|
};
|
|
305
445
|
}
|
|
306
446
|
|
|
@@ -328,7 +468,9 @@ module.exports = function (RED) {
|
|
|
328
468
|
return;
|
|
329
469
|
}
|
|
330
470
|
const cssHash = await state.cssHashReady;
|
|
331
|
-
const user = state.portalAuth
|
|
471
|
+
const user = state.portalAuth
|
|
472
|
+
? extractPortalUser(_req.headers)
|
|
473
|
+
: null;
|
|
332
474
|
res
|
|
333
475
|
.type("text/html")
|
|
334
476
|
.send(
|
|
@@ -339,6 +481,8 @@ module.exports = function (RED) {
|
|
|
339
481
|
state.customHead,
|
|
340
482
|
cssHash,
|
|
341
483
|
user,
|
|
484
|
+
state.showWsStatus,
|
|
485
|
+
state.vendorHash,
|
|
342
486
|
),
|
|
343
487
|
);
|
|
344
488
|
});
|
|
@@ -500,7 +644,9 @@ module.exports = function (RED) {
|
|
|
500
644
|
}); // end setImmediate
|
|
501
645
|
}
|
|
502
646
|
|
|
503
|
-
RED.nodes.registerType("portal-react", PortalReactNode
|
|
647
|
+
RED.nodes.registerType("portal-react", PortalReactNode, {
|
|
648
|
+
dynamicModuleList: "libs",
|
|
649
|
+
});
|
|
504
650
|
|
|
505
651
|
// ── Serve Monaco editor files locally ────────────────────────
|
|
506
652
|
const express = require("express");
|
|
@@ -536,14 +682,21 @@ module.exports = function (RED) {
|
|
|
536
682
|
res.send(css);
|
|
537
683
|
});
|
|
538
684
|
|
|
539
|
-
// ── Vendor
|
|
540
|
-
RED.httpAdmin.get("/portal-react/vendor
|
|
685
|
+
// ── Vendor bundle endpoint (dynamic, hash-based) ───────────
|
|
686
|
+
RED.httpAdmin.get("/portal-react/vendor/:hash.js", (req, res) => {
|
|
687
|
+
const entry = Object.values(vendorCache).find(
|
|
688
|
+
(v) => v.hash === req.params.hash,
|
|
689
|
+
);
|
|
690
|
+
if (!entry) {
|
|
691
|
+
res.status(404).send("Not found");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
541
694
|
res.set({
|
|
542
695
|
"Content-Type": "application/javascript",
|
|
543
696
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
544
|
-
ETag: `"${
|
|
697
|
+
ETag: `"${req.params.hash}"`,
|
|
545
698
|
});
|
|
546
|
-
res.send(
|
|
699
|
+
res.send(entry.js);
|
|
547
700
|
});
|
|
548
701
|
|
|
549
702
|
// ── Admin API for component registry ──────────────────────────
|
|
@@ -570,95 +723,116 @@ module.exports = function (RED) {
|
|
|
570
723
|
|
|
571
724
|
// ── Page builders ─────────────────────────────────────────────
|
|
572
725
|
|
|
573
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user) {
|
|
726
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, vendorHash) {
|
|
574
727
|
return `<!DOCTYPE html>
|
|
575
|
-
<html lang="en">
|
|
576
|
-
<head>
|
|
577
|
-
<meta charset="UTF-8">
|
|
578
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
579
|
-
<title>${esc(title)}</title>
|
|
580
|
-
<script src="${adminRoot}/portal-react/vendor
|
|
581
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
582
|
-
${escScript(customHead)}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
#__cs:hover{opacity:1}
|
|
591
|
-
#__cs.ok{color
|
|
592
|
-
#__cs.err{color
|
|
593
|
-
</style
|
|
594
|
-
</head>
|
|
595
|
-
<body>
|
|
596
|
-
<div id="root"></div>
|
|
597
|
-
|
|
598
|
-
<script>
|
|
599
|
-
window.__NR={
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
728
|
+
<html lang="en">
|
|
729
|
+
<head>
|
|
730
|
+
<meta charset="UTF-8">
|
|
731
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
732
|
+
<title>${esc(title)}</title>
|
|
733
|
+
<script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
|
|
734
|
+
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
735
|
+
${escScript(customHead)}
|
|
736
|
+
${showWsStatus ? `<style>
|
|
737
|
+
#__cs {
|
|
738
|
+
position: fixed; bottom: 6px; right: 6px;
|
|
739
|
+
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
740
|
+
z-index: 99999; background: #111; border: 1px solid #333;
|
|
741
|
+
opacity: .7; transition: opacity .2s;
|
|
742
|
+
}
|
|
743
|
+
#__cs:hover { opacity: 1 }
|
|
744
|
+
#__cs.ok { color: #4ade80 }
|
|
745
|
+
#__cs.err { color: #f87171 }
|
|
746
|
+
</style>` : ""}
|
|
747
|
+
</head>
|
|
748
|
+
<body>
|
|
749
|
+
<div id="root"></div>
|
|
750
|
+
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
751
|
+
<script>
|
|
752
|
+
window.__NR = {
|
|
753
|
+
_ws: null,
|
|
754
|
+
_listeners: new Set(),
|
|
755
|
+
_lastData: null,
|
|
756
|
+
_retries: 0,
|
|
757
|
+
_wasConnected: false,
|
|
758
|
+
_version: null,
|
|
759
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
760
|
+
|
|
761
|
+
connect() {
|
|
762
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
763
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
764
|
+
this._ws = ws;
|
|
765
|
+
const s = document.getElementById('__cs');
|
|
766
|
+
|
|
767
|
+
ws.onopen = () => {
|
|
768
|
+
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
769
|
+
this._retries = 0;
|
|
770
|
+
this._wasConnected = true;
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
ws.onmessage = (e) => {
|
|
774
|
+
try {
|
|
775
|
+
const m = JSON.parse(e.data);
|
|
776
|
+
if (m.type === 'version') {
|
|
777
|
+
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
778
|
+
this._version = m.hash;
|
|
779
|
+
}
|
|
780
|
+
if (m.type === 'data') {
|
|
781
|
+
this._lastData = m.payload;
|
|
782
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
783
|
+
}
|
|
784
|
+
} catch (err) { console.error('WS parse', err); }
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
ws.onclose = () => {
|
|
788
|
+
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
789
|
+
this._ws = null;
|
|
790
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
791
|
+
this._retries++;
|
|
792
|
+
setTimeout(() => this.connect(), delay);
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
ws.onerror = () => ws.close();
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
subscribe(fn) {
|
|
799
|
+
this._listeners.add(fn);
|
|
800
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
801
|
+
return () => this._listeners.delete(fn);
|
|
802
|
+
},
|
|
803
|
+
|
|
804
|
+
send(payload, topic) {
|
|
805
|
+
if (this._ws && this._ws.readyState === 1)
|
|
806
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
window.__NR.connect();
|
|
810
|
+
<\/script>
|
|
811
|
+
<script>
|
|
812
|
+
${escScript(transpiledJs)}
|
|
813
|
+
<\/script>
|
|
814
|
+
</body>
|
|
815
|
+
</html>`;
|
|
642
816
|
}
|
|
643
817
|
|
|
644
818
|
function buildErrorPage(title, error) {
|
|
645
819
|
return `<!DOCTYPE html>
|
|
646
|
-
<html lang="en">
|
|
647
|
-
<head>
|
|
648
|
-
<meta charset="UTF-8">
|
|
649
|
-
<title>${esc(title)} — Error</title>
|
|
650
|
-
<style>
|
|
651
|
-
body{font-family:monospace;background
|
|
652
|
-
h1{color
|
|
653
|
-
pre{background
|
|
654
|
-
</style>
|
|
655
|
-
</head>
|
|
656
|
-
<body>
|
|
657
|
-
<h1>JSX Transpile Error</h1>
|
|
658
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
659
|
-
<pre>${esc(error)}</pre>
|
|
660
|
-
</body>
|
|
661
|
-
</html>`;
|
|
820
|
+
<html lang="en">
|
|
821
|
+
<head>
|
|
822
|
+
<meta charset="UTF-8">
|
|
823
|
+
<title>${esc(title)} — Error</title>
|
|
824
|
+
<style>
|
|
825
|
+
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
826
|
+
h1 { color: #ff4444; margin-bottom: 16px }
|
|
827
|
+
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
828
|
+
</style>
|
|
829
|
+
</head>
|
|
830
|
+
<body>
|
|
831
|
+
<h1>JSX Transpile Error</h1>
|
|
832
|
+
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
833
|
+
<pre>${esc(error)}</pre>
|
|
834
|
+
</body>
|
|
835
|
+
</html>`;
|
|
662
836
|
}
|
|
663
837
|
|
|
664
838
|
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.9",
|
|
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",
|