@aaqu/fromcubes-portal-react 0.1.0-alpha.12 → 0.1.0-alpha.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -25
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +2 -28
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/portal-react.html +508 -9
- package/nodes/portal-react.js +288 -172
- package/package.json +1 -1
- package/examples/chart-portal-flow.json +0 -74
- package/examples/d3-poland-flow.json +0 -80
- package/examples/threejs-portal-flow.json +0 -61
package/nodes/portal-react.js
CHANGED
|
@@ -23,12 +23,6 @@ module.exports = function (RED) {
|
|
|
23
23
|
}
|
|
24
24
|
const registry = RED.settings.fcPortalRegistry;
|
|
25
25
|
|
|
26
|
-
// CSS cache: hash → css string
|
|
27
|
-
if (!RED.settings.fcCssCache) {
|
|
28
|
-
RED.settings.fcCssCache = {};
|
|
29
|
-
}
|
|
30
|
-
const cssCache = RED.settings.fcCssCache;
|
|
31
|
-
|
|
32
26
|
// Active upgrade handlers per node id (for cleanup on redeploy)
|
|
33
27
|
if (!RED.settings.fcUpgradeHandlers) {
|
|
34
28
|
RED.settings.fcUpgradeHandlers = {};
|
|
@@ -53,12 +47,6 @@ module.exports = function (RED) {
|
|
|
53
47
|
}
|
|
54
48
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
55
49
|
|
|
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
50
|
// ── Helpers ───────────────────────────────────────────────────
|
|
63
51
|
|
|
64
52
|
function hash(str) {
|
|
@@ -92,121 +80,31 @@ module.exports = function (RED) {
|
|
|
92
80
|
const pkgRoot = path.join(__dirname, "..");
|
|
93
81
|
// userDir — where dynamicModuleList installs user packages
|
|
94
82
|
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
83
|
|
|
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
84
|
|
|
85
|
+
function transpile(jsx) {
|
|
193
86
|
try {
|
|
194
87
|
const buildResult = esbuild.buildSync({
|
|
195
88
|
stdin: {
|
|
196
89
|
contents: jsx,
|
|
197
|
-
resolveDir,
|
|
90
|
+
resolveDir: pkgRoot,
|
|
198
91
|
loader: "jsx",
|
|
199
92
|
},
|
|
200
93
|
bundle: true,
|
|
201
94
|
format: "iife",
|
|
95
|
+
minify: true,
|
|
202
96
|
write: false,
|
|
203
97
|
target: ["es2020"],
|
|
204
98
|
jsx: "transform",
|
|
205
99
|
jsxFactory: "React.createElement",
|
|
206
100
|
jsxFragment: "React.Fragment",
|
|
207
|
-
external: externalList,
|
|
208
101
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
209
|
-
|
|
102
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
103
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
104
|
+
alias: {
|
|
105
|
+
"react": path.dirname(require.resolve("react/package.json", { paths: [pkgRoot] })),
|
|
106
|
+
"react-dom": path.dirname(require.resolve("react-dom/package.json", { paths: [pkgRoot] })),
|
|
107
|
+
},
|
|
210
108
|
});
|
|
211
109
|
return { js: buildResult.outputFiles[0].text, error: null };
|
|
212
110
|
} catch (e) {
|
|
@@ -215,13 +113,11 @@ module.exports = function (RED) {
|
|
|
215
113
|
}
|
|
216
114
|
|
|
217
115
|
async function generateCSS(source) {
|
|
218
|
-
const
|
|
219
|
-
if (cssCache[key]) return cssCache[key];
|
|
116
|
+
const cssHash = hash(source);
|
|
220
117
|
const compiled = await getTwCompiled();
|
|
221
118
|
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
222
119
|
const css = compiled.build(candidates);
|
|
223
|
-
|
|
224
|
-
return css;
|
|
120
|
+
return { css, cssHash };
|
|
225
121
|
}
|
|
226
122
|
|
|
227
123
|
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -320,25 +216,24 @@ module.exports = function (RED) {
|
|
|
320
216
|
const libs = config.libs || [];
|
|
321
217
|
|
|
322
218
|
// State
|
|
323
|
-
const clients = new
|
|
219
|
+
const clients = new Map(); // portalId → ws
|
|
324
220
|
let lastPayload = null;
|
|
325
221
|
let wsServer = null;
|
|
326
222
|
let isClosing = false;
|
|
327
223
|
|
|
224
|
+
if (libs.length > 0) {
|
|
225
|
+
const names = libs.map((l) => l.module).join(", ");
|
|
226
|
+
node.status({ fill: "blue", shape: "ring", text: `installing ${names}...` });
|
|
227
|
+
} else {
|
|
228
|
+
node.status({ fill: "yellow", shape: "ring", text: "starting..." });
|
|
229
|
+
}
|
|
230
|
+
|
|
328
231
|
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
329
232
|
|
|
330
233
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
331
234
|
|
|
332
235
|
function rebuild() {
|
|
333
|
-
|
|
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
|
-
}
|
|
236
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
342
237
|
|
|
343
238
|
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
344
239
|
const allEntries = Object.entries(registry);
|
|
@@ -378,7 +273,21 @@ module.exports = function (RED) {
|
|
|
378
273
|
)
|
|
379
274
|
.join("\n\n");
|
|
380
275
|
|
|
276
|
+
// Extract import statements from library/user code so they appear at top level
|
|
277
|
+
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
278
|
+
const libImports = libraryJsx.match(importRe) || [];
|
|
279
|
+
const userImports = componentCode.match(importRe) || [];
|
|
280
|
+
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
281
|
+
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
282
|
+
|
|
381
283
|
const fullJsx = [
|
|
284
|
+
"// ── Imports ──",
|
|
285
|
+
'import React from "react";',
|
|
286
|
+
'import ReactDOM from "react-dom";',
|
|
287
|
+
'import { createRoot } from "react-dom/client";',
|
|
288
|
+
...libImports,
|
|
289
|
+
...userImports,
|
|
290
|
+
"",
|
|
382
291
|
"// ── React shorthand ──",
|
|
383
292
|
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
384
293
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
@@ -394,54 +303,66 @@ module.exports = function (RED) {
|
|
|
394
303
|
" window.__NR.send(payload, topic);",
|
|
395
304
|
" }, []);",
|
|
396
305
|
" const user = window.__NR._user || null;",
|
|
397
|
-
"
|
|
306
|
+
" const portalClient = window.__NR._portalClient;",
|
|
307
|
+
" return { data, send, user, portalClient };",
|
|
398
308
|
"}",
|
|
399
309
|
].join("\n"),
|
|
400
310
|
"",
|
|
401
311
|
"// ── Library components ──",
|
|
402
|
-
|
|
312
|
+
cleanLibJsx,
|
|
403
313
|
"",
|
|
404
314
|
"// ── View component ──",
|
|
405
|
-
|
|
315
|
+
cleanCompCode,
|
|
406
316
|
"",
|
|
407
317
|
"// ── Mount ──",
|
|
408
|
-
"
|
|
318
|
+
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
409
319
|
].join("\n");
|
|
410
320
|
|
|
411
|
-
const compiled = transpile(fullJsx
|
|
321
|
+
const compiled = transpile(fullJsx);
|
|
412
322
|
|
|
413
323
|
if (compiled.error) {
|
|
414
324
|
node.error("JSX transpile error: " + compiled.error);
|
|
415
325
|
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
416
326
|
} else {
|
|
417
|
-
node.status({ fill: "
|
|
327
|
+
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
418
328
|
}
|
|
419
329
|
|
|
420
|
-
const cssHashReady = !compiled.error
|
|
421
|
-
? generateCSS(fullJsx)
|
|
422
|
-
.then((css) => {
|
|
423
|
-
node.status({ fill: "grey", shape: "ring", text: endpoint });
|
|
424
|
-
return css ? hash(fullJsx) : "";
|
|
425
|
-
})
|
|
426
|
-
.catch((err) => {
|
|
427
|
-
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
428
|
-
return "";
|
|
429
|
-
})
|
|
430
|
-
: Promise.resolve("");
|
|
431
|
-
|
|
432
330
|
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
331
|
+
const prevState = pageState[endpoint];
|
|
332
|
+
const jsxHash = hash(fullJsx);
|
|
333
|
+
|
|
334
|
+
const cssReady = !compiled.error
|
|
335
|
+
? (prevState?.jsxHash === jsxHash && prevState?.css
|
|
336
|
+
? Promise.resolve({ css: prevState.css, cssHash: prevState.cssHash })
|
|
337
|
+
: generateCSS(fullJsx))
|
|
338
|
+
.catch((err) => {
|
|
339
|
+
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
340
|
+
return { css: "", cssHash: "" };
|
|
341
|
+
})
|
|
342
|
+
: Promise.resolve({ css: "", cssHash: "" });
|
|
433
343
|
|
|
434
344
|
pageState[endpoint] = {
|
|
435
345
|
compiled,
|
|
436
346
|
contentHash,
|
|
437
|
-
|
|
347
|
+
cssReady,
|
|
348
|
+
jsxHash,
|
|
349
|
+
css: null,
|
|
350
|
+
cssHash: "",
|
|
438
351
|
pageTitle,
|
|
439
352
|
wsPath,
|
|
440
353
|
customHead,
|
|
441
354
|
portalAuth,
|
|
442
355
|
showWsStatus,
|
|
443
|
-
vendorHash: vendorBundle.hash,
|
|
444
356
|
};
|
|
357
|
+
|
|
358
|
+
cssReady.then(({ css, cssHash }) => {
|
|
359
|
+
const state = pageState[endpoint];
|
|
360
|
+
if (state && state.jsxHash === jsxHash) {
|
|
361
|
+
state.css = css;
|
|
362
|
+
state.cssHash = cssHash;
|
|
363
|
+
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
364
|
+
}
|
|
365
|
+
});
|
|
445
366
|
}
|
|
446
367
|
|
|
447
368
|
// Register rebuild callback so library components can trigger re-transpile
|
|
@@ -467,7 +388,7 @@ module.exports = function (RED) {
|
|
|
467
388
|
.send(buildErrorPage(state.pageTitle, state.compiled.error));
|
|
468
389
|
return;
|
|
469
390
|
}
|
|
470
|
-
const cssHash = await state.
|
|
391
|
+
const { cssHash } = await state.cssReady;
|
|
471
392
|
const user = state.portalAuth
|
|
472
393
|
? extractPortalUser(_req.headers)
|
|
473
394
|
: null;
|
|
@@ -482,7 +403,6 @@ module.exports = function (RED) {
|
|
|
482
403
|
cssHash,
|
|
483
404
|
user,
|
|
484
405
|
state.showWsStatus,
|
|
485
|
-
state.vendorHash,
|
|
486
406
|
),
|
|
487
407
|
);
|
|
488
408
|
});
|
|
@@ -525,10 +445,12 @@ module.exports = function (RED) {
|
|
|
525
445
|
ws.close();
|
|
526
446
|
return;
|
|
527
447
|
}
|
|
448
|
+
const portalClient = crypto.randomUUID();
|
|
449
|
+
ws._portalClient = portalClient;
|
|
528
450
|
if (portalAuth) {
|
|
529
451
|
ws._portalUser = extractPortalUser(request.headers);
|
|
530
452
|
}
|
|
531
|
-
clients.
|
|
453
|
+
clients.set(portalClient, ws);
|
|
532
454
|
updateStatus();
|
|
533
455
|
|
|
534
456
|
// Push current state to new client
|
|
@@ -540,6 +462,9 @@ module.exports = function (RED) {
|
|
|
540
462
|
const contentHash = pageState[endpoint]?.contentHash || "";
|
|
541
463
|
wsSend(ws, { type: "version", hash: contentHash });
|
|
542
464
|
|
|
465
|
+
// Send assigned portalClient to browser
|
|
466
|
+
wsSend(ws, { type: "hello", portalClient });
|
|
467
|
+
|
|
543
468
|
ws.on("message", (raw) => {
|
|
544
469
|
try {
|
|
545
470
|
const msg = JSON.parse(raw.toString());
|
|
@@ -548,9 +473,11 @@ module.exports = function (RED) {
|
|
|
548
473
|
payload: msg.payload,
|
|
549
474
|
topic: msg.topic || "",
|
|
550
475
|
};
|
|
476
|
+
const client = { portalClient: ws._portalClient };
|
|
551
477
|
if (portalAuth && ws._portalUser) {
|
|
552
|
-
|
|
478
|
+
Object.assign(client, ws._portalUser);
|
|
553
479
|
}
|
|
480
|
+
out._client = client;
|
|
554
481
|
node.send(out);
|
|
555
482
|
}
|
|
556
483
|
} catch (e) {
|
|
@@ -559,12 +486,12 @@ module.exports = function (RED) {
|
|
|
559
486
|
});
|
|
560
487
|
|
|
561
488
|
ws.on("close", () => {
|
|
562
|
-
clients.delete(
|
|
489
|
+
clients.delete(portalClient);
|
|
563
490
|
updateStatus();
|
|
564
491
|
});
|
|
565
492
|
|
|
566
493
|
ws.on("error", () => {
|
|
567
|
-
clients.delete(
|
|
494
|
+
clients.delete(portalClient);
|
|
568
495
|
updateStatus();
|
|
569
496
|
});
|
|
570
497
|
});
|
|
@@ -575,11 +502,33 @@ module.exports = function (RED) {
|
|
|
575
502
|
// ── Input handler ─────────────────────────────────────────
|
|
576
503
|
|
|
577
504
|
node.on("input", (msg, send, done) => {
|
|
578
|
-
|
|
505
|
+
const target = msg._client;
|
|
579
506
|
const frame = JSON.stringify({ type: "data", payload: msg.payload });
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
507
|
+
|
|
508
|
+
if (target && target.portalClient) {
|
|
509
|
+
// Target specific client by portalClient
|
|
510
|
+
const ws = clients.get(target.portalClient);
|
|
511
|
+
if (ws && ws.readyState === 1) ws.send(frame);
|
|
512
|
+
} else if (target && (target.userId || target.username)) {
|
|
513
|
+
// Target all sessions of a specific user
|
|
514
|
+
const matchId = target.userId;
|
|
515
|
+
const matchName = target.username;
|
|
516
|
+
clients.forEach((ws) => {
|
|
517
|
+
if (ws.readyState !== 1) return;
|
|
518
|
+
const u = ws._portalUser;
|
|
519
|
+
if (!u) return;
|
|
520
|
+
if ((matchId && u.userId === matchId) || (matchName && u.username === matchName)) {
|
|
521
|
+
ws.send(frame);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
} else {
|
|
525
|
+
// Broadcast to all (default)
|
|
526
|
+
lastPayload = msg.payload;
|
|
527
|
+
clients.forEach((ws) => {
|
|
528
|
+
if (ws.readyState === 1) ws.send(frame);
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
583
532
|
updateStatus();
|
|
584
533
|
if (done) done();
|
|
585
534
|
});
|
|
@@ -668,9 +617,16 @@ module.exports = function (RED) {
|
|
|
668
617
|
res.json(twClassesCache);
|
|
669
618
|
});
|
|
670
619
|
|
|
671
|
-
// ── Vendor CSS endpoint (per
|
|
620
|
+
// ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
|
|
672
621
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
673
|
-
const
|
|
622
|
+
const reqHash = req.params.hash;
|
|
623
|
+
let css = null;
|
|
624
|
+
for (const ep in pageState) {
|
|
625
|
+
if (pageState[ep]?.cssHash === reqHash) {
|
|
626
|
+
css = pageState[ep].css;
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
674
630
|
if (!css) {
|
|
675
631
|
res.status(404).send("Not found");
|
|
676
632
|
return;
|
|
@@ -682,21 +638,178 @@ module.exports = function (RED) {
|
|
|
682
638
|
res.send(css);
|
|
683
639
|
});
|
|
684
640
|
|
|
685
|
-
// ──
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
641
|
+
// ── Public assets folder ─────────────────────────────────────
|
|
642
|
+
const assetsDir = path.join(userDir, "fromcubes-public");
|
|
643
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
644
|
+
const UNSAFE_EXTS = new Set([".html", ".htm", ".svg", ".js", ".mjs", ".xml", ".xhtml"]);
|
|
645
|
+
RED.httpNode.use(
|
|
646
|
+
"/fromcubes/public",
|
|
647
|
+
(req, res, next) => {
|
|
648
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
649
|
+
res.set("Content-Security-Policy", "default-src 'none'");
|
|
650
|
+
const ext = path.extname(req.path).toLowerCase();
|
|
651
|
+
if (UNSAFE_EXTS.has(ext)) {
|
|
652
|
+
res.set("Content-Disposition", "attachment");
|
|
653
|
+
}
|
|
654
|
+
next();
|
|
655
|
+
},
|
|
656
|
+
express.static(assetsDir, { maxAge: "1d" }),
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)(\.|$)/i;
|
|
660
|
+
function isSafePathSegment(s) {
|
|
661
|
+
return (
|
|
662
|
+
typeof s === "string" &&
|
|
663
|
+
s.length > 0 &&
|
|
664
|
+
s.length <= 255 &&
|
|
665
|
+
!/[\\:*?"<>|\0]/.test(s) &&
|
|
666
|
+
!s.startsWith(".") &&
|
|
667
|
+
!s.endsWith(".") && // Windows strips trailing dots
|
|
668
|
+
!s.endsWith(" ") && // Windows strips trailing spaces
|
|
669
|
+
s !== ".." &&
|
|
670
|
+
!RESERVED_NAMES.test(s)
|
|
689
671
|
);
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const MAX_PATH_DEPTH = 10;
|
|
675
|
+
function safePath(rel) {
|
|
676
|
+
if (!rel || typeof rel !== "string") return null;
|
|
677
|
+
const segments = rel.split("/").filter(Boolean);
|
|
678
|
+
if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
|
|
679
|
+
if (!segments.every(isSafePathSegment)) return null;
|
|
680
|
+
const resolved = path.resolve(assetsDir, ...segments);
|
|
681
|
+
if (!resolved.startsWith(assetsDir + path.sep) && resolved !== assetsDir)
|
|
682
|
+
return null;
|
|
683
|
+
// Symlink escape check: verify realpath stays inside assetsDir
|
|
684
|
+
try {
|
|
685
|
+
const real = fs.realpathSync(resolved);
|
|
686
|
+
if (!real.startsWith(assetsDir + path.sep) && real !== assetsDir)
|
|
687
|
+
return null;
|
|
688
|
+
} catch (_e) { /* path doesn't exist yet — OK for mkdir/upload */ }
|
|
689
|
+
return resolved;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function scanDir(dir, prefix) {
|
|
693
|
+
const results = [];
|
|
694
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
695
|
+
if (entry.isSymbolicLink()) continue; // skip symlinks for safety
|
|
696
|
+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
|
697
|
+
if (entry.isDirectory()) {
|
|
698
|
+
results.push({ name: rel, type: "dir" });
|
|
699
|
+
results.push(...scanDir(path.join(dir, entry.name), rel));
|
|
700
|
+
} else if (entry.isFile()) {
|
|
701
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
702
|
+
results.push({ name: rel, type: "file", size: stat.size, mtime: stat.mtimeMs });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return results;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
|
|
709
|
+
try {
|
|
710
|
+
res.json(scanDir(assetsDir, ""));
|
|
711
|
+
} catch (e) {
|
|
712
|
+
res.json([]);
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
|
|
717
|
+
const target = safePath(req.body && req.body.path);
|
|
718
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
719
|
+
try {
|
|
720
|
+
fs.mkdirSync(target, { recursive: true });
|
|
721
|
+
res.json({ ok: true });
|
|
722
|
+
} catch (e) {
|
|
723
|
+
RED.log.error("portal-react assets mkdir: " + e.message);
|
|
724
|
+
res.status(500).json({ error: "internal error" });
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
|
|
729
|
+
const from = safePath(req.body && req.body.from);
|
|
730
|
+
const to = safePath(req.body && req.body.to);
|
|
731
|
+
if (!from || !to) return res.status(400).json({ error: "invalid path" });
|
|
732
|
+
const toName = path.basename(to);
|
|
733
|
+
if (!toName || !toName.trim()) return res.status(400).json({ error: "name cannot be empty" });
|
|
734
|
+
try {
|
|
735
|
+
const toDir = path.dirname(to);
|
|
736
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
737
|
+
fs.renameSync(from, to);
|
|
738
|
+
res.json({ ok: true });
|
|
739
|
+
} catch (e) {
|
|
740
|
+
RED.log.error("portal-react assets move: " + e.message);
|
|
741
|
+
res.status(500).json({ error: "internal error" });
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
const MAX_ASSETS_BYTES = 500 * 1024 * 1024; // 500 MB total
|
|
746
|
+
const MAX_ASSETS_FILES = 1000;
|
|
747
|
+
function getAssetsStats() {
|
|
748
|
+
let size = 0, count = 0;
|
|
749
|
+
function walk(dir) {
|
|
750
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
751
|
+
if (e.isSymbolicLink()) continue;
|
|
752
|
+
const p = path.join(dir, e.name);
|
|
753
|
+
if (e.isDirectory()) walk(p);
|
|
754
|
+
else if (e.isFile()) { size += fs.statSync(p).size; count++; }
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
try { walk(assetsDir); } catch (_e) { /* ignore */ }
|
|
758
|
+
return { size, count };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
RED.httpAdmin.post(
|
|
762
|
+
"/portal-react/assets/upload/*",
|
|
763
|
+
express.raw({ type: "*/*", limit: "100mb" }),
|
|
764
|
+
(req, res) => {
|
|
765
|
+
const rel = req.params[0];
|
|
766
|
+
const target = safePath(rel);
|
|
767
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
768
|
+
const stats = getAssetsStats();
|
|
769
|
+
if (stats.size + req.body.length > MAX_ASSETS_BYTES)
|
|
770
|
+
return res.status(413).json({ error: "storage limit exceeded (500MB)" });
|
|
771
|
+
if (stats.count >= MAX_ASSETS_FILES)
|
|
772
|
+
return res.status(413).json({ error: "file count limit exceeded (1000)" });
|
|
773
|
+
try {
|
|
774
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
775
|
+
fs.writeFileSync(target, req.body);
|
|
776
|
+
res.json({ ok: true });
|
|
777
|
+
} catch (e) {
|
|
778
|
+
RED.log.error("portal-react assets upload: " + e.message);
|
|
779
|
+
res.status(500).json({ error: "internal error" });
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
|
|
785
|
+
const rel = req.params[0];
|
|
786
|
+
const target = safePath(rel);
|
|
787
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
788
|
+
try {
|
|
789
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
790
|
+
res.json({ ok: true });
|
|
791
|
+
} catch (e) {
|
|
792
|
+
RED.log.error("portal-react assets delete: " + e.message);
|
|
793
|
+
res.status(404).json({ error: "not found" });
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
|
|
798
|
+
const rel = req.params[0];
|
|
799
|
+
const target = safePath(rel);
|
|
800
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
801
|
+
try {
|
|
802
|
+
const stat = fs.statSync(target);
|
|
803
|
+
if (stat.isDirectory()) return res.status(400).json({ error: "is a directory" });
|
|
804
|
+
const filename = path.basename(target);
|
|
805
|
+
res.set({
|
|
806
|
+
"Content-Disposition": 'attachment; filename="' + filename.replace(/"/g, '\\"') + '"',
|
|
807
|
+
"Content-Length": stat.size,
|
|
808
|
+
});
|
|
809
|
+
fs.createReadStream(target).pipe(res);
|
|
810
|
+
} catch (e) {
|
|
811
|
+
res.status(404).json({ error: "not found" });
|
|
693
812
|
}
|
|
694
|
-
res.set({
|
|
695
|
-
"Content-Type": "application/javascript",
|
|
696
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
697
|
-
ETag: `"${req.params.hash}"`,
|
|
698
|
-
});
|
|
699
|
-
res.send(entry.js);
|
|
700
813
|
});
|
|
701
814
|
|
|
702
815
|
// ── Admin API for component registry ──────────────────────────
|
|
@@ -723,14 +836,13 @@ module.exports = function (RED) {
|
|
|
723
836
|
|
|
724
837
|
// ── Page builders ─────────────────────────────────────────────
|
|
725
838
|
|
|
726
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus
|
|
839
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
|
|
727
840
|
return `<!DOCTYPE html>
|
|
728
841
|
<html lang="en">
|
|
729
842
|
<head>
|
|
730
843
|
<meta charset="UTF-8">
|
|
731
844
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
732
845
|
<title>${esc(title)}</title>
|
|
733
|
-
<script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
|
|
734
846
|
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
735
847
|
${escScript(customHead)}
|
|
736
848
|
${showWsStatus ? `<style>
|
|
@@ -756,6 +868,7 @@ module.exports = function (RED) {
|
|
|
756
868
|
_retries: 0,
|
|
757
869
|
_wasConnected: false,
|
|
758
870
|
_version: null,
|
|
871
|
+
_portalClient: null,
|
|
759
872
|
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
760
873
|
|
|
761
874
|
connect() {
|
|
@@ -773,6 +886,9 @@ module.exports = function (RED) {
|
|
|
773
886
|
ws.onmessage = (e) => {
|
|
774
887
|
try {
|
|
775
888
|
const m = JSON.parse(e.data);
|
|
889
|
+
if (m.type === 'hello') {
|
|
890
|
+
this._portalClient = m.portalClient;
|
|
891
|
+
}
|
|
776
892
|
if (m.type === 'version') {
|
|
777
893
|
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
778
894
|
this._version = m.hash;
|