@aaqu/fromcubes-portal-react 0.1.0-alpha.12 → 0.1.0-alpha.14
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 +341 -175
- 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,11 +47,15 @@ module.exports = function (RED) {
|
|
|
53
47
|
}
|
|
54
48
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
55
49
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
// Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
|
|
51
|
+
let _rebuildTimer = null;
|
|
52
|
+
function scheduleRebuildAll() {
|
|
53
|
+
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
54
|
+
_rebuildTimer = setTimeout(() => {
|
|
55
|
+
_rebuildTimer = null;
|
|
56
|
+
Object.values(rebuildCallbacks).forEach((fn) => fn());
|
|
57
|
+
}, 50);
|
|
59
58
|
}
|
|
60
|
-
const vendorCache = RED.settings.fcVendorCache;
|
|
61
59
|
|
|
62
60
|
// ── Helpers ───────────────────────────────────────────────────
|
|
63
61
|
|
|
@@ -92,136 +90,56 @@ module.exports = function (RED) {
|
|
|
92
90
|
const pkgRoot = path.join(__dirname, "..");
|
|
93
91
|
// userDir — where dynamicModuleList installs user packages
|
|
94
92
|
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
93
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function getInstalledVersion(pkgName) {
|
|
94
|
+
// Skip npm install for packages already present in node_modules (offline/Docker)
|
|
95
|
+
// https://nodered.org/docs/api/hooks/install/
|
|
96
|
+
RED.hooks.add("preInstall.fcPortal", (event) => {
|
|
104
97
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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]);
|
|
98
|
+
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
99
|
+
if (fs.existsSync(modDir)) {
|
|
100
|
+
RED.log.info(`[portal-react] ${event.module} already in node_modules, skipping install`);
|
|
101
|
+
return false;
|
|
179
102
|
}
|
|
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");
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
});
|
|
192
105
|
|
|
106
|
+
function transpile(jsx) {
|
|
193
107
|
try {
|
|
194
108
|
const buildResult = esbuild.buildSync({
|
|
195
109
|
stdin: {
|
|
196
110
|
contents: jsx,
|
|
197
|
-
resolveDir,
|
|
111
|
+
resolveDir: pkgRoot,
|
|
198
112
|
loader: "jsx",
|
|
199
113
|
},
|
|
200
114
|
bundle: true,
|
|
201
115
|
format: "iife",
|
|
116
|
+
minify: true,
|
|
202
117
|
write: false,
|
|
203
118
|
target: ["es2020"],
|
|
204
119
|
jsx: "transform",
|
|
205
120
|
jsxFactory: "React.createElement",
|
|
206
121
|
jsxFragment: "React.Fragment",
|
|
207
|
-
external: externalList,
|
|
208
122
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
209
|
-
|
|
123
|
+
metafile: true,
|
|
124
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
125
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
126
|
+
alias: {
|
|
127
|
+
"react": path.dirname(require.resolve("react/package.json", { paths: [pkgRoot] })),
|
|
128
|
+
"react-dom": path.dirname(require.resolve("react-dom/package.json", { paths: [pkgRoot] })),
|
|
129
|
+
},
|
|
210
130
|
});
|
|
211
|
-
return { js: buildResult.outputFiles[0].text, error: null };
|
|
131
|
+
return { js: buildResult.outputFiles[0].text, metafile: buildResult.metafile, error: null };
|
|
212
132
|
} catch (e) {
|
|
213
133
|
return { js: null, error: e.message };
|
|
214
134
|
}
|
|
215
135
|
}
|
|
216
136
|
|
|
217
137
|
async function generateCSS(source) {
|
|
218
|
-
const
|
|
219
|
-
if (cssCache[key]) return cssCache[key];
|
|
138
|
+
const cssHash = hash(source);
|
|
220
139
|
const compiled = await getTwCompiled();
|
|
221
140
|
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
222
141
|
const css = compiled.build(candidates);
|
|
223
|
-
|
|
224
|
-
return css;
|
|
142
|
+
return { css, cssHash };
|
|
225
143
|
}
|
|
226
144
|
|
|
227
145
|
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -291,10 +209,8 @@ module.exports = function (RED) {
|
|
|
291
209
|
|
|
292
210
|
node.status({ fill: "green", shape: "dot", text: compName });
|
|
293
211
|
|
|
294
|
-
// Trigger re-transpile on all portal-react nodes (
|
|
295
|
-
|
|
296
|
-
Object.values(rebuildCallbacks).forEach((fn) => fn());
|
|
297
|
-
});
|
|
212
|
+
// Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
|
|
213
|
+
scheduleRebuildAll();
|
|
298
214
|
|
|
299
215
|
node.on("close", function (removed, done) {
|
|
300
216
|
delete registry[compName];
|
|
@@ -320,25 +236,23 @@ module.exports = function (RED) {
|
|
|
320
236
|
const libs = config.libs || [];
|
|
321
237
|
|
|
322
238
|
// State
|
|
323
|
-
const clients = new
|
|
239
|
+
const clients = new Map(); // portalId → ws
|
|
324
240
|
let lastPayload = null;
|
|
325
241
|
let wsServer = null;
|
|
326
242
|
let isClosing = false;
|
|
327
243
|
|
|
244
|
+
if (libs.length > 0) {
|
|
245
|
+
node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
|
|
246
|
+
} else {
|
|
247
|
+
node.status({ fill: "yellow", shape: "ring", text: "starting..." });
|
|
248
|
+
}
|
|
249
|
+
|
|
328
250
|
const wsPath = nodeRoot + endpoint + "/_ws";
|
|
329
251
|
|
|
330
252
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
331
253
|
|
|
332
254
|
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
|
-
}
|
|
255
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
342
256
|
|
|
343
257
|
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
344
258
|
const allEntries = Object.entries(registry);
|
|
@@ -378,7 +292,41 @@ module.exports = function (RED) {
|
|
|
378
292
|
)
|
|
379
293
|
.join("\n\n");
|
|
380
294
|
|
|
295
|
+
// Extract import statements from library/user code so they appear at top level
|
|
296
|
+
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
297
|
+
const libImports = libraryJsx.match(importRe) || [];
|
|
298
|
+
const userImports = componentCode.match(importRe) || [];
|
|
299
|
+
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
300
|
+
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
301
|
+
|
|
302
|
+
// Warn about import * (prevents tree-shaking)
|
|
303
|
+
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
304
|
+
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
305
|
+
for (const imp of [...libImports, ...userImports]) {
|
|
306
|
+
const m = imp.match(starRe);
|
|
307
|
+
if (!m) continue;
|
|
308
|
+
const [, localName, modulePath] = m;
|
|
309
|
+
const propRe = new RegExp(`\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`, "g");
|
|
310
|
+
const props = new Set();
|
|
311
|
+
let pm;
|
|
312
|
+
while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
|
|
313
|
+
if (props.size > 0) {
|
|
314
|
+
const named = [...props].sort().join(", ");
|
|
315
|
+
node.warn(
|
|
316
|
+
`"import * as ${localName}" bundles entire ${modulePath} library. ` +
|
|
317
|
+
`For smaller builds use: import { ${named} } from '${modulePath}'`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
381
322
|
const fullJsx = [
|
|
323
|
+
"// ── Imports ──",
|
|
324
|
+
'import React from "react";',
|
|
325
|
+
'import ReactDOM from "react-dom";',
|
|
326
|
+
'import { createRoot } from "react-dom/client";',
|
|
327
|
+
...libImports,
|
|
328
|
+
...userImports,
|
|
329
|
+
"",
|
|
382
330
|
"// ── React shorthand ──",
|
|
383
331
|
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
384
332
|
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
@@ -394,62 +342,85 @@ module.exports = function (RED) {
|
|
|
394
342
|
" window.__NR.send(payload, topic);",
|
|
395
343
|
" }, []);",
|
|
396
344
|
" const user = window.__NR._user || null;",
|
|
397
|
-
"
|
|
345
|
+
" const portalClient = window.__NR._portalClient;",
|
|
346
|
+
" return { data, send, user, portalClient };",
|
|
398
347
|
"}",
|
|
399
348
|
].join("\n"),
|
|
400
349
|
"",
|
|
401
350
|
"// ── Library components ──",
|
|
402
|
-
|
|
351
|
+
cleanLibJsx,
|
|
403
352
|
"",
|
|
404
353
|
"// ── View component ──",
|
|
405
|
-
|
|
354
|
+
cleanCompCode,
|
|
406
355
|
"",
|
|
407
356
|
"// ── Mount ──",
|
|
408
|
-
"
|
|
357
|
+
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
409
358
|
].join("\n");
|
|
410
359
|
|
|
411
|
-
const compiled = transpile(fullJsx
|
|
360
|
+
const compiled = transpile(fullJsx);
|
|
412
361
|
|
|
413
362
|
if (compiled.error) {
|
|
414
363
|
node.error("JSX transpile error: " + compiled.error);
|
|
415
364
|
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
416
365
|
} else {
|
|
417
|
-
node.status({ fill: "
|
|
366
|
+
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
367
|
+
if (compiled.metafile) {
|
|
368
|
+
const output = Object.values(compiled.metafile.outputs)[0];
|
|
369
|
+
const sizes = output
|
|
370
|
+
? Object.entries(output.inputs)
|
|
371
|
+
.map(([name, info]) => ({ name: name.replace(/^.*node_modules\//, ""), bytes: info.bytesInOutput }))
|
|
372
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
373
|
+
.slice(0, 5)
|
|
374
|
+
: [];
|
|
375
|
+
const totalKB = (compiled.js.length / 1024).toFixed(1);
|
|
376
|
+
node.log(`Bundle: ${totalKB}KB — top: ${sizes.map((s) => `${s.name} (${(s.bytes / 1024).toFixed(1)}KB)`).join(", ")}`);
|
|
377
|
+
}
|
|
418
378
|
}
|
|
419
379
|
|
|
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
380
|
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
381
|
+
const prevState = pageState[endpoint];
|
|
382
|
+
const jsxHash = hash(fullJsx);
|
|
383
|
+
|
|
384
|
+
const cssReady = !compiled.error
|
|
385
|
+
? (prevState?.jsxHash === jsxHash && prevState?.css
|
|
386
|
+
? Promise.resolve({ css: prevState.css, cssHash: prevState.cssHash })
|
|
387
|
+
: generateCSS(fullJsx))
|
|
388
|
+
.catch((err) => {
|
|
389
|
+
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
390
|
+
return { css: "", cssHash: "" };
|
|
391
|
+
})
|
|
392
|
+
: Promise.resolve({ css: "", cssHash: "" });
|
|
433
393
|
|
|
434
394
|
pageState[endpoint] = {
|
|
435
395
|
compiled,
|
|
436
396
|
contentHash,
|
|
437
|
-
|
|
397
|
+
cssReady,
|
|
398
|
+
jsxHash,
|
|
399
|
+
css: null,
|
|
400
|
+
cssHash: "",
|
|
438
401
|
pageTitle,
|
|
439
402
|
wsPath,
|
|
440
403
|
customHead,
|
|
441
404
|
portalAuth,
|
|
442
405
|
showWsStatus,
|
|
443
|
-
vendorHash: vendorBundle.hash,
|
|
444
406
|
};
|
|
407
|
+
|
|
408
|
+
cssReady.then(({ css, cssHash }) => {
|
|
409
|
+
const state = pageState[endpoint];
|
|
410
|
+
if (state && state.jsxHash === jsxHash) {
|
|
411
|
+
state.css = css;
|
|
412
|
+
state.cssHash = cssHash;
|
|
413
|
+
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
445
416
|
}
|
|
446
417
|
|
|
447
418
|
// Register rebuild callback so library components can trigger re-transpile
|
|
448
419
|
rebuildCallbacks[nodeId] = rebuild;
|
|
449
420
|
|
|
450
|
-
//
|
|
421
|
+
// Initial build: debounced so all fc-portal-component nodes register first
|
|
422
|
+
scheduleRebuildAll();
|
|
451
423
|
setImmediate(() => {
|
|
452
|
-
rebuild();
|
|
453
424
|
|
|
454
425
|
// Register route only once per endpoint (persists across deploys)
|
|
455
426
|
if (!registeredRoutes[endpoint]) {
|
|
@@ -467,7 +438,7 @@ module.exports = function (RED) {
|
|
|
467
438
|
.send(buildErrorPage(state.pageTitle, state.compiled.error));
|
|
468
439
|
return;
|
|
469
440
|
}
|
|
470
|
-
const cssHash = await state.
|
|
441
|
+
const { cssHash } = await state.cssReady;
|
|
471
442
|
const user = state.portalAuth
|
|
472
443
|
? extractPortalUser(_req.headers)
|
|
473
444
|
: null;
|
|
@@ -482,7 +453,6 @@ module.exports = function (RED) {
|
|
|
482
453
|
cssHash,
|
|
483
454
|
user,
|
|
484
455
|
state.showWsStatus,
|
|
485
|
-
state.vendorHash,
|
|
486
456
|
),
|
|
487
457
|
);
|
|
488
458
|
});
|
|
@@ -525,10 +495,12 @@ module.exports = function (RED) {
|
|
|
525
495
|
ws.close();
|
|
526
496
|
return;
|
|
527
497
|
}
|
|
498
|
+
const portalClient = crypto.randomUUID();
|
|
499
|
+
ws._portalClient = portalClient;
|
|
528
500
|
if (portalAuth) {
|
|
529
501
|
ws._portalUser = extractPortalUser(request.headers);
|
|
530
502
|
}
|
|
531
|
-
clients.
|
|
503
|
+
clients.set(portalClient, ws);
|
|
532
504
|
updateStatus();
|
|
533
505
|
|
|
534
506
|
// Push current state to new client
|
|
@@ -540,6 +512,9 @@ module.exports = function (RED) {
|
|
|
540
512
|
const contentHash = pageState[endpoint]?.contentHash || "";
|
|
541
513
|
wsSend(ws, { type: "version", hash: contentHash });
|
|
542
514
|
|
|
515
|
+
// Send assigned portalClient to browser
|
|
516
|
+
wsSend(ws, { type: "hello", portalClient });
|
|
517
|
+
|
|
543
518
|
ws.on("message", (raw) => {
|
|
544
519
|
try {
|
|
545
520
|
const msg = JSON.parse(raw.toString());
|
|
@@ -548,9 +523,11 @@ module.exports = function (RED) {
|
|
|
548
523
|
payload: msg.payload,
|
|
549
524
|
topic: msg.topic || "",
|
|
550
525
|
};
|
|
526
|
+
const client = { portalClient: ws._portalClient };
|
|
551
527
|
if (portalAuth && ws._portalUser) {
|
|
552
|
-
|
|
528
|
+
Object.assign(client, ws._portalUser);
|
|
553
529
|
}
|
|
530
|
+
out._client = client;
|
|
554
531
|
node.send(out);
|
|
555
532
|
}
|
|
556
533
|
} catch (e) {
|
|
@@ -559,12 +536,12 @@ module.exports = function (RED) {
|
|
|
559
536
|
});
|
|
560
537
|
|
|
561
538
|
ws.on("close", () => {
|
|
562
|
-
clients.delete(
|
|
539
|
+
clients.delete(portalClient);
|
|
563
540
|
updateStatus();
|
|
564
541
|
});
|
|
565
542
|
|
|
566
543
|
ws.on("error", () => {
|
|
567
|
-
clients.delete(
|
|
544
|
+
clients.delete(portalClient);
|
|
568
545
|
updateStatus();
|
|
569
546
|
});
|
|
570
547
|
});
|
|
@@ -575,11 +552,33 @@ module.exports = function (RED) {
|
|
|
575
552
|
// ── Input handler ─────────────────────────────────────────
|
|
576
553
|
|
|
577
554
|
node.on("input", (msg, send, done) => {
|
|
578
|
-
|
|
555
|
+
const target = msg._client;
|
|
579
556
|
const frame = JSON.stringify({ type: "data", payload: msg.payload });
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
557
|
+
|
|
558
|
+
if (target && target.portalClient) {
|
|
559
|
+
// Target specific client by portalClient
|
|
560
|
+
const ws = clients.get(target.portalClient);
|
|
561
|
+
if (ws && ws.readyState === 1) ws.send(frame);
|
|
562
|
+
} else if (target && (target.userId || target.username)) {
|
|
563
|
+
// Target all sessions of a specific user
|
|
564
|
+
const matchId = target.userId;
|
|
565
|
+
const matchName = target.username;
|
|
566
|
+
clients.forEach((ws) => {
|
|
567
|
+
if (ws.readyState !== 1) return;
|
|
568
|
+
const u = ws._portalUser;
|
|
569
|
+
if (!u) return;
|
|
570
|
+
if ((matchId && u.userId === matchId) || (matchName && u.username === matchName)) {
|
|
571
|
+
ws.send(frame);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
} else {
|
|
575
|
+
// Broadcast to all (default)
|
|
576
|
+
lastPayload = msg.payload;
|
|
577
|
+
clients.forEach((ws) => {
|
|
578
|
+
if (ws.readyState === 1) ws.send(frame);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
583
582
|
updateStatus();
|
|
584
583
|
if (done) done();
|
|
585
584
|
});
|
|
@@ -668,9 +667,16 @@ module.exports = function (RED) {
|
|
|
668
667
|
res.json(twClassesCache);
|
|
669
668
|
});
|
|
670
669
|
|
|
671
|
-
// ── Vendor CSS endpoint (per
|
|
670
|
+
// ── Vendor CSS endpoint (per page, looked up from pageState) ─────────
|
|
672
671
|
RED.httpAdmin.get("/portal-react/css/:hash.css", (req, res) => {
|
|
673
|
-
const
|
|
672
|
+
const reqHash = req.params.hash;
|
|
673
|
+
let css = null;
|
|
674
|
+
for (const ep in pageState) {
|
|
675
|
+
if (pageState[ep]?.cssHash === reqHash) {
|
|
676
|
+
css = pageState[ep].css;
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
674
680
|
if (!css) {
|
|
675
681
|
res.status(404).send("Not found");
|
|
676
682
|
return;
|
|
@@ -682,21 +688,178 @@ module.exports = function (RED) {
|
|
|
682
688
|
res.send(css);
|
|
683
689
|
});
|
|
684
690
|
|
|
685
|
-
// ──
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
691
|
+
// ── Public assets folder ─────────────────────────────────────
|
|
692
|
+
const assetsDir = path.join(userDir, "fromcubes-public");
|
|
693
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
694
|
+
const UNSAFE_EXTS = new Set([".html", ".htm", ".svg", ".js", ".mjs", ".xml", ".xhtml"]);
|
|
695
|
+
RED.httpNode.use(
|
|
696
|
+
"/fromcubes/public",
|
|
697
|
+
(req, res, next) => {
|
|
698
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
699
|
+
res.set("Content-Security-Policy", "default-src 'none'");
|
|
700
|
+
const ext = path.extname(req.path).toLowerCase();
|
|
701
|
+
if (UNSAFE_EXTS.has(ext)) {
|
|
702
|
+
res.set("Content-Disposition", "attachment");
|
|
703
|
+
}
|
|
704
|
+
next();
|
|
705
|
+
},
|
|
706
|
+
express.static(assetsDir, { maxAge: "1d" }),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)(\.|$)/i;
|
|
710
|
+
function isSafePathSegment(s) {
|
|
711
|
+
return (
|
|
712
|
+
typeof s === "string" &&
|
|
713
|
+
s.length > 0 &&
|
|
714
|
+
s.length <= 255 &&
|
|
715
|
+
!/[\\:*?"<>|\0]/.test(s) &&
|
|
716
|
+
!s.startsWith(".") &&
|
|
717
|
+
!s.endsWith(".") && // Windows strips trailing dots
|
|
718
|
+
!s.endsWith(" ") && // Windows strips trailing spaces
|
|
719
|
+
s !== ".." &&
|
|
720
|
+
!RESERVED_NAMES.test(s)
|
|
689
721
|
);
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const MAX_PATH_DEPTH = 10;
|
|
725
|
+
function safePath(rel) {
|
|
726
|
+
if (!rel || typeof rel !== "string") return null;
|
|
727
|
+
const segments = rel.split("/").filter(Boolean);
|
|
728
|
+
if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
|
|
729
|
+
if (!segments.every(isSafePathSegment)) return null;
|
|
730
|
+
const resolved = path.resolve(assetsDir, ...segments);
|
|
731
|
+
if (!resolved.startsWith(assetsDir + path.sep) && resolved !== assetsDir)
|
|
732
|
+
return null;
|
|
733
|
+
// Symlink escape check: verify realpath stays inside assetsDir
|
|
734
|
+
try {
|
|
735
|
+
const real = fs.realpathSync(resolved);
|
|
736
|
+
if (!real.startsWith(assetsDir + path.sep) && real !== assetsDir)
|
|
737
|
+
return null;
|
|
738
|
+
} catch (_e) { /* path doesn't exist yet — OK for mkdir/upload */ }
|
|
739
|
+
return resolved;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function scanDir(dir, prefix) {
|
|
743
|
+
const results = [];
|
|
744
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
745
|
+
if (entry.isSymbolicLink()) continue; // skip symlinks for safety
|
|
746
|
+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
|
747
|
+
if (entry.isDirectory()) {
|
|
748
|
+
results.push({ name: rel, type: "dir" });
|
|
749
|
+
results.push(...scanDir(path.join(dir, entry.name), rel));
|
|
750
|
+
} else if (entry.isFile()) {
|
|
751
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
752
|
+
results.push({ name: rel, type: "file", size: stat.size, mtime: stat.mtimeMs });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return results;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
|
|
759
|
+
try {
|
|
760
|
+
res.json(scanDir(assetsDir, ""));
|
|
761
|
+
} catch (e) {
|
|
762
|
+
res.json([]);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
|
|
767
|
+
const target = safePath(req.body && req.body.path);
|
|
768
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
769
|
+
try {
|
|
770
|
+
fs.mkdirSync(target, { recursive: true });
|
|
771
|
+
res.json({ ok: true });
|
|
772
|
+
} catch (e) {
|
|
773
|
+
RED.log.error("portal-react assets mkdir: " + e.message);
|
|
774
|
+
res.status(500).json({ error: "internal error" });
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
|
|
779
|
+
const from = safePath(req.body && req.body.from);
|
|
780
|
+
const to = safePath(req.body && req.body.to);
|
|
781
|
+
if (!from || !to) return res.status(400).json({ error: "invalid path" });
|
|
782
|
+
const toName = path.basename(to);
|
|
783
|
+
if (!toName || !toName.trim()) return res.status(400).json({ error: "name cannot be empty" });
|
|
784
|
+
try {
|
|
785
|
+
const toDir = path.dirname(to);
|
|
786
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
787
|
+
fs.renameSync(from, to);
|
|
788
|
+
res.json({ ok: true });
|
|
789
|
+
} catch (e) {
|
|
790
|
+
RED.log.error("portal-react assets move: " + e.message);
|
|
791
|
+
res.status(500).json({ error: "internal error" });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const MAX_ASSETS_BYTES = 500 * 1024 * 1024; // 500 MB total
|
|
796
|
+
const MAX_ASSETS_FILES = 1000;
|
|
797
|
+
function getAssetsStats() {
|
|
798
|
+
let size = 0, count = 0;
|
|
799
|
+
function walk(dir) {
|
|
800
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
801
|
+
if (e.isSymbolicLink()) continue;
|
|
802
|
+
const p = path.join(dir, e.name);
|
|
803
|
+
if (e.isDirectory()) walk(p);
|
|
804
|
+
else if (e.isFile()) { size += fs.statSync(p).size; count++; }
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
try { walk(assetsDir); } catch (_e) { /* ignore */ }
|
|
808
|
+
return { size, count };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
RED.httpAdmin.post(
|
|
812
|
+
"/portal-react/assets/upload/*",
|
|
813
|
+
express.raw({ type: "*/*", limit: "100mb" }),
|
|
814
|
+
(req, res) => {
|
|
815
|
+
const rel = req.params[0];
|
|
816
|
+
const target = safePath(rel);
|
|
817
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
818
|
+
const stats = getAssetsStats();
|
|
819
|
+
if (stats.size + req.body.length > MAX_ASSETS_BYTES)
|
|
820
|
+
return res.status(413).json({ error: "storage limit exceeded (500MB)" });
|
|
821
|
+
if (stats.count >= MAX_ASSETS_FILES)
|
|
822
|
+
return res.status(413).json({ error: "file count limit exceeded (1000)" });
|
|
823
|
+
try {
|
|
824
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
825
|
+
fs.writeFileSync(target, req.body);
|
|
826
|
+
res.json({ ok: true });
|
|
827
|
+
} catch (e) {
|
|
828
|
+
RED.log.error("portal-react assets upload: " + e.message);
|
|
829
|
+
res.status(500).json({ error: "internal error" });
|
|
830
|
+
}
|
|
831
|
+
},
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
|
|
835
|
+
const rel = req.params[0];
|
|
836
|
+
const target = safePath(rel);
|
|
837
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
838
|
+
try {
|
|
839
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
840
|
+
res.json({ ok: true });
|
|
841
|
+
} catch (e) {
|
|
842
|
+
RED.log.error("portal-react assets delete: " + e.message);
|
|
843
|
+
res.status(404).json({ error: "not found" });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
|
|
848
|
+
const rel = req.params[0];
|
|
849
|
+
const target = safePath(rel);
|
|
850
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
851
|
+
try {
|
|
852
|
+
const stat = fs.statSync(target);
|
|
853
|
+
if (stat.isDirectory()) return res.status(400).json({ error: "is a directory" });
|
|
854
|
+
const filename = path.basename(target);
|
|
855
|
+
res.set({
|
|
856
|
+
"Content-Disposition": 'attachment; filename="' + filename.replace(/"/g, '\\"') + '"',
|
|
857
|
+
"Content-Length": stat.size,
|
|
858
|
+
});
|
|
859
|
+
fs.createReadStream(target).pipe(res);
|
|
860
|
+
} catch (e) {
|
|
861
|
+
res.status(404).json({ error: "not found" });
|
|
693
862
|
}
|
|
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
863
|
});
|
|
701
864
|
|
|
702
865
|
// ── Admin API for component registry ──────────────────────────
|
|
@@ -723,14 +886,13 @@ module.exports = function (RED) {
|
|
|
723
886
|
|
|
724
887
|
// ── Page builders ─────────────────────────────────────────────
|
|
725
888
|
|
|
726
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus
|
|
889
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
|
|
727
890
|
return `<!DOCTYPE html>
|
|
728
891
|
<html lang="en">
|
|
729
892
|
<head>
|
|
730
893
|
<meta charset="UTF-8">
|
|
731
894
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
732
895
|
<title>${esc(title)}</title>
|
|
733
|
-
<script src="${adminRoot}/portal-react/vendor/${vendorHash}.js"><\/script>
|
|
734
896
|
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
735
897
|
${escScript(customHead)}
|
|
736
898
|
${showWsStatus ? `<style>
|
|
@@ -756,6 +918,7 @@ module.exports = function (RED) {
|
|
|
756
918
|
_retries: 0,
|
|
757
919
|
_wasConnected: false,
|
|
758
920
|
_version: null,
|
|
921
|
+
_portalClient: null,
|
|
759
922
|
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
760
923
|
|
|
761
924
|
connect() {
|
|
@@ -773,6 +936,9 @@ module.exports = function (RED) {
|
|
|
773
936
|
ws.onmessage = (e) => {
|
|
774
937
|
try {
|
|
775
938
|
const m = JSON.parse(e.data);
|
|
939
|
+
if (m.type === 'hello') {
|
|
940
|
+
this._portalClient = m.portalClient;
|
|
941
|
+
}
|
|
776
942
|
if (m.type === 'version') {
|
|
777
943
|
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
778
944
|
this._version = m.hash;
|