@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.
- package/LICENSE +190 -0
- package/README.md +120 -0
- package/examples/sensor-portal-flow.json +71 -0
- package/nodes/portal-react.html +1059 -0
- package/nodes/portal-react.js +633 -0
- package/nodes/tw-candidates.js +976 -0
- package/nodes/vendor/react-19.production.min.js +55 -0
- package/package.json +49 -0
- package/scripts/bundle-react.js +31 -0
|
@@ -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, "&")
|
|
625
|
+
.replace(/</g, "<")
|
|
626
|
+
.replace(/>/g, ">")
|
|
627
|
+
.replace(/"/g, """);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function escScript(s) {
|
|
631
|
+
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
632
|
+
}
|
|
633
|
+
};
|