@aaqu/fromcubes-portal-react 0.1.0-alpha.1

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.
@@ -0,0 +1,633 @@
1
+ /**
2
+ * @aaqu/fromcubes-portal-react
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.
7
+ */
8
+
9
+ const crypto = require("crypto");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const esbuild = require("esbuild");
13
+
14
+ const reactBundle = fs.readFileSync(
15
+ path.join(__dirname, "vendor", "react-19.production.min.js"),
16
+ "utf8",
17
+ );
18
+ const reactHash = crypto
19
+ .createHash("sha256")
20
+ .update(reactBundle)
21
+ .digest("hex")
22
+ .slice(0, 10);
23
+
24
+ module.exports = function (RED) {
25
+ // ── Shared state ──────────────────────────────────────────────
26
+ // Component registry: populated by fc-portal-component canvas nodes at deploy time
27
+ if (!RED.settings.fcPortalRegistry) {
28
+ RED.settings.fcPortalRegistry = {};
29
+ }
30
+ const registry = RED.settings.fcPortalRegistry;
31
+
32
+ // CSS cache: hash → css string
33
+ if (!RED.settings.fcCssCache) {
34
+ RED.settings.fcCssCache = {};
35
+ }
36
+ const cssCache = RED.settings.fcCssCache;
37
+
38
+ // Active upgrade handlers per node id (for cleanup on redeploy)
39
+ if (!RED.settings.fcUpgradeHandlers) {
40
+ RED.settings.fcUpgradeHandlers = {};
41
+ }
42
+ const upgradeHandlers = RED.settings.fcUpgradeHandlers;
43
+
44
+ // Live page state per endpoint — route handlers read from this on each request
45
+ if (!RED.settings.fcPageState) {
46
+ RED.settings.fcPageState = {};
47
+ }
48
+ const pageState = RED.settings.fcPageState;
49
+
50
+ // Track which endpoints already have a registered Express route
51
+ if (!RED.settings.fcRegisteredRoutes) {
52
+ RED.settings.fcRegisteredRoutes = {};
53
+ }
54
+ const registeredRoutes = RED.settings.fcRegisteredRoutes;
55
+
56
+ // Rebuild callbacks: portal-react nodes register here so components can trigger re-transpile
57
+ if (!RED.settings.fcRebuildCallbacks) {
58
+ RED.settings.fcRebuildCallbacks = {};
59
+ }
60
+ const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
61
+
62
+ // ── Helpers ───────────────────────────────────────────────────
63
+
64
+ function hash(str) {
65
+ return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
66
+ }
67
+
68
+ const twCompile = require("tailwindcss").compile;
69
+ const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
70
+
71
+ let twCompiled = null;
72
+ async function getTwCompiled() {
73
+ if (twCompiled) return twCompiled;
74
+ twCompiled = await twCompile(`@import 'tailwindcss';`, {
75
+ loadStylesheet: async (id, base) => {
76
+ let resolved;
77
+ if (id === "tailwindcss") {
78
+ resolved = require.resolve("tailwindcss/index.css");
79
+ } else {
80
+ resolved = require.resolve(id, { paths: [base || __dirname] });
81
+ }
82
+ return {
83
+ content: fs.readFileSync(resolved, "utf8"),
84
+ base: path.dirname(resolved),
85
+ };
86
+ },
87
+ });
88
+ return twCompiled;
89
+ }
90
+
91
+ function transpile(jsx) {
92
+ try {
93
+ const buildResult = esbuild.buildSync({
94
+ stdin: {
95
+ contents: jsx,
96
+ resolveDir: path.join(__dirname, "../../.."),
97
+ loader: "jsx",
98
+ },
99
+ bundle: true,
100
+ format: "iife",
101
+ write: false,
102
+ target: ["es2020"],
103
+ jsx: "transform",
104
+ jsxFactory: "React.createElement",
105
+ jsxFragment: "React.Fragment",
106
+ external: ["react", "react-dom"],
107
+ define: { "process.env.NODE_ENV": '"production"' },
108
+ });
109
+ return { js: buildResult.outputFiles[0].text, error: null };
110
+ } catch (e) {
111
+ return { js: null, error: e.message };
112
+ }
113
+ }
114
+
115
+ async function generateCSS(source) {
116
+ const key = hash(source);
117
+ if (cssCache[key]) return cssCache[key];
118
+ const compiled = await getTwCompiled();
119
+ const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
120
+ const css = compiled.build(candidates);
121
+ cssCache[key] = css;
122
+ return css;
123
+ }
124
+
125
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
126
+
127
+ function isSafeName(name) {
128
+ return (
129
+ typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
130
+ );
131
+ }
132
+
133
+ function removeRoute(router, path) {
134
+ if (!router || !router.stack) return;
135
+ router.stack = router.stack.filter(
136
+ (layer) => !(layer.route && layer.route.path === path),
137
+ );
138
+ }
139
+
140
+ // ── Canvas node: shared component ─────────────────────────────
141
+
142
+ function PortalComponentNode(config) {
143
+ RED.nodes.createNode(this, config);
144
+ const node = this;
145
+ const compName = (config.compName || "").trim();
146
+
147
+ if (!isSafeName(compName)) {
148
+ node.error("Invalid component name: " + compName);
149
+ node.status({ fill: "red", shape: "dot", text: "invalid name" });
150
+ return;
151
+ }
152
+
153
+ registry[compName] = {
154
+ code: config.compCode || "",
155
+ inputs: config.compInputs
156
+ ? config.compInputs
157
+ .split(",")
158
+ .map((s) => s.trim())
159
+ .filter(Boolean)
160
+ : [],
161
+ outputs: config.compOutputs
162
+ ? config.compOutputs
163
+ .split(",")
164
+ .map((s) => s.trim())
165
+ .filter(Boolean)
166
+ : [],
167
+ };
168
+
169
+ node.status({ fill: "green", shape: "dot", text: compName });
170
+
171
+ // Trigger re-transpile on all portal-react nodes (after all nodes init)
172
+ setImmediate(() => {
173
+ Object.values(rebuildCallbacks).forEach((fn) => fn());
174
+ });
175
+
176
+ node.on("close", function (removed, done) {
177
+ delete registry[compName];
178
+ if (done) done();
179
+ });
180
+ }
181
+ RED.nodes.registerType("fc-portal-component", PortalComponentNode);
182
+
183
+ // ── Main node: portal-react ───────────────────────────────────
184
+
185
+ function PortalReactNode(config) {
186
+ RED.nodes.createNode(this, config);
187
+ const node = this;
188
+ const nodeId = node.id;
189
+
190
+ // Config
191
+ const endpoint = (config.endpoint || "/portal").replace(/\/+$/, "");
192
+ const componentCode = config.componentCode || "";
193
+ const pageTitle = config.pageTitle || "Portal";
194
+ const customHead = config.customHead || "";
195
+
196
+ // State
197
+ const clients = new Set();
198
+ let lastPayload = null;
199
+ let wsServer = null;
200
+ let isClosing = false;
201
+
202
+ const wsPath = endpoint + "/_ws";
203
+
204
+ // ── Rebuild: transpile JSX + update page state ────────────
205
+
206
+ function rebuild() {
207
+ // Topological sort: components used by others come first
208
+ const entries = Object.entries(registry);
209
+ const names = entries.map(([n]) => n);
210
+ entries.sort((a, b) => {
211
+ const aUsesB = a[1].code.includes(b[0]);
212
+ const bUsesA = b[1].code.includes(a[0]);
213
+ if (aUsesB && !bUsesA) return 1; // a depends on b → b first
214
+ if (bUsesA && !aUsesB) return -1; // b depends on a → a first
215
+ return 0;
216
+ });
217
+ const libraryJsx = entries
218
+ .map(([name, c]) =>
219
+ `// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`
220
+ )
221
+ .join("\n\n");
222
+
223
+ const fullJsx = [
224
+ "// ── React shorthand ──",
225
+ "Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
226
+ "const { createContext, memo, forwardRef, Fragment } = React;",
227
+ "",
228
+ "// ── 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
+ }`,
239
+ "",
240
+ "// ── Library components ──",
241
+ libraryJsx,
242
+ "",
243
+ "// ── View component ──",
244
+ componentCode,
245
+ "",
246
+ "// ── Mount ──",
247
+ "ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));",
248
+ ].join("\n");
249
+
250
+ const compiled = transpile(fullJsx);
251
+
252
+ if (compiled.error) {
253
+ node.error("JSX transpile error: " + compiled.error);
254
+ node.status({ fill: "red", shape: "dot", text: "transpile error" });
255
+ } else {
256
+ node.status({ fill: "grey", shape: "ring", text: endpoint });
257
+ }
258
+
259
+ const cssHashReady = !compiled.error
260
+ ? generateCSS(fullJsx)
261
+ .then((css) => {
262
+ node.status({ fill: "grey", shape: "ring", text: endpoint });
263
+ return css ? hash(fullJsx) : "";
264
+ })
265
+ .catch((err) => {
266
+ node.warn("Tailwind CSS generation failed: " + err.message);
267
+ return "";
268
+ })
269
+ : Promise.resolve("");
270
+
271
+ pageState[endpoint] = {
272
+ compiled,
273
+ cssHashReady,
274
+ pageTitle,
275
+ wsPath,
276
+ customHead,
277
+ };
278
+ }
279
+
280
+ // Register rebuild callback so library components can trigger re-transpile
281
+ rebuildCallbacks[nodeId] = rebuild;
282
+
283
+ // Delay initial build so all fc-portal-component nodes register first
284
+ setImmediate(() => {
285
+ rebuild();
286
+
287
+ // Register route only once per endpoint (persists across deploys)
288
+ if (!registeredRoutes[endpoint]) {
289
+ RED.httpNode.get(endpoint, async function (_req, res) {
290
+ const state = pageState[endpoint];
291
+ if (!state) {
292
+ res.status(404).send("Not found");
293
+ return;
294
+ }
295
+ res.set("Cache-Control", "no-store");
296
+ if (state.compiled.error) {
297
+ res
298
+ .status(500)
299
+ .type("text/html")
300
+ .send(buildErrorPage(state.pageTitle, state.compiled.error));
301
+ return;
302
+ }
303
+ const cssHash = await state.cssHashReady;
304
+ res
305
+ .type("text/html")
306
+ .send(
307
+ buildPage(
308
+ state.pageTitle,
309
+ state.compiled.js,
310
+ state.wsPath,
311
+ state.customHead,
312
+ cssHash,
313
+ ),
314
+ );
315
+ });
316
+ registeredRoutes[endpoint] = true;
317
+ }
318
+
319
+ // ── WebSocket ─────────────────────────────────────────────
320
+
321
+ try {
322
+ const WebSocket = require("ws");
323
+ wsServer = new WebSocket.Server({ noServer: true });
324
+
325
+ // Remove previous upgrade handler for this node (dirty deploy)
326
+ if (upgradeHandlers[nodeId]) {
327
+ RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
328
+ delete upgradeHandlers[nodeId];
329
+ }
330
+
331
+ const onUpgrade = function (request, socket, head) {
332
+ if (isClosing) return;
333
+ let pathname;
334
+ try {
335
+ pathname = new URL(request.url, `http://${request.headers.host}`)
336
+ .pathname;
337
+ } catch {
338
+ pathname = request.url;
339
+ }
340
+ if (pathname === wsPath) {
341
+ wsServer.handleUpgrade(request, socket, head, (ws) => {
342
+ wsServer.emit("connection", ws, request);
343
+ });
344
+ }
345
+ };
346
+
347
+ RED.server.on("upgrade", onUpgrade);
348
+ upgradeHandlers[nodeId] = onUpgrade;
349
+
350
+ wsServer.on("connection", (ws) => {
351
+ if (isClosing) {
352
+ ws.close();
353
+ return;
354
+ }
355
+ clients.add(ws);
356
+ updateStatus();
357
+
358
+ // Push current state to new client
359
+ if (lastPayload !== null) {
360
+ wsSend(ws, { type: "data", payload: lastPayload });
361
+ }
362
+
363
+ ws.on("message", (raw) => {
364
+ try {
365
+ const msg = JSON.parse(raw.toString());
366
+ if (msg.type === "output") {
367
+ node.send({
368
+ payload: msg.payload,
369
+ topic: msg.topic || "",
370
+ });
371
+ }
372
+ } catch (e) {
373
+ node.warn("Bad WS message: " + e.message);
374
+ }
375
+ });
376
+
377
+ ws.on("close", () => {
378
+ clients.delete(ws);
379
+ updateStatus();
380
+ });
381
+
382
+ ws.on("error", () => {
383
+ clients.delete(ws);
384
+ updateStatus();
385
+ });
386
+ });
387
+ } catch (e) {
388
+ node.error("WebSocket setup failed: " + e.message);
389
+ }
390
+
391
+ // ── Input handler ─────────────────────────────────────────
392
+
393
+ node.on("input", (msg, send, done) => {
394
+ lastPayload = msg.payload;
395
+ const frame = JSON.stringify({ type: "data", payload: msg.payload });
396
+ clients.forEach((ws) => {
397
+ if (ws.readyState === 1) ws.send(frame);
398
+ });
399
+ updateStatus();
400
+ if (done) done();
401
+ });
402
+
403
+ // ── Cleanup on redeploy / shutdown ────────────────────────
404
+
405
+ node.on("close", (removed, done) => {
406
+ isClosing = true;
407
+
408
+ // Remove upgrade handler
409
+ if (upgradeHandlers[nodeId]) {
410
+ RED.server.removeListener("upgrade", upgradeHandlers[nodeId]);
411
+ delete upgradeHandlers[nodeId];
412
+ }
413
+
414
+ // Close all WS clients
415
+ clients.forEach((ws) => {
416
+ try {
417
+ ws.close(1001, "node redeployed");
418
+ } catch (_) {}
419
+ });
420
+ clients.clear();
421
+
422
+ // Close WS server
423
+ if (wsServer) {
424
+ try {
425
+ wsServer.close();
426
+ } catch (_) {}
427
+ wsServer = null;
428
+ }
429
+
430
+ // Unregister rebuild callback
431
+ delete rebuildCallbacks[nodeId];
432
+
433
+ // Clean up route only when node is fully removed (not redeployed)
434
+ if (removed) {
435
+ delete pageState[endpoint];
436
+ removeRoute(RED.httpNode._router, endpoint);
437
+ delete registeredRoutes[endpoint];
438
+ }
439
+
440
+ if (done) done();
441
+ });
442
+
443
+ // ── Utilities ─────────────────────────────────────────────
444
+
445
+ function wsSend(ws, obj) {
446
+ try {
447
+ if (ws.readyState === 1) ws.send(JSON.stringify(obj));
448
+ } catch (_) {}
449
+ }
450
+
451
+ function updateStatus() {
452
+ if (isClosing) return;
453
+ const n = clients.size;
454
+ node.status({
455
+ fill: n > 0 ? "green" : "grey",
456
+ shape: n > 0 ? "dot" : "ring",
457
+ text: `${endpoint} [${n} client${n !== 1 ? "s" : ""}]`,
458
+ });
459
+ }
460
+ }); // end setImmediate
461
+ }
462
+
463
+ RED.nodes.registerType("portal-react", PortalReactNode);
464
+
465
+ // ── Serve Monaco editor files locally ────────────────────────
466
+ const express = require("express");
467
+ const monacoPath = path.dirname(
468
+ require.resolve("monaco-editor/package.json"),
469
+ );
470
+ RED.httpAdmin.use(
471
+ "/portal-react/vs",
472
+ express.static(path.join(monacoPath, "min", "vs")),
473
+ );
474
+
475
+ // ── Tailwind class list endpoint ────────────────────────────
476
+ const { generateCandidates } = require("./tw-candidates");
477
+ let twClassesCache = null;
478
+ RED.httpAdmin.get("/portal-react/tw-classes", (_req, res) => {
479
+ if (!twClassesCache) {
480
+ twClassesCache = generateCandidates();
481
+ }
482
+ res.json(twClassesCache);
483
+ });
484
+
485
+ // ── Vendor CSS endpoint (per content hash) ─────────────────
486
+ RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
487
+ const css = cssCache[req.params.hash];
488
+ if (!css) {
489
+ res.status(404).send("Not found");
490
+ return;
491
+ }
492
+ res.set({
493
+ "Content-Type": "text/css",
494
+ "Cache-Control": "public, max-age=31536000, immutable",
495
+ });
496
+ res.send(css);
497
+ });
498
+
499
+ // ── Vendor React bundle endpoint ────────────────────────────
500
+ RED.httpAdmin.get("/portal-react/vendor/react.min.js", (_req, res) => {
501
+ res.set({
502
+ "Content-Type": "application/javascript",
503
+ "Cache-Control": "public, max-age=31536000, immutable",
504
+ ETag: `"${reactHash}"`,
505
+ });
506
+ res.send(reactBundle);
507
+ });
508
+
509
+ // ── Admin API for component registry ──────────────────────────
510
+
511
+ RED.httpAdmin.get("/portal-react/registry", (_req, res) => {
512
+ res.json(registry);
513
+ });
514
+
515
+ RED.httpAdmin.post("/portal-react/registry", (req, res) => {
516
+ const { name, code, inputs, outputs } = req.body || {};
517
+ if (!isSafeName(name))
518
+ return res.status(400).json({ error: "invalid name" });
519
+ registry[name] = { code, inputs: inputs || [], outputs: outputs || [] };
520
+ res.json({ ok: true });
521
+ });
522
+
523
+ RED.httpAdmin.delete("/portal-react/registry/:name", (req, res) => {
524
+ const name = req.params.name;
525
+ if (!isSafeName(name))
526
+ return res.status(400).json({ error: "invalid name" });
527
+ delete registry[name];
528
+ res.json({ ok: true });
529
+ });
530
+
531
+ // ── Page builders ─────────────────────────────────────────────
532
+
533
+ function buildPage(title, transpiledJs, wsPath, customHead, cssHash) {
534
+ 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>`;
600
+ }
601
+
602
+ function buildErrorPage(title, error) {
603
+ 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>`;
620
+ }
621
+
622
+ function esc(s) {
623
+ return String(s)
624
+ .replace(/&/g, "&amp;")
625
+ .replace(/</g, "&lt;")
626
+ .replace(/>/g, "&gt;")
627
+ .replace(/"/g, "&quot;");
628
+ }
629
+
630
+ function escScript(s) {
631
+ return String(s).replace(/<\/(script)/gi, "<\\/$1");
632
+ }
633
+ };