@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.
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * @aaqu/fromcubes-portal-react
3
3
  *
4
- * Server-side JSX transpilation & bundling via esbuild.
5
- * Deploy-safe: handles rapid redeploys without leaking WS connections or stale routes.
6
- * Transpiled JS is cached by content hash unchanged code skips recompilation on redeploy.
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 timebrowsers 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
- function transpile(jsx) {
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: path.join(__dirname, "../../.."),
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: ["react", "react-dom"],
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 || "/portal").replace(/\/+$/, "");
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
- // Topological sort: components used by others come first
208
- const entries = Object.entries(registry);
209
- const names = entries.map(([n]) => n);
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; // a depends on b → b first
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(([name, c]) =>
219
- `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`
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
- `function useNodeRed() {
230
- const [data, setData] = React.useState(window.__NR._lastData);
231
- React.useEffect(() => {
232
- return window.__NR.subscribe(setData);
233
- }, []);
234
- const send = React.useCallback((payload, topic) => {
235
- window.__NR.send(payload, topic);
236
- }, []);
237
- return { data, send };
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
- node.send({
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 React bundle endpoint ────────────────────────────
500
- RED.httpAdmin.get("/portal-react/vendor/react.min.js", (_req, res) => {
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: `"${reactHash}"`,
697
+ ETag: `"${req.params.hash}"`,
505
698
  });
506
- res.send(reactBundle);
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/react.min.js?v=${reactHash}"><\/script>
541
- ${cssHash ? `<link rel="stylesheet" href="/portal-react/css/${cssHash}.css">` : ""}
542
- ${escScript(customHead)}
543
- <style>
544
- @layer base{
545
- *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
546
- body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#e0e0e0}
547
- #root{min-height:100vh}
548
- }
549
- #__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}
550
- #__cs:hover{opacity:1}
551
- #__cs.ok{color:#4ade80}
552
- #__cs.err{color:#f87171}
553
- </style>
554
- </head>
555
- <body>
556
- <div id="root"></div>
557
- <div id="__cs" class="err">disconnected</div>
558
- <script>
559
- window.__NR={
560
- _ws:null,_listeners:new Set(),_lastData:null,_retries:0,_wasConnected:false,
561
- connect(){
562
- const p=location.protocol==='https:'?'wss:':'ws:';
563
- const ws=new WebSocket(p+'//'+location.host+'${wsPath}');
564
- this._ws=ws;
565
- const s=document.getElementById('__cs');
566
- ws.onopen=()=>{
567
- if(this._wasConnected){location.reload();return;}
568
- s.textContent='connected';s.className='ok';this._retries=0;this._wasConnected=true;
569
- };
570
- ws.onmessage=(e)=>{
571
- try{const m=JSON.parse(e.data);if(m.type==='data'){this._lastData=m.payload;this._listeners.forEach(fn=>fn(m.payload));}}
572
- catch(err){console.error('WS parse',err);}
573
- };
574
- ws.onclose=(e)=>{
575
- s.textContent='disconnected';s.className='err';
576
- this._ws=null;
577
- const delay=Math.min(500*Math.pow(2,this._retries),8000);
578
- this._retries++;
579
- setTimeout(()=>this.connect(),delay);
580
- };
581
- ws.onerror=()=>ws.close();
582
- },
583
- subscribe(fn){
584
- this._listeners.add(fn);
585
- if(this._lastData!==null)fn(this._lastData);
586
- return()=>this._listeners.delete(fn);
587
- },
588
- send(payload,topic){
589
- if(this._ws&&this._ws.readyState===1)
590
- this._ws.send(JSON.stringify({type:'output',payload,topic:topic||''}));
591
- }
592
- };
593
- window.__NR.connect();
594
- <\/script>
595
- <script>
596
- ${escScript(transpiledJs)}
597
- <\/script>
598
- </body>
599
- </html>`;
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:#1a0000;color:#f87171;padding:40px;line-height:1.6}
610
- h1{color:#ff4444;margin-bottom:16px}
611
- pre{background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5}
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.1",
4
- "description": "React portal node for Node-RED server-side JSX transpilation, Monaco editor, component registry, WebSocket I/O",
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.25.0",
29
+ "esbuild": "^0.27.4",
32
30
  "monaco-editor": "^0.55.1",
33
- "tailwindcss": "^4.0.0"
31
+ "react": "^19.2.4",
32
+ "react-dom": "^19.2.4",
33
+ "tailwindcss": "^4.2.1"
34
34
  },
35
35
  "devDependencies": {
36
- "prettier": "^3.8.1",
37
- "react": "^19.0.0",
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",