@aaqu/fromcubes-portal-react 0.1.0-alpha.14 → 0.1.0-alpha.16
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/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 +414 -616
- 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,136 +45,55 @@ module.exports = function (RED) {
|
|
|
47
45
|
}
|
|
48
46
|
const rebuildCallbacks = RED.settings.fcRebuildCallbacks;
|
|
49
47
|
|
|
48
|
+
// Track endpoint ownership: { endpoint: nodeId } — prevents duplicate endpoints
|
|
49
|
+
if (!RED.settings.fcEndpointOwners) {
|
|
50
|
+
RED.settings.fcEndpointOwners = {};
|
|
51
|
+
}
|
|
52
|
+
const endpointOwners = RED.settings.fcEndpointOwners;
|
|
53
|
+
|
|
54
|
+
// Track component name ownership: { compName: nodeId } — prevents duplicate component names
|
|
55
|
+
if (!RED.settings.fcCompNameOwners) {
|
|
56
|
+
RED.settings.fcCompNameOwners = {};
|
|
57
|
+
}
|
|
58
|
+
const compNameOwners = RED.settings.fcCompNameOwners;
|
|
59
|
+
|
|
50
60
|
// Debounced rebuild-all: coalesces multiple component registrations into one rebuild pass
|
|
61
|
+
// Yields event loop between builds so HTTP server stays responsive
|
|
51
62
|
let _rebuildTimer = null;
|
|
52
63
|
function scheduleRebuildAll() {
|
|
53
64
|
if (_rebuildTimer) clearTimeout(_rebuildTimer);
|
|
54
65
|
_rebuildTimer = setTimeout(() => {
|
|
55
66
|
_rebuildTimer = null;
|
|
56
|
-
Object.values(rebuildCallbacks)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const twCompile = require("tailwindcss").compile;
|
|
67
|
-
const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
|
|
68
|
-
|
|
69
|
-
let twCompiled = null;
|
|
70
|
-
async function getTwCompiled() {
|
|
71
|
-
if (twCompiled) return twCompiled;
|
|
72
|
-
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
73
|
-
loadStylesheet: async (id, base) => {
|
|
74
|
-
let resolved;
|
|
75
|
-
if (id === "tailwindcss") {
|
|
76
|
-
resolved = require.resolve("tailwindcss/index.css");
|
|
77
|
-
} else {
|
|
78
|
-
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
79
|
-
}
|
|
80
|
-
return {
|
|
81
|
-
content: fs.readFileSync(resolved, "utf8"),
|
|
82
|
-
base: path.dirname(resolved),
|
|
83
|
-
};
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
return twCompiled;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Package root — where react/react-dom live (this package's own node_modules)
|
|
90
|
-
const pkgRoot = path.join(__dirname, "..");
|
|
91
|
-
// userDir — where dynamicModuleList installs user packages
|
|
92
|
-
const userDir = RED.settings.userDir || path.join(__dirname, "../../..");
|
|
93
|
-
|
|
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) => {
|
|
97
|
-
try {
|
|
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;
|
|
102
|
-
}
|
|
103
|
-
} catch (_) {}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
function transpile(jsx) {
|
|
107
|
-
try {
|
|
108
|
-
const buildResult = esbuild.buildSync({
|
|
109
|
-
stdin: {
|
|
110
|
-
contents: jsx,
|
|
111
|
-
resolveDir: pkgRoot,
|
|
112
|
-
loader: "jsx",
|
|
113
|
-
},
|
|
114
|
-
bundle: true,
|
|
115
|
-
format: "iife",
|
|
116
|
-
minify: true,
|
|
117
|
-
write: false,
|
|
118
|
-
target: ["es2020"],
|
|
119
|
-
jsx: "transform",
|
|
120
|
-
jsxFactory: "React.createElement",
|
|
121
|
-
jsxFragment: "React.Fragment",
|
|
122
|
-
define: { "process.env.NODE_ENV": '"production"' },
|
|
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
|
-
},
|
|
130
|
-
});
|
|
131
|
-
return { js: buildResult.outputFiles[0].text, metafile: buildResult.metafile, error: null };
|
|
132
|
-
} catch (e) {
|
|
133
|
-
return { js: null, error: e.message };
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function generateCSS(source) {
|
|
138
|
-
const cssHash = hash(source);
|
|
139
|
-
const compiled = await getTwCompiled();
|
|
140
|
-
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
141
|
-
const css = compiled.build(candidates);
|
|
142
|
-
return { css, cssHash };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
146
|
-
|
|
147
|
-
function isSafeName(name) {
|
|
148
|
-
return (
|
|
149
|
-
typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function extractPortalUser(headers) {
|
|
154
|
-
const user = {};
|
|
155
|
-
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
156
|
-
if (headers["x-portal-user-name"])
|
|
157
|
-
user.userName = headers["x-portal-user-name"];
|
|
158
|
-
if (headers["x-portal-user-username"])
|
|
159
|
-
user.username = headers["x-portal-user-username"];
|
|
160
|
-
if (headers["x-portal-user-email"])
|
|
161
|
-
user.email = headers["x-portal-user-email"];
|
|
162
|
-
if (headers["x-portal-user-role"])
|
|
163
|
-
user.role = headers["x-portal-user-role"];
|
|
164
|
-
if (headers["x-portal-user-groups"]) {
|
|
165
|
-
try {
|
|
166
|
-
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
167
|
-
} catch (_) {
|
|
168
|
-
user.groups = headers["x-portal-user-groups"];
|
|
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);
|
|
169
74
|
}
|
|
170
|
-
|
|
171
|
-
|
|
75
|
+
next();
|
|
76
|
+
}, 50);
|
|
172
77
|
}
|
|
173
78
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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");
|
|
180
97
|
|
|
181
98
|
// ── Canvas node: shared component ─────────────────────────────
|
|
182
99
|
|
|
@@ -191,20 +108,26 @@ module.exports = function (RED) {
|
|
|
191
108
|
return;
|
|
192
109
|
}
|
|
193
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
|
+
|
|
194
129
|
registry[compName] = {
|
|
195
130
|
code: config.compCode || "",
|
|
196
|
-
inputs: config.compInputs
|
|
197
|
-
? config.compInputs
|
|
198
|
-
.split(",")
|
|
199
|
-
.map((s) => s.trim())
|
|
200
|
-
.filter(Boolean)
|
|
201
|
-
: [],
|
|
202
|
-
outputs: config.compOutputs
|
|
203
|
-
? config.compOutputs
|
|
204
|
-
.split(",")
|
|
205
|
-
.map((s) => s.trim())
|
|
206
|
-
.filter(Boolean)
|
|
207
|
-
: [],
|
|
208
131
|
};
|
|
209
132
|
|
|
210
133
|
node.status({ fill: "green", shape: "dot", text: compName });
|
|
@@ -213,6 +136,9 @@ module.exports = function (RED) {
|
|
|
213
136
|
scheduleRebuildAll();
|
|
214
137
|
|
|
215
138
|
node.on("close", function (removed, done) {
|
|
139
|
+
if (compNameOwners[compName] === node.id) {
|
|
140
|
+
delete compNameOwners[compName];
|
|
141
|
+
}
|
|
216
142
|
delete registry[compName];
|
|
217
143
|
if (done) done();
|
|
218
144
|
});
|
|
@@ -235,11 +161,30 @@ module.exports = function (RED) {
|
|
|
235
161
|
const showWsStatus = config.showWsStatus === true;
|
|
236
162
|
const libs = config.libs || [];
|
|
237
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
|
+
|
|
238
182
|
// State
|
|
239
183
|
const clients = new Map(); // portalId → ws
|
|
240
184
|
let lastPayload = null;
|
|
241
185
|
let wsServer = null;
|
|
242
186
|
let isClosing = false;
|
|
187
|
+
let lastJsxHash = null;
|
|
243
188
|
|
|
244
189
|
if (libs.length > 0) {
|
|
245
190
|
node.status({ fill: "blue", shape: "ring", text: "loading libs..." });
|
|
@@ -252,167 +197,273 @@ module.exports = function (RED) {
|
|
|
252
197
|
// ── Rebuild: transpile JSX + update page state ────────────
|
|
253
198
|
|
|
254
199
|
function rebuild() {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
}
|
|
269
227
|
}
|
|
270
228
|
}
|
|
271
|
-
}
|
|
272
229
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
230
|
+
for (const [name] of allEntries) {
|
|
231
|
+
if (componentCode.includes(name)) {
|
|
232
|
+
addWithDeps(name);
|
|
233
|
+
}
|
|
276
234
|
}
|
|
277
|
-
}
|
|
278
235
|
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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}'`,
|
|
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",
|
|
318
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
|
+
}
|
|
281
|
+
|
|
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
|
+
}
|
|
319
357
|
}
|
|
320
|
-
}
|
|
321
358
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
"const { createContext, memo, forwardRef, Fragment } = React;",
|
|
333
|
-
"",
|
|
334
|
-
"// ── useNodeRed hook ──",
|
|
335
|
-
[
|
|
336
|
-
"function useNodeRed() {",
|
|
337
|
-
" const [data, setData] = React.useState(window.__NR._lastData);",
|
|
338
|
-
" React.useEffect(() => {",
|
|
339
|
-
" return window.__NR.subscribe(setData);",
|
|
340
|
-
" }, []);",
|
|
341
|
-
" const send = React.useCallback((payload, topic) => {",
|
|
342
|
-
" window.__NR.send(payload, topic);",
|
|
343
|
-
" }, []);",
|
|
344
|
-
" const user = window.__NR._user || null;",
|
|
345
|
-
" const portalClient = window.__NR._portalClient;",
|
|
346
|
-
" return { data, send, user, portalClient };",
|
|
347
|
-
"}",
|
|
348
|
-
].join("\n"),
|
|
349
|
-
"",
|
|
350
|
-
"// ── Library components ──",
|
|
351
|
-
cleanLibJsx,
|
|
352
|
-
"",
|
|
353
|
-
"// ── View component ──",
|
|
354
|
-
cleanCompCode,
|
|
355
|
-
"",
|
|
356
|
-
"// ── Mount ──",
|
|
357
|
-
"createRoot(document.getElementById('root')).render(React.createElement(App));",
|
|
358
|
-
].join("\n");
|
|
359
|
-
|
|
360
|
-
const compiled = transpile(fullJsx);
|
|
361
|
-
|
|
362
|
-
if (compiled.error) {
|
|
363
|
-
node.error("JSX transpile error: " + compiled.error);
|
|
364
|
-
node.status({ fill: "red", shape: "dot", text: "transpile error" });
|
|
365
|
-
} else {
|
|
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(", ")}`);
|
|
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
|
+
}
|
|
377
369
|
}
|
|
378
|
-
}
|
|
379
370
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
state.css = css;
|
|
412
|
-
state.cssHash = cssHash;
|
|
413
|
-
node.status({ fill: "green", shape: "dot", text: `built • ${endpoint}` });
|
|
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
|
+
}
|
|
414
402
|
}
|
|
415
|
-
|
|
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
|
+
}
|
|
416
467
|
}
|
|
417
468
|
|
|
418
469
|
// Register rebuild callback so library components can trigger re-transpile
|
|
@@ -421,40 +472,74 @@ module.exports = function (RED) {
|
|
|
421
472
|
// Initial build: debounced so all fc-portal-component nodes register first
|
|
422
473
|
scheduleRebuildAll();
|
|
423
474
|
setImmediate(() => {
|
|
424
|
-
|
|
425
475
|
// Register route only once per endpoint (persists across deploys)
|
|
426
476
|
if (!registeredRoutes[endpoint]) {
|
|
427
477
|
RED.httpNode.get(endpoint, async function (_req, res) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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) {
|
|
435
532
|
res
|
|
436
533
|
.status(500)
|
|
437
534
|
.type("text/html")
|
|
438
|
-
.send(
|
|
439
|
-
|
|
535
|
+
.send(
|
|
536
|
+
buildErrorPage(
|
|
537
|
+
pageTitle,
|
|
538
|
+
"Page build failed: " + e.message,
|
|
539
|
+
wsPath,
|
|
540
|
+
),
|
|
541
|
+
);
|
|
440
542
|
}
|
|
441
|
-
const { cssHash } = await state.cssReady;
|
|
442
|
-
const user = state.portalAuth
|
|
443
|
-
? extractPortalUser(_req.headers)
|
|
444
|
-
: null;
|
|
445
|
-
res
|
|
446
|
-
.type("text/html")
|
|
447
|
-
.send(
|
|
448
|
-
buildPage(
|
|
449
|
-
state.pageTitle,
|
|
450
|
-
state.compiled.js,
|
|
451
|
-
state.wsPath,
|
|
452
|
-
state.customHead,
|
|
453
|
-
cssHash,
|
|
454
|
-
user,
|
|
455
|
-
state.showWsStatus,
|
|
456
|
-
),
|
|
457
|
-
);
|
|
458
543
|
});
|
|
459
544
|
registeredRoutes[endpoint] = true;
|
|
460
545
|
}
|
|
@@ -567,7 +652,10 @@ module.exports = function (RED) {
|
|
|
567
652
|
if (ws.readyState !== 1) return;
|
|
568
653
|
const u = ws._portalUser;
|
|
569
654
|
if (!u) return;
|
|
570
|
-
if (
|
|
655
|
+
if (
|
|
656
|
+
(matchId && u.userId === matchId) ||
|
|
657
|
+
(matchName && u.username === matchName)
|
|
658
|
+
) {
|
|
571
659
|
ws.send(frame);
|
|
572
660
|
}
|
|
573
661
|
});
|
|
@@ -613,8 +701,17 @@ module.exports = function (RED) {
|
|
|
613
701
|
// Unregister rebuild callback
|
|
614
702
|
delete rebuildCallbacks[nodeId];
|
|
615
703
|
|
|
704
|
+
// Release endpoint ownership
|
|
705
|
+
if (endpointOwners[endpoint] === nodeId) {
|
|
706
|
+
delete endpointOwners[endpoint];
|
|
707
|
+
}
|
|
708
|
+
|
|
616
709
|
// Clean up route only when node is fully removed (not redeployed)
|
|
617
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
|
+
}
|
|
618
715
|
delete pageState[endpoint];
|
|
619
716
|
removeRoute(RED.httpNode._router, endpoint);
|
|
620
717
|
delete registeredRoutes[endpoint];
|
|
@@ -689,178 +786,8 @@ module.exports = function (RED) {
|
|
|
689
786
|
});
|
|
690
787
|
|
|
691
788
|
// ── Public assets folder ─────────────────────────────────────
|
|
692
|
-
const
|
|
693
|
-
|
|
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)
|
|
721
|
-
);
|
|
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" });
|
|
862
|
-
}
|
|
863
|
-
});
|
|
789
|
+
const { registerAssets } = require("./lib/assets");
|
|
790
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"));
|
|
864
791
|
|
|
865
792
|
// ── Admin API for component registry ──────────────────────────
|
|
866
793
|
|
|
@@ -869,10 +796,10 @@ module.exports = function (RED) {
|
|
|
869
796
|
});
|
|
870
797
|
|
|
871
798
|
RED.httpAdmin.post("/portal-react/registry", (req, res) => {
|
|
872
|
-
const { name, code
|
|
799
|
+
const { name, code } = req.body || {};
|
|
873
800
|
if (!isSafeName(name))
|
|
874
801
|
return res.status(400).json({ error: "invalid name" });
|
|
875
|
-
registry[name] = { code
|
|
802
|
+
registry[name] = { code: code || "" };
|
|
876
803
|
res.json({ ok: true });
|
|
877
804
|
});
|
|
878
805
|
|
|
@@ -883,133 +810,4 @@ module.exports = function (RED) {
|
|
|
883
810
|
delete registry[name];
|
|
884
811
|
res.json({ ok: true });
|
|
885
812
|
});
|
|
886
|
-
|
|
887
|
-
// ── Page builders ─────────────────────────────────────────────
|
|
888
|
-
|
|
889
|
-
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus) {
|
|
890
|
-
return `<!DOCTYPE html>
|
|
891
|
-
<html lang="en">
|
|
892
|
-
<head>
|
|
893
|
-
<meta charset="UTF-8">
|
|
894
|
-
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
895
|
-
<title>${esc(title)}</title>
|
|
896
|
-
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
897
|
-
${escScript(customHead)}
|
|
898
|
-
${showWsStatus ? `<style>
|
|
899
|
-
#__cs {
|
|
900
|
-
position: fixed; bottom: 6px; right: 6px;
|
|
901
|
-
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
902
|
-
z-index: 99999; background: #111; border: 1px solid #333;
|
|
903
|
-
opacity: .7; transition: opacity .2s;
|
|
904
|
-
}
|
|
905
|
-
#__cs:hover { opacity: 1 }
|
|
906
|
-
#__cs.ok { color: #4ade80 }
|
|
907
|
-
#__cs.err { color: #f87171 }
|
|
908
|
-
</style>` : ""}
|
|
909
|
-
</head>
|
|
910
|
-
<body>
|
|
911
|
-
<div id="root"></div>
|
|
912
|
-
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
913
|
-
<script>
|
|
914
|
-
window.__NR = {
|
|
915
|
-
_ws: null,
|
|
916
|
-
_listeners: new Set(),
|
|
917
|
-
_lastData: null,
|
|
918
|
-
_retries: 0,
|
|
919
|
-
_wasConnected: false,
|
|
920
|
-
_version: null,
|
|
921
|
-
_portalClient: null,
|
|
922
|
-
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
923
|
-
|
|
924
|
-
connect() {
|
|
925
|
-
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
926
|
-
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
927
|
-
this._ws = ws;
|
|
928
|
-
const s = document.getElementById('__cs');
|
|
929
|
-
|
|
930
|
-
ws.onopen = () => {
|
|
931
|
-
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
932
|
-
this._retries = 0;
|
|
933
|
-
this._wasConnected = true;
|
|
934
|
-
};
|
|
935
|
-
|
|
936
|
-
ws.onmessage = (e) => {
|
|
937
|
-
try {
|
|
938
|
-
const m = JSON.parse(e.data);
|
|
939
|
-
if (m.type === 'hello') {
|
|
940
|
-
this._portalClient = m.portalClient;
|
|
941
|
-
}
|
|
942
|
-
if (m.type === 'version') {
|
|
943
|
-
if (this._version && this._version !== m.hash) { location.reload(); return; }
|
|
944
|
-
this._version = m.hash;
|
|
945
|
-
}
|
|
946
|
-
if (m.type === 'data') {
|
|
947
|
-
this._lastData = m.payload;
|
|
948
|
-
this._listeners.forEach(fn => fn(m.payload));
|
|
949
|
-
}
|
|
950
|
-
} catch (err) { console.error('WS parse', err); }
|
|
951
|
-
};
|
|
952
|
-
|
|
953
|
-
ws.onclose = () => {
|
|
954
|
-
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
955
|
-
this._ws = null;
|
|
956
|
-
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
957
|
-
this._retries++;
|
|
958
|
-
setTimeout(() => this.connect(), delay);
|
|
959
|
-
};
|
|
960
|
-
|
|
961
|
-
ws.onerror = () => ws.close();
|
|
962
|
-
},
|
|
963
|
-
|
|
964
|
-
subscribe(fn) {
|
|
965
|
-
this._listeners.add(fn);
|
|
966
|
-
if (this._lastData !== null) fn(this._lastData);
|
|
967
|
-
return () => this._listeners.delete(fn);
|
|
968
|
-
},
|
|
969
|
-
|
|
970
|
-
send(payload, topic) {
|
|
971
|
-
if (this._ws && this._ws.readyState === 1)
|
|
972
|
-
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
973
|
-
}
|
|
974
|
-
};
|
|
975
|
-
window.__NR.connect();
|
|
976
|
-
<\/script>
|
|
977
|
-
<script>
|
|
978
|
-
${escScript(transpiledJs)}
|
|
979
|
-
<\/script>
|
|
980
|
-
</body>
|
|
981
|
-
</html>`;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function buildErrorPage(title, error) {
|
|
985
|
-
return `<!DOCTYPE html>
|
|
986
|
-
<html lang="en">
|
|
987
|
-
<head>
|
|
988
|
-
<meta charset="UTF-8">
|
|
989
|
-
<title>${esc(title)} — Error</title>
|
|
990
|
-
<style>
|
|
991
|
-
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
992
|
-
h1 { color: #ff4444; margin-bottom: 16px }
|
|
993
|
-
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
994
|
-
</style>
|
|
995
|
-
</head>
|
|
996
|
-
<body>
|
|
997
|
-
<h1>JSX Transpile Error</h1>
|
|
998
|
-
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
999
|
-
<pre>${esc(error)}</pre>
|
|
1000
|
-
</body>
|
|
1001
|
-
</html>`;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function esc(s) {
|
|
1005
|
-
return String(s)
|
|
1006
|
-
.replace(/&/g, "&")
|
|
1007
|
-
.replace(/</g, "<")
|
|
1008
|
-
.replace(/>/g, ">")
|
|
1009
|
-
.replace(/"/g, """);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
function escScript(s) {
|
|
1013
|
-
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
1014
|
-
}
|
|
1015
813
|
};
|