@aaqu/fromcubes-portal-react 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +68 -33
- 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 +553 -166
- package/nodes/portal-react.js +341 -125
- package/package.json +8 -10
- 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,17 +11,11 @@ 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) {
|
|
15
|
+
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
16
|
+
const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
|
|
17
|
+
const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
|
|
18
|
+
|
|
25
19
|
// ── Shared state ──────────────────────────────────────────────
|
|
26
20
|
// Component registry: populated by fc-portal-component canvas nodes at deploy time
|
|
27
21
|
if (!RED.settings.fcPortalRegistry) {
|
|
@@ -59,6 +53,12 @@ module.exports = function (RED) {
|
|
|
59
53
|
}
|
|
60
54
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
61
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
|
+
|
|
62
62
|
// ── Helpers ───────────────────────────────────────────────────
|
|
63
63
|
|
|
64
64
|
function hash(str) {
|
|
@@ -88,12 +88,113 @@ module.exports = function (RED) {
|
|
|
88
88
|
return twCompiled;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
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
|
+
|
|
92
193
|
try {
|
|
93
194
|
const buildResult = esbuild.buildSync({
|
|
94
195
|
stdin: {
|
|
95
196
|
contents: jsx,
|
|
96
|
-
resolveDir
|
|
197
|
+
resolveDir,
|
|
97
198
|
loader: "jsx",
|
|
98
199
|
},
|
|
99
200
|
bundle: true,
|
|
@@ -103,8 +204,9 @@ module.exports = function (RED) {
|
|
|
103
204
|
jsx: "transform",
|
|
104
205
|
jsxFactory: "React.createElement",
|
|
105
206
|
jsxFragment: "React.Fragment",
|
|
106
|
-
external:
|
|
207
|
+
external: externalList,
|
|
107
208
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
209
|
+
banner: { js: requireShim },
|
|
108
210
|
});
|
|
109
211
|
return { js: buildResult.outputFiles[0].text, error: null };
|
|
110
212
|
} catch (e) {
|
|
@@ -130,6 +232,27 @@ module.exports = function (RED) {
|
|
|
130
232
|
);
|
|
131
233
|
}
|
|
132
234
|
|
|
235
|
+
function extractPortalUser(headers) {
|
|
236
|
+
const user = {};
|
|
237
|
+
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
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"];
|
|
246
|
+
if (headers["x-portal-user-groups"]) {
|
|
247
|
+
try {
|
|
248
|
+
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
249
|
+
} catch (_) {
|
|
250
|
+
user.groups = headers["x-portal-user-groups"];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return Object.keys(user).length > 0 ? user : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
133
256
|
function removeRoute(router, path) {
|
|
134
257
|
if (!router || !router.stack) return;
|
|
135
258
|
router.stack = router.stack.filter(
|
|
@@ -188,10 +311,13 @@ module.exports = function (RED) {
|
|
|
188
311
|
const nodeId = node.id;
|
|
189
312
|
|
|
190
313
|
// Config
|
|
191
|
-
const endpoint = (config.endpoint || "/
|
|
314
|
+
const endpoint = (config.endpoint || "/fromcubes").replace(/\/+$/, "");
|
|
192
315
|
const componentCode = config.componentCode || "";
|
|
193
316
|
const pageTitle = config.pageTitle || "Portal";
|
|
194
317
|
const customHead = config.customHead || "";
|
|
318
|
+
const portalAuth = config.portalAuth === true;
|
|
319
|
+
const showWsStatus = config.showWsStatus === true;
|
|
320
|
+
const libs = config.libs || [];
|
|
195
321
|
|
|
196
322
|
// State
|
|
197
323
|
const clients = new Set();
|
|
@@ -199,24 +325,56 @@ module.exports = function (RED) {
|
|
|
199
325
|
let wsServer = null;
|
|
200
326
|
let isClosing = false;
|
|
201
327
|
|
|
202
|
-
const wsPath = endpoint + "/_ws";
|
|
328
|
+
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
203
329
|
|
|
204
330
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
205
331
|
|
|
206
332
|
function rebuild() {
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
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));
|
|
210
367
|
entries.sort((a, b) => {
|
|
211
368
|
const aUsesB = a[1].code.includes(b[0]);
|
|
212
369
|
const bUsesA = b[1].code.includes(a[0]);
|
|
213
|
-
if (aUsesB && !bUsesA) return 1;
|
|
370
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
214
371
|
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
215
372
|
return 0;
|
|
216
373
|
});
|
|
217
374
|
const libraryJsx = entries
|
|
218
|
-
.map(
|
|
219
|
-
|
|
375
|
+
.map(
|
|
376
|
+
([name, c]) =>
|
|
377
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
220
378
|
)
|
|
221
379
|
.join("\n\n");
|
|
222
380
|
|
|
@@ -226,16 +384,19 @@ module.exports = function (RED) {
|
|
|
226
384
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
227
385
|
"",
|
|
228
386
|
"// ── useNodeRed hook ──",
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
React.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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"),
|
|
239
400
|
"",
|
|
240
401
|
"// ── Library components ──",
|
|
241
402
|
libraryJsx,
|
|
@@ -247,7 +408,7 @@ module.exports = function (RED) {
|
|
|
247
408
|
"ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
248
409
|
].join("\n");
|
|
249
410
|
|
|
250
|
-
const compiled = transpile(fullJsx);
|
|
411
|
+
const compiled = transpile(fullJsx, libs);
|
|
251
412
|
|
|
252
413
|
if (compiled.error) {
|
|
253
414
|
node.error("JSX transpile error: " + compiled.error);
|
|
@@ -268,12 +429,18 @@ module.exports = function (RED) {
|
|
|
268
429
|
})
|
|
269
430
|
: Promise.resolve("");
|
|
270
431
|
|
|
432
|
+
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
433
|
+
|
|
271
434
|
pageState[endpoint] = {
|
|
272
435
|
compiled,
|
|
436
|
+
contentHash,
|
|
273
437
|
cssHashReady,
|
|
274
438
|
pageTitle,
|
|
275
439
|
wsPath,
|
|
276
440
|
customHead,
|
|
441
|
+
portalAuth,
|
|
442
|
+
showWsStatus,
|
|
443
|
+
vendorHash: vendorBundle.hash,
|
|
277
444
|
};
|
|
278
445
|
}
|
|
279
446
|
|
|
@@ -301,6 +468,9 @@ module.exports = function (RED) {
|
|
|
301
468
|
return;
|
|
302
469
|
}
|
|
303
470
|
const cssHash = await state.cssHashReady;
|
|
471
|
+
const user = state.portalAuth
|
|
472
|
+
? extractPortalUser(_req.headers)
|
|
473
|
+
: null;
|
|
304
474
|
res
|
|
305
475
|
.type("text/html")
|
|
306
476
|
.send(
|
|
@@ -310,6 +480,9 @@ module.exports = function (RED) {
|
|
|
310
480
|
state.wsPath,
|
|
311
481
|
state.customHead,
|
|
312
482
|
cssHash,
|
|
483
|
+
user,
|
|
484
|
+
state.showWsStatus,
|
|
485
|
+
state.vendorHash,
|
|
313
486
|
),
|
|
314
487
|
);
|
|
315
488
|
});
|
|
@@ -347,11 +520,14 @@ module.exports = function (RED) {
|
|
|
347
520
|
RED.server.on("upgrade", onUpgrade);
|
|
348
521
|
upgradeHandlers[nodeId] = onUpgrade;
|
|
349
522
|
|
|
350
|
-
wsServer.on("connection", (ws) => {
|
|
523
|
+
wsServer.on("connection", (ws, request) => {
|
|
351
524
|
if (isClosing) {
|
|
352
525
|
ws.close();
|
|
353
526
|
return;
|
|
354
527
|
}
|
|
528
|
+
if (portalAuth) {
|
|
529
|
+
ws._portalUser = extractPortalUser(request.headers);
|
|
530
|
+
}
|
|
355
531
|
clients.add(ws);
|
|
356
532
|
updateStatus();
|
|
357
533
|
|
|
@@ -360,14 +536,22 @@ module.exports = function (RED) {
|
|
|
360
536
|
wsSend(ws, { type: "data", payload: lastPayload });
|
|
361
537
|
}
|
|
362
538
|
|
|
539
|
+
// Send content version for deploy-reload detection
|
|
540
|
+
const contentHash = pageState[endpoint]?.contentHash || "";
|
|
541
|
+
wsSend(ws, { type: "version", hash: contentHash });
|
|
542
|
+
|
|
363
543
|
ws.on("message", (raw) => {
|
|
364
544
|
try {
|
|
365
545
|
const msg = JSON.parse(raw.toString());
|
|
366
546
|
if (msg.type === "output") {
|
|
367
|
-
|
|
547
|
+
const out = {
|
|
368
548
|
payload: msg.payload,
|
|
369
549
|
topic: msg.topic || "",
|
|
370
|
-
}
|
|
550
|
+
};
|
|
551
|
+
if (portalAuth && ws._portalUser) {
|
|
552
|
+
out._client = ws._portalUser;
|
|
553
|
+
}
|
|
554
|
+
node.send(out);
|
|
371
555
|
}
|
|
372
556
|
} catch (e) {
|
|
373
557
|
node.warn("Bad WS message: " + e.message);
|
|
@@ -460,7 +644,9 @@ module.exports = function (RED) {
|
|
|
460
644
|
}); // end setImmediate
|
|
461
645
|
}
|
|
462
646
|
|
|
463
|
-
RED.nodes.registerType("portal-react", PortalReactNode
|
|
647
|
+
RED.nodes.registerType("portal-react", PortalReactNode, {
|
|
648
|
+
dynamicModuleList: "libs",
|
|
649
|
+
});
|
|
464
650
|
|
|
465
651
|
// ── Serve Monaco editor files locally ────────────────────────
|
|
466
652
|
const express = require("express");
|
|
@@ -496,14 +682,21 @@ module.exports = function (RED) {
|
|
|
496
682
|
res.send(css);
|
|
497
683
|
});
|
|
498
684
|
|
|
499
|
-
// ── Vendor
|
|
500
|
-
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
|
+
}
|
|
501
694
|
res.set({
|
|
502
695
|
"Content-Type": "application/javascript",
|
|
503
696
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
504
|
-
ETag: `"${
|
|
697
|
+
ETag: `"${req.params.hash}"`,
|
|
505
698
|
});
|
|
506
|
-
res.send(
|
|
699
|
+
res.send(entry.js);
|
|
507
700
|
});
|
|
508
701
|
|
|
509
702
|
// ── Admin API for component registry ──────────────────────────
|
|
@@ -530,93 +723,116 @@ module.exports = function (RED) {
|
|
|
530
723
|
|
|
531
724
|
// ── Page builders ─────────────────────────────────────────────
|
|
532
725
|
|
|
533
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash) {
|
|
726
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, vendorHash) {
|
|
534
727
|
return `<!DOCTYPE html>
|
|
535
|
-
<html lang="en">
|
|
536
|
-
<head>
|
|
537
|
-
<meta charset="UTF-8">
|
|
538
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
539
|
-
<title>${esc(title)}</title>
|
|
540
|
-
<script src="/portal-react/vendor
|
|
541
|
-
${cssHash ? `<link rel="stylesheet" href="/portal-react/css/${cssHash}.css">` : ""}
|
|
542
|
-
${escScript(customHead)}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
#__cs:hover{opacity:1}
|
|
551
|
-
#__cs.ok{color
|
|
552
|
-
#__cs.err{color
|
|
553
|
-
</style
|
|
554
|
-
</head>
|
|
555
|
-
<body>
|
|
556
|
-
<div id="root"></div>
|
|
557
|
-
|
|
558
|
-
<script>
|
|
559
|
-
window.__NR={
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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>`;
|
|
600
816
|
}
|
|
601
817
|
|
|
602
818
|
function buildErrorPage(title, error) {
|
|
603
819
|
return `<!DOCTYPE html>
|
|
604
|
-
<html lang="en">
|
|
605
|
-
<head>
|
|
606
|
-
<meta charset="UTF-8">
|
|
607
|
-
<title>${esc(title)} — Error</title>
|
|
608
|
-
<style>
|
|
609
|
-
body{font-family:monospace;background
|
|
610
|
-
h1{color
|
|
611
|
-
pre{background
|
|
612
|
-
</style>
|
|
613
|
-
</head>
|
|
614
|
-
<body>
|
|
615
|
-
<h1>JSX Transpile Error</h1>
|
|
616
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
617
|
-
<pre>${esc(error)}</pre>
|
|
618
|
-
</body>
|
|
619
|
-
</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>`;
|
|
620
836
|
}
|
|
621
837
|
|
|
622
838
|
function esc(s) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaqu/fromcubes-portal-react",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0-alpha.10",
|
|
4
|
+
"description": "Fromcubes Portal - React for Node-RED with Tailwind CSS and auto complete",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
7
|
-
"react",
|
|
8
7
|
"dashboard",
|
|
9
8
|
"portal",
|
|
10
9
|
"jsx",
|
|
@@ -20,7 +19,6 @@
|
|
|
20
19
|
"files": [
|
|
21
20
|
"nodes/",
|
|
22
21
|
"examples/",
|
|
23
|
-
"scripts/",
|
|
24
22
|
"README.md",
|
|
25
23
|
"LICENSE"
|
|
26
24
|
],
|
|
@@ -28,17 +26,17 @@
|
|
|
28
26
|
"node": ">=18"
|
|
29
27
|
},
|
|
30
28
|
"dependencies": {
|
|
31
|
-
"esbuild": "0.
|
|
29
|
+
"esbuild": "^0.27.4",
|
|
32
30
|
"monaco-editor": "^0.55.1",
|
|
33
|
-
"
|
|
31
|
+
"react": "^19.2.4",
|
|
32
|
+
"react-dom": "^19.2.4",
|
|
33
|
+
"tailwindcss": "^4.2.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"react-dom": "^19.0.0"
|
|
36
|
+
"node-red": "next",
|
|
37
|
+
"prettier": "^3.8.1"
|
|
39
38
|
},
|
|
40
39
|
"scripts": {
|
|
41
|
-
"build": "node scripts/bundle-react.js",
|
|
42
40
|
"start": "node-red"
|
|
43
41
|
},
|
|
44
42
|
"license": "Apache-2.0",
|