@aaqu/fromcubes-portal-react 0.1.0-alpha.13 → 0.1.0-alpha.15
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/examples/004-d3-poland-flow.json +1 -1
- package/examples/007-webgpu-tsl-flow.json +1 -1
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +270 -0
- package/nodes/lib/page-builder.js +210 -0
- package/nodes/portal-react.html +23 -34
- package/nodes/portal-react.js +426 -578
- package/package.json +7 -3
package/nodes/portal-react.js
CHANGED
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const crypto = require("crypto");
|
|
10
|
-
const fs = require("fs");
|
|
11
10
|
const path = require("path");
|
|
12
|
-
const esbuild = require("esbuild");
|
|
13
11
|
|
|
14
12
|
module.exports = function (RED) {
|
|
15
13
|
// ── Admin root prefix (for correct URLs when httpAdminRoot is set) ──
|
|
@@ -47,114 +45,55 @@ module.exports = function (RED) {
|
|
|
47
45
|
}
|
|
48
46
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
49
47
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const twCompile = require("tailwindcss").compile;
|
|
57
|
-
const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
|
|
58
|
-
|
|
59
|
-
let twCompiled = null;
|
|
60
|
-
async function getTwCompiled() {
|
|
61
|
-
if (twCompiled) return twCompiled;
|
|
62
|
-
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
63
|
-
loadStylesheet: async (id, base) => {
|
|
64
|
-
let resolved;
|
|
65
|
-
if (id === "tailwindcss") {
|
|
66
|
-
resolved = require.resolve("tailwindcss/index.css");
|
|
67
|
-
} else {
|
|
68
|
-
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
content: fs.readFileSync(resolved, "utf8"),
|
|
72
|
-
base: path.dirname(resolved),
|
|
73
|
-
};
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
return twCompiled;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Package root — where react/react-dom live (this package's own node_modules)
|
|
80
|
-
const pkgRoot = path.join(__dirname, "..");
|
|
81
|
-
// userDir — where dynamicModuleList installs user packages
|
|
82
|
-
const userDir = RED.settings.userDir || path.join(__dirname, "../../..");
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
function transpile(jsx) {
|
|
86
|
-
try {
|
|
87
|
-
const buildResult = esbuild.buildSync({
|
|
88
|
-
stdin: {
|
|
89
|
-
contents: jsx,
|
|
90
|
-
resolveDir: pkgRoot,
|
|
91
|
-
loader: "jsx",
|
|
92
|
-
},
|
|
93
|
-
bundle: true,
|
|
94
|
-
format: "iife",
|
|
95
|
-
minify: true,
|
|
96
|
-
write: false,
|
|
97
|
-
target: ["es2020"],
|
|
98
|
-
jsx: "transform",
|
|
99
|
-
jsxFactory: "React.createElement",
|
|
100
|
-
jsxFragment: "React.Fragment",
|
|
101
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
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
|
-
},
|
|
108
|
-
});
|
|
109
|
-
return { js: buildResult.outputFiles[0].text, error: null };
|
|
110
|
-
} catch (e) {
|
|
111
|
-
return { js: null, error: e.message };
|
|
112
|
-
}
|
|
48
|
+
// Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
|
|
49
|
+
if (!RED.settings.fcEndpointOwners) {
|
|
50
|
+
RED.settings.fcEndpointOwners = {};
|
|
113
51
|
}
|
|
52
|
+
const endpointOwners = RED.settings.fcEndpointOwners;
|
|
114
53
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
119
|
-
const css = compiled.build(candidates);
|
|
120
|
-
return { css, cssHash };
|
|
54
|
+
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
55
|
+
if (!RED.settings.fcCompNameOwners) {
|
|
56
|
+
RED.settings.fcCompNameOwners = {};
|
|
121
57
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (headers["x-portal-user-email"])
|
|
139
|
-
user.email = headers["x-portal-user-email"];
|
|
140
|
-
if (headers["x-portal-user-role"])
|
|
141
|
-
user.role = headers["x-portal-user-role"];
|
|
142
|
-
if (headers["x-portal-user-groups"]) {
|
|
143
|
-
try {
|
|
144
|
-
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
145
|
-
} catch (_) {
|
|
146
|
-
user.groups = headers["x-portal-user-groups"];
|
|
58
|
+
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
59
|
+
|
|
60
|
+
// Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
|
|
61
|
+
// Yields event loop between builds so HTTP server stays responsive
|
|
62
|
+
let _rebuildTimer = null;
|
|
63
|
+
function scheduleRebuildAll() {
|
|
64
|
+
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
65
|
+
_rebuildTimer = setTimeout(() => {
|
|
66
|
+
_rebuildTimer = null;
|
|
67
|
+
const fns = Object.values(rebuildCallbacks);
|
|
68
|
+
let i = 0;
|
|
69
|
+
function next() {
|
|
70
|
+
if (i >= fns.length) return;
|
|
71
|
+
try { fns[i](); } catch (e) { RED.log.error("[portal-react] rebuild failed: " + e.message); }
|
|
72
|
+
i++;
|
|
73
|
+
if (i < fns.length) setImmediate(next);
|
|
147
74
|
}
|
|
148
|
-
|
|
149
|
-
|
|
75
|
+
next();
|
|
76
|
+
}, 50);
|
|
150
77
|
}
|
|
151
78
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
79
|
+
// ── Load modules ─────────────────────────────────────────────
|
|
80
|
+
const helpers = require("./lib/helpers")(RED);
|
|
81
|
+
const {
|
|
82
|
+
hash,
|
|
83
|
+
transpile,
|
|
84
|
+
generateCSS,
|
|
85
|
+
extractPortalUser,
|
|
86
|
+
removeRoute,
|
|
87
|
+
isSafeName,
|
|
88
|
+
userDir,
|
|
89
|
+
readCachedJS,
|
|
90
|
+
writeCachedJS,
|
|
91
|
+
readCachedCSS,
|
|
92
|
+
writeCachedCSS,
|
|
93
|
+
deleteCacheFiles,
|
|
94
|
+
isHashInUse,
|
|
95
|
+
} = helpers;
|
|
96
|
+
const { buildPage, buildErrorPage } = require("./lib/page-builder");
|
|
158
97
|
|
|
159
98
|
// ── Canvas node: shared component ─────────────────────────────
|
|
160
99
|
|
|
@@ -169,30 +108,37 @@ module.exports = function (RED) {
|
|
|
169
108
|
return;
|
|
170
109
|
}
|
|
171
110
|
|
|
111
|
+
// Duplicate component name check
|
|
112
|
+
const existingOwner = compNameOwners[compName];
|
|
113
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
114
|
+
node.error(
|
|
115
|
+
`Component name "${compName}" is already used by another node`,
|
|
116
|
+
);
|
|
117
|
+
node.status({
|
|
118
|
+
fill: "red",
|
|
119
|
+
shape: "ring",
|
|
120
|
+
text: "duplicate: " + compName,
|
|
121
|
+
});
|
|
122
|
+
node.on("close", function (_removed, done) {
|
|
123
|
+
if (done) done();
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
compNameOwners[compName] = node.id;
|
|
128
|
+
|
|
172
129
|
registry[compName] = {
|
|
173
130
|
code: config.compCode || "",
|
|
174
|
-
inputs: config.compInputs
|
|
175
|
-
? config.compInputs
|
|
176
|
-
.split(",")
|
|
177
|
-
.map((s) => s.trim())
|
|
178
|
-
.filter(Boolean)
|
|
179
|
-
: [],
|
|
180
|
-
outputs: config.compOutputs
|
|
181
|
-
? config.compOutputs
|
|
182
|
-
.split(",")
|
|
183
|
-
.map((s) => s.trim())
|
|
184
|
-
.filter(Boolean)
|
|
185
|
-
: [],
|
|
186
131
|
};
|
|
187
132
|
|
|
188
133
|
node.status({ fill: "green", shape: "dot", text: compName });
|
|
189
134
|
|
|
190
|
-
// Trigger re-transpile on all portal-react nodes (
|
|
191
|
-
|
|
192
|
-
Object.values(rebuildCallbacks).forEach((fn) => fn());
|
|
193
|
-
});
|
|
135
|
+
// Trigger re-transpile on all portal-react nodes (debounced across all component registrations)
|
|
136
|
+
scheduleRebuildAll();
|
|
194
137
|
|
|
195
138
|
node.on("close", function (removed, done) {
|
|
139
|
+
if (compNameOwners[compName] === node.id) {
|
|
140
|
+
delete compNameOwners[compName];
|
|
141
|
+
}
|
|
196
142
|
delete registry[compName];
|
|
197
143
|
if (done) done();
|
|
198
144
|
});
|
|
@@ -215,15 +161,33 @@ module.exports = function (RED) {
|
|
|
215
161
|
const showWsStatus = config.showWsStatus === true;
|
|
216
162
|
const libs = config.libs || [];
|
|
217
163
|
|
|
164
|
+
// ── Duplicate endpoint check ──
|
|
165
|
+
const existingOwner = endpointOwners[endpoint];
|
|
166
|
+
if (existingOwner && existingOwner !== nodeId) {
|
|
167
|
+
node.error(
|
|
168
|
+
`Endpoint "${endpoint}" is already used by another portal node`,
|
|
169
|
+
);
|
|
170
|
+
node.status({
|
|
171
|
+
fill: "red",
|
|
172
|
+
shape: "ring",
|
|
173
|
+
text: "duplicate: " + endpoint,
|
|
174
|
+
});
|
|
175
|
+
node.on("close", function (_removed, done) {
|
|
176
|
+
if (done) done();
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
endpointOwners[endpoint] = nodeId;
|
|
181
|
+
|
|
218
182
|
// State
|
|
219
183
|
const clients = new Map(); // portalId → ws
|
|
220
184
|
let lastPayload = null;
|
|
221
185
|
let wsServer = null;
|
|
222
186
|
let isClosing = false;
|
|
187
|
+
let lastJsxHash = null;
|
|
223
188
|
|
|
224
189
|
if (libs.length > 0) {
|
|
225
|
-
|
|
226
|
-
node.status({ fill: "blue", shape: "ring", text: `installing ${names}...` });
|
|
190
|
+
node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
|
|
227
191
|
} else {
|
|
228
192
|
node.status({ fill: "yellow", shape: "ring", text: "starting..." });
|
|
229
193
|
}
|
|
@@ -233,178 +197,349 @@ module.exports = function (RED) {
|
|
|
233
197
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
234
198
|
|
|
235
199
|
function rebuild() {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
200
|
+
try {
|
|
201
|
+
node.status({ fill: "yellow", shape: "dot", text: "building..." });
|
|
202
|
+
|
|
203
|
+
// ── Pre-build: clear cache, set building state, notify browsers ──
|
|
204
|
+
const prevState = pageState[endpoint];
|
|
205
|
+
const prevHash = prevState?.jsxHash;
|
|
206
|
+
if (prevHash && !isHashInUse(prevHash, pageState, endpoint)) {
|
|
207
|
+
deleteCacheFiles(prevHash);
|
|
208
|
+
}
|
|
209
|
+
pageState[endpoint] = { building: true, wsPath, pageTitle };
|
|
210
|
+
clients.forEach((ws) => {
|
|
211
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "building" })); } catch (_) {}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Selective injection: only include components referenced in user code (+ transitive deps)
|
|
215
|
+
const allEntries = Object.entries(registry);
|
|
216
|
+
const needed = new Set();
|
|
217
|
+
|
|
218
|
+
function addWithDeps(name) {
|
|
219
|
+
if (needed.has(name)) return;
|
|
220
|
+
const entry = registry[name];
|
|
221
|
+
if (!entry) return;
|
|
222
|
+
needed.add(name);
|
|
223
|
+
for (const [other] of allEntries) {
|
|
224
|
+
if (other !== name && entry.code.includes(other)) {
|
|
225
|
+
addWithDeps(other);
|
|
226
|
+
}
|
|
250
227
|
}
|
|
251
228
|
}
|
|
252
|
-
}
|
|
253
229
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
230
|
+
for (const [name] of allEntries) {
|
|
231
|
+
if (componentCode.includes(name)) {
|
|
232
|
+
addWithDeps(name);
|
|
233
|
+
}
|
|
257
234
|
}
|
|
258
|
-
}
|
|
259
235
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
" const user = window.__NR._user || null;",
|
|
306
|
-
" const portalClient = window.__NR._portalClient;",
|
|
307
|
-
" return { data, send, user, portalClient };",
|
|
308
|
-
"}",
|
|
309
|
-
].join("\n"),
|
|
310
|
-
"",
|
|
311
|
-
"// ── Library components ──",
|
|
312
|
-
cleanLibJsx,
|
|
313
|
-
"",
|
|
314
|
-
"// ── View component ──",
|
|
315
|
-
cleanCompCode,
|
|
316
|
-
"",
|
|
317
|
-
"// ── Mount ──",
|
|
318
|
-
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
319
|
-
].join("\n");
|
|
320
|
-
|
|
321
|
-
const compiled = transpile(fullJsx);
|
|
322
|
-
|
|
323
|
-
if (compiled.error) {
|
|
324
|
-
node.error("JSX transpile error: " + compiled.error);
|
|
325
|
-
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
326
|
-
} else {
|
|
327
|
-
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
328
|
-
}
|
|
236
|
+
// Topological sort only needed components
|
|
237
|
+
const entries = allEntries.filter(([n]) => needed.has(n));
|
|
238
|
+
entries.sort((a, b) => {
|
|
239
|
+
const aUsesB = a[1].code.includes(b[0]);
|
|
240
|
+
const bUsesA = b[1].code.includes(a[0]);
|
|
241
|
+
if (aUsesB && !bUsesA) return 1; // a depends on b → b first
|
|
242
|
+
if (bUsesA && !aUsesB) return -1; // b depends on a → a first
|
|
243
|
+
return 0;
|
|
244
|
+
});
|
|
245
|
+
const libraryJsx = entries
|
|
246
|
+
.map(
|
|
247
|
+
([name, c]) =>
|
|
248
|
+
`// Library: ${name}\nconst ${name} = (() => {\n${c.code}\nreturn ${name};\n})();`,
|
|
249
|
+
)
|
|
250
|
+
.join("\n\n");
|
|
251
|
+
|
|
252
|
+
// Extract import statements from library/user code so they appear at top level
|
|
253
|
+
const importRe = /^import\s+.+?from\s+['"].+?['"];?\s*$/gm;
|
|
254
|
+
const libImports = libraryJsx.match(importRe) || [];
|
|
255
|
+
const userImports = componentCode.match(importRe) || [];
|
|
256
|
+
const cleanLibJsx = libraryJsx.replace(importRe, "").trim();
|
|
257
|
+
const cleanCompCode = componentCode.replace(importRe, "").trim();
|
|
258
|
+
|
|
259
|
+
// Warn about import * (prevents tree-shaking)
|
|
260
|
+
const starRe = /^import\s+\*\s+as\s+(\w+)\s+from\s+['"](.+?)['"];?\s*$/;
|
|
261
|
+
const allCode = cleanLibJsx + "\n" + cleanCompCode;
|
|
262
|
+
for (const imp of [...libImports, ...userImports]) {
|
|
263
|
+
const m = imp.match(starRe);
|
|
264
|
+
if (!m) continue;
|
|
265
|
+
const [, localName, modulePath] = m;
|
|
266
|
+
const propRe = new RegExp(
|
|
267
|
+
`\\b${localName}\\s*\\??\\s*\\.\\s*(\\w+)`,
|
|
268
|
+
"g",
|
|
269
|
+
);
|
|
270
|
+
const props = new Set();
|
|
271
|
+
let pm;
|
|
272
|
+
while ((pm = propRe.exec(allCode)) !== null) props.add(pm[1]);
|
|
273
|
+
if (props.size > 0) {
|
|
274
|
+
const named = [...props].sort().join(", ");
|
|
275
|
+
node.warn(
|
|
276
|
+
`"import * as ${localName}" bundles entire ${modulePath} library. ` +
|
|
277
|
+
`For smaller builds use: import { ${named} } from '${modulePath}'`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
329
281
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
282
|
+
const fullJsx = [
|
|
283
|
+
"// ── Imports ──",
|
|
284
|
+
'import React from "react";',
|
|
285
|
+
'import ReactDOM from "react-dom";',
|
|
286
|
+
'import { createRoot } from "react-dom/client";',
|
|
287
|
+
...libImports,
|
|
288
|
+
...userImports,
|
|
289
|
+
"",
|
|
290
|
+
"// ── React shorthand ──",
|
|
291
|
+
"Object.keys(React).filter(k => /^use[A-Z]/.test(k)).forEach(k => { window[k] = React[k]; });",
|
|
292
|
+
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
293
|
+
"",
|
|
294
|
+
"// ── useNodeRed hook ──",
|
|
295
|
+
[
|
|
296
|
+
"function useNodeRed() {",
|
|
297
|
+
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
298
|
+
" React.useEffect(() => {",
|
|
299
|
+
" return window.__NR.subscribe(setData);",
|
|
300
|
+
" }, []);",
|
|
301
|
+
" const send = React.useCallback((payload, topic) => {",
|
|
302
|
+
" window.__NR.send(payload, topic);",
|
|
303
|
+
" }, []);",
|
|
304
|
+
" const user = window.__NR._user || null;",
|
|
305
|
+
" const portalClient = window.__NR._portalClient;",
|
|
306
|
+
" return { data, send, user, portalClient };",
|
|
307
|
+
"}",
|
|
308
|
+
].join("\n"),
|
|
309
|
+
"",
|
|
310
|
+
"// ── Library components ──",
|
|
311
|
+
cleanLibJsx,
|
|
312
|
+
"",
|
|
313
|
+
"// ── View component ──",
|
|
314
|
+
cleanCompCode,
|
|
315
|
+
"",
|
|
316
|
+
"// ── Mount ──",
|
|
317
|
+
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
318
|
+
].join("\n");
|
|
319
|
+
|
|
320
|
+
// ── Check: missing return in App ──
|
|
321
|
+
const appFnMatch = cleanCompCode.match(/function\s+App\s*\([^)]*\)\s*\{/);
|
|
322
|
+
if (appFnMatch) {
|
|
323
|
+
let depth = 1, i = appFnMatch.index + appFnMatch[0].length;
|
|
324
|
+
let hasReturn = false;
|
|
325
|
+
while (i < cleanCompCode.length && depth > 0) {
|
|
326
|
+
const ch = cleanCompCode[i];
|
|
327
|
+
if (ch === "{") depth++;
|
|
328
|
+
else if (ch === "}") depth--;
|
|
329
|
+
else if (cleanCompCode.slice(i, i + 7) === "return ") hasReturn = true;
|
|
330
|
+
i++;
|
|
331
|
+
}
|
|
332
|
+
if (!hasReturn) {
|
|
333
|
+
node.error("App component has no return statement");
|
|
334
|
+
node.status({ fill: "red", shape: "dot", text: "missing return" });
|
|
335
|
+
const missingReturnError = {
|
|
336
|
+
js: null,
|
|
337
|
+
error: "App component has no return statement.\n\nAdd a return with JSX, e.g.:\n\nfunction App() {\n return <div>Hello</div>\n}",
|
|
338
|
+
};
|
|
339
|
+
pageState[endpoint] = {
|
|
340
|
+
compiled: missingReturnError,
|
|
341
|
+
contentHash: "",
|
|
342
|
+
cssReady: Promise.resolve({ css: "", cssHash: "" }),
|
|
343
|
+
jsxHash: "",
|
|
344
|
+
css: null,
|
|
345
|
+
cssHash: "",
|
|
346
|
+
pageTitle,
|
|
347
|
+
wsPath,
|
|
348
|
+
customHead,
|
|
349
|
+
portalAuth,
|
|
350
|
+
showWsStatus,
|
|
351
|
+
};
|
|
352
|
+
clients.forEach((ws) => {
|
|
353
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: missingReturnError.error })); } catch (_) {}
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
364
357
|
}
|
|
365
|
-
|
|
358
|
+
|
|
359
|
+
const jsxHash = hash(fullJsx);
|
|
360
|
+
|
|
361
|
+
// ── JS: disk cache → transpile ──
|
|
362
|
+
let compiled = readCachedJS(jsxHash);
|
|
363
|
+
let cacheHit = !!compiled;
|
|
364
|
+
if (!compiled) {
|
|
365
|
+
compiled = transpile(fullJsx);
|
|
366
|
+
if (!compiled.error) {
|
|
367
|
+
writeCachedJS(jsxHash, compiled.js, compiled.metafile);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (compiled.error) {
|
|
372
|
+
node.error("JSX transpile error: " + compiled.error);
|
|
373
|
+
RED.log.warn(
|
|
374
|
+
`[portal-react] ${endpoint} — JSX transpile error: ${compiled.error}`,
|
|
375
|
+
);
|
|
376
|
+
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
377
|
+
clients.forEach((ws) => {
|
|
378
|
+
try { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "error", message: compiled.error })); } catch (_) {}
|
|
379
|
+
});
|
|
380
|
+
} else {
|
|
381
|
+
node.status({
|
|
382
|
+
fill: "green",
|
|
383
|
+
shape: "dot",
|
|
384
|
+
text: `built • ${endpoint}`,
|
|
385
|
+
});
|
|
386
|
+
if (compiled.metafile) {
|
|
387
|
+
const output = Object.values(compiled.metafile.outputs)[0];
|
|
388
|
+
const sizes = output
|
|
389
|
+
? Object.entries(output.inputs)
|
|
390
|
+
.map(([name, info]) => ({
|
|
391
|
+
name: name.replace(/^.*node_modules\//, ""),
|
|
392
|
+
bytes: info.bytesInOutput,
|
|
393
|
+
}))
|
|
394
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
395
|
+
.slice(0, 5)
|
|
396
|
+
: [];
|
|
397
|
+
const totalKB = (compiled.js.length / 1024).toFixed(1);
|
|
398
|
+
node.log(
|
|
399
|
+
`Bundle${cacheHit ? " (cached)" : ""}: ${totalKB}KB — top: ${sizes.map((s) => `${s.name} (${(s.bytes / 1024).toFixed(1)}KB)`).join(", ")}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const contentHash = compiled.js ? hash(compiled.js) : "";
|
|
405
|
+
|
|
406
|
+
// ── CSS: disk cache → in-memory → generate ──
|
|
407
|
+
const cssReady = !compiled.error
|
|
408
|
+
? (() => {
|
|
409
|
+
const cachedCSS = readCachedCSS(jsxHash);
|
|
410
|
+
if (cachedCSS) return Promise.resolve(cachedCSS);
|
|
411
|
+
if (prevState?.jsxHash === jsxHash && prevState?.css) {
|
|
412
|
+
return Promise.resolve({
|
|
413
|
+
css: prevState.css,
|
|
414
|
+
cssHash: prevState.cssHash,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return generateCSS(fullJsx).then(({ css, cssHash }) => {
|
|
418
|
+
writeCachedCSS(jsxHash, css);
|
|
419
|
+
return { css, cssHash };
|
|
420
|
+
});
|
|
421
|
+
})().catch((err) => {
|
|
422
|
+
node.warn("Tailwind CSS generation failed: " + err.message);
|
|
423
|
+
return { css: "", cssHash: "" };
|
|
424
|
+
})
|
|
425
|
+
: Promise.resolve({ css: "", cssHash: "" });
|
|
426
|
+
|
|
427
|
+
lastJsxHash = jsxHash;
|
|
428
|
+
|
|
429
|
+
pageState[endpoint] = {
|
|
430
|
+
compiled,
|
|
431
|
+
contentHash,
|
|
432
|
+
cssReady,
|
|
433
|
+
jsxHash,
|
|
434
|
+
css: null,
|
|
435
|
+
cssHash: "",
|
|
436
|
+
pageTitle,
|
|
437
|
+
wsPath,
|
|
438
|
+
customHead,
|
|
439
|
+
portalAuth,
|
|
440
|
+
showWsStatus,
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// Notify all connected browsers that build finished — triggers reload or overlay cleanup
|
|
444
|
+
if (!compiled.error && contentHash) {
|
|
445
|
+
const versionFrame = JSON.stringify({ type: "version", hash: contentHash });
|
|
446
|
+
clients.forEach((ws) => {
|
|
447
|
+
try { if (ws.readyState === 1) ws.send(versionFrame); } catch (_) {}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
cssReady.then(({ css, cssHash }) => {
|
|
452
|
+
const state = pageState[endpoint];
|
|
453
|
+
if (state && state.jsxHash === jsxHash) {
|
|
454
|
+
state.css = css;
|
|
455
|
+
state.cssHash = cssHash;
|
|
456
|
+
node.status({
|
|
457
|
+
fill: "green",
|
|
458
|
+
shape: "dot",
|
|
459
|
+
text: `built • ${endpoint}`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
} catch (e) {
|
|
464
|
+
node.error("Rebuild failed: " + e.message);
|
|
465
|
+
node.status({ fill: "red", shape: "dot", text: "rebuild error" });
|
|
466
|
+
}
|
|
366
467
|
}
|
|
367
468
|
|
|
368
469
|
// Register rebuild callback so library components can trigger re-transpile
|
|
369
470
|
rebuildCallbacks[nodeId] = rebuild;
|
|
370
471
|
|
|
371
|
-
//
|
|
472
|
+
// Initial build: debounced so all fc-portal-component nodes register first
|
|
473
|
+
scheduleRebuildAll();
|
|
372
474
|
setImmediate(() => {
|
|
373
|
-
rebuild();
|
|
374
|
-
|
|
375
475
|
// Register route only once per endpoint (persists across deploys)
|
|
376
476
|
if (!registeredRoutes[endpoint]) {
|
|
377
477
|
RED.httpNode.get(endpoint, async function (_req, res) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
478
|
+
try {
|
|
479
|
+
const state = pageState[endpoint];
|
|
480
|
+
if (!state || state.building) {
|
|
481
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
482
|
+
res
|
|
483
|
+
.set("Cache-Control", "no-store")
|
|
484
|
+
.set("Refresh", "3")
|
|
485
|
+
.type("text/html")
|
|
486
|
+
.send(
|
|
487
|
+
`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Building\u2026</title><style>@keyframes __sp{to{transform:rotate(360deg)}}body{font-family:monospace;background:#111;color:#888;margin:0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center}</style></head><body><div style="font-size:24px;margin-bottom:16px">Building\u2026</div><div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div><script>(function(){var r=0;function c(){var p=location.protocol==='https:'?'wss:':'ws:';var ws=new WebSocket(p+'//'+location.host+'${bWsPath}');ws.onmessage=function(e){try{var m=JSON.parse(e.data);if(m.type==='version'||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){var d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`,
|
|
488
|
+
);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
res.set("Cache-Control", "no-store");
|
|
492
|
+
if (state.compiled.error) {
|
|
493
|
+
res
|
|
494
|
+
.status(500)
|
|
495
|
+
.type("text/html")
|
|
496
|
+
.send(
|
|
497
|
+
buildErrorPage(
|
|
498
|
+
state.pageTitle,
|
|
499
|
+
state.compiled.error,
|
|
500
|
+
state.wsPath,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const { cssHash } = await Promise.race([
|
|
506
|
+
state.cssReady,
|
|
507
|
+
new Promise((_, reject) =>
|
|
508
|
+
setTimeout(
|
|
509
|
+
() => reject(new Error("CSS generation timeout")),
|
|
510
|
+
15000,
|
|
511
|
+
),
|
|
512
|
+
),
|
|
513
|
+
]);
|
|
514
|
+
const user = state.portalAuth
|
|
515
|
+
? extractPortalUser(_req.headers)
|
|
516
|
+
: null;
|
|
517
|
+
res
|
|
518
|
+
.type("text/html")
|
|
519
|
+
.send(
|
|
520
|
+
buildPage(
|
|
521
|
+
state.pageTitle,
|
|
522
|
+
state.compiled.js,
|
|
523
|
+
state.wsPath,
|
|
524
|
+
state.customHead,
|
|
525
|
+
cssHash,
|
|
526
|
+
user,
|
|
527
|
+
state.showWsStatus,
|
|
528
|
+
adminRoot,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
} catch (e) {
|
|
385
532
|
res
|
|
386
533
|
.status(500)
|
|
387
534
|
.type("text/html")
|
|
388
|
-
.send(
|
|
389
|
-
|
|
535
|
+
.send(
|
|
536
|
+
buildErrorPage(
|
|
537
|
+
pageTitle,
|
|
538
|
+
"Page build failed: " + e.message,
|
|
539
|
+
wsPath,
|
|
540
|
+
),
|
|
541
|
+
);
|
|
390
542
|
}
|
|
391
|
-
const { cssHash } = await state.cssReady;
|
|
392
|
-
const user = state.portalAuth
|
|
393
|
-
? extractPortalUser(_req.headers)
|
|
394
|
-
: null;
|
|
395
|
-
res
|
|
396
|
-
.type("text/html")
|
|
397
|
-
.send(
|
|
398
|
-
buildPage(
|
|
399
|
-
state.pageTitle,
|
|
400
|
-
state.compiled.js,
|
|
401
|
-
state.wsPath,
|
|
402
|
-
state.customHead,
|
|
403
|
-
cssHash,
|
|
404
|
-
user,
|
|
405
|
-
state.showWsStatus,
|
|
406
|
-
),
|
|
407
|
-
);
|
|
408
543
|
});
|
|
409
544
|
registeredRoutes[endpoint] = true;
|
|
410
545
|
}
|
|
@@ -517,7 +652,10 @@ module.exports = function (RED) {
|
|
|
517
652
|
if (ws.readyState !== 1) return;
|
|
518
653
|
const u = ws._portalUser;
|
|
519
654
|
if (!u) return;
|
|
520
|
-
if (
|
|
655
|
+
if (
|
|
656
|
+
(matchId && u.userId === matchId) ||
|
|
657
|
+
(matchName && u.username === matchName)
|
|
658
|
+
) {
|
|
521
659
|
ws.send(frame);
|
|
522
660
|
}
|
|
523
661
|
});
|
|
@@ -563,8 +701,17 @@ module.exports = function (RED) {
|
|
|
563
701
|
// Unregister rebuild callback
|
|
564
702
|
delete rebuildCallbacks[nodeId];
|
|
565
703
|
|
|
704
|
+
// Release endpoint ownership
|
|
705
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
706
|
+
delete endpointOwners[endpoint];
|
|
707
|
+
}
|
|
708
|
+
|
|
566
709
|
// Clean up route only when node is fully removed (not redeployed)
|
|
567
710
|
if (removed) {
|
|
711
|
+
// Delete disk cache if no other endpoint uses this hash
|
|
712
|
+
if (lastJsxHash && !isHashInUse(lastJsxHash, pageState, endpoint)) {
|
|
713
|
+
deleteCacheFiles(lastJsxHash);
|
|
714
|
+
}
|
|
568
715
|
delete pageState[endpoint];
|
|
569
716
|
removeRoute(RED.httpNode._router, endpoint);
|
|
570
717
|
delete registeredRoutes[endpoint];
|
|
@@ -639,178 +786,8 @@ module.exports = function (RED) {
|
|
|
639
786
|
});
|
|
640
787
|
|
|
641
788
|
// ── Public assets folder ─────────────────────────────────────
|
|
642
|
-
const
|
|
643
|
-
|
|
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)
|
|
671
|
-
);
|
|
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" });
|
|
812
|
-
}
|
|
813
|
-
});
|
|
789
|
+
const { registerAssets } = require("./lib/assets");
|
|
790
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
|
|
814
791
|
|
|
815
792
|
// ── Admin API for component registry ──────────────────────────
|
|
816
793
|
|
|
@@ -819,10 +796,10 @@ module.exports = function (RED) {
|
|
|
819
796
|
});
|
|
820
797
|
|
|
821
798
|
RED.httpAdmin.post("/portal-react/registry", (req, res) => {
|
|
822
|
-
const { name, code
|
|
799
|
+
const { name, code } = req.body || {};
|
|
823
800
|
if (!isSafeName(name))
|
|
824
801
|
return res.status(400).json({ error: "invalid name" });
|
|
825
|
-
registry[name] = { code
|
|
802
|
+
registry[name] = { code: code || "" };
|
|
826
803
|
res.json({ ok: true });
|
|
827
804
|
});
|
|
828
805
|
|
|
@@ -833,133 +810,4 @@ module.exports = function (RED) {
|
|
|
833
810
|
delete registry[name];
|
|
834
811
|
res.json({ ok: true });
|
|
835
812
|
});
|
|
836
|
-
|
|
837
|
-
// ── Page builders ─────────────────────────────────────────────
|
|
838
|
-
|
|
839
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
|
|
840
|
-
return `<!DOCTYPE html>
|
|
841
|
-
<html lang="en">
|
|
842
|
-
<head>
|
|
843
|
-
<meta charset="UTF-8">
|
|
844
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
845
|
-
<title>${esc(title)}</title>
|
|
846
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
847
|
-
${escScript(customHead)}
|
|
848
|
-
${showWsStatus ? `<style>
|
|
849
|
-
#__cs {
|
|
850
|
-
position: fixed; bottom: 6px; right: 6px;
|
|
851
|
-
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
852
|
-
z-index: 99999; background: #111; border: 1px solid #333;
|
|
853
|
-
opacity: .7; transition: opacity .2s;
|
|
854
|
-
}
|
|
855
|
-
#__cs:hover { opacity: 1 }
|
|
856
|
-
#__cs.ok { color: #4ade80 }
|
|
857
|
-
#__cs.err { color: #f87171 }
|
|
858
|
-
</style>` : ""}
|
|
859
|
-
</head>
|
|
860
|
-
<body>
|
|
861
|
-
<div id="root"></div>
|
|
862
|
-
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
863
|
-
<script>
|
|
864
|
-
window.__NR = {
|
|
865
|
-
_ws: null,
|
|
866
|
-
_listeners: new Set(),
|
|
867
|
-
_lastData: null,
|
|
868
|
-
_retries: 0,
|
|
869
|
-
_wasConnected: false,
|
|
870
|
-
_version: null,
|
|
871
|
-
_portalClient: null,
|
|
872
|
-
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
873
|
-
|
|
874
|
-
connect() {
|
|
875
|
-
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
876
|
-
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
877
|
-
this._ws = ws;
|
|
878
|
-
const s = document.getElementById('__cs');
|
|
879
|
-
|
|
880
|
-
ws.onopen = () => {
|
|
881
|
-
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
882
|
-
this._retries = 0;
|
|
883
|
-
this._wasConnected = true;
|
|
884
|
-
};
|
|
885
|
-
|
|
886
|
-
ws.onmessage = (e) => {
|
|
887
|
-
try {
|
|
888
|
-
const m = JSON.parse(e.data);
|
|
889
|
-
if (m.type === 'hello') {
|
|
890
|
-
this._portalClient = m.portalClient;
|
|
891
|
-
}
|
|
892
|
-
if (m.type === 'version') {
|
|
893
|
-
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
894
|
-
this._version = m.hash;
|
|
895
|
-
}
|
|
896
|
-
if (m.type === 'data') {
|
|
897
|
-
this._lastData = m.payload;
|
|
898
|
-
this._listeners.forEach(fn => fn(m.payload));
|
|
899
|
-
}
|
|
900
|
-
} catch (err) { console.error('WS parse', err); }
|
|
901
|
-
};
|
|
902
|
-
|
|
903
|
-
ws.onclose = () => {
|
|
904
|
-
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
905
|
-
this._ws = null;
|
|
906
|
-
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
907
|
-
this._retries++;
|
|
908
|
-
setTimeout(() => this.connect(), delay);
|
|
909
|
-
};
|
|
910
|
-
|
|
911
|
-
ws.onerror = () => ws.close();
|
|
912
|
-
},
|
|
913
|
-
|
|
914
|
-
subscribe(fn) {
|
|
915
|
-
this._listeners.add(fn);
|
|
916
|
-
if (this._lastData !== null) fn(this._lastData);
|
|
917
|
-
return () => this._listeners.delete(fn);
|
|
918
|
-
},
|
|
919
|
-
|
|
920
|
-
send(payload, topic) {
|
|
921
|
-
if (this._ws && this._ws.readyState === 1)
|
|
922
|
-
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
923
|
-
}
|
|
924
|
-
};
|
|
925
|
-
window.__NR.connect();
|
|
926
|
-
<\/script>
|
|
927
|
-
<script>
|
|
928
|
-
${escScript(transpiledJs)}
|
|
929
|
-
<\/script>
|
|
930
|
-
</body>
|
|
931
|
-
</html>`;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
function buildErrorPage(title, error) {
|
|
935
|
-
return `<!DOCTYPE html>
|
|
936
|
-
<html lang="en">
|
|
937
|
-
<head>
|
|
938
|
-
<meta charset="UTF-8">
|
|
939
|
-
<title>${esc(title)} — Error</title>
|
|
940
|
-
<style>
|
|
941
|
-
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
942
|
-
h1 { color: #ff4444; margin-bottom: 16px }
|
|
943
|
-
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
944
|
-
</style>
|
|
945
|
-
</head>
|
|
946
|
-
<body>
|
|
947
|
-
<h1>JSX Transpile Error</h1>
|
|
948
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
949
|
-
<pre>${esc(error)}</pre>
|
|
950
|
-
</body>
|
|
951
|
-
</html>`;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function esc(s) {
|
|
955
|
-
return String(s)
|
|
956
|
-
.replace(/&/g, "&")
|
|
957
|
-
.replace(/</g, "<")
|
|
958
|
-
.replace(/>/g, ">")
|
|
959
|
-
.replace(/"/g, """);
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function escScript(s) {
|
|
963
|
-
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
964
|
-
}
|
|
965
813
|
};
|