@aaqu/fromcubes-portal-react 0.1.0-alpha.14 → 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/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
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Assets — static file serving with security validation.
|
|
3
|
+
*
|
|
4
|
+
* Exports pure validation functions (for testing) and a registerAssets factory
|
|
5
|
+
* that mounts Express routes on RED.httpAdmin / RED.httpNode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
|
|
11
|
+
// ── Constants ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const UNSAFE_EXTS = new Set([".html", ".htm", ".svg", ".js", ".mjs", ".xml", ".xhtml"]);
|
|
14
|
+
const RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM\d|LPT\d)(\.|$)/i;
|
|
15
|
+
const MAX_PATH_DEPTH = 10;
|
|
16
|
+
const MAX_ASSETS_BYTES = 500 * 1024 * 1024; // 500 MB total
|
|
17
|
+
const MAX_ASSETS_FILES = 1000;
|
|
18
|
+
|
|
19
|
+
// ── Pure validators ───────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function isSafePathSegment(s) {
|
|
22
|
+
return (
|
|
23
|
+
typeof s === "string" &&
|
|
24
|
+
s.length > 0 &&
|
|
25
|
+
s.length <= 255 &&
|
|
26
|
+
!/[\\:*?"<>|\0]/.test(s) &&
|
|
27
|
+
!s.startsWith(".") &&
|
|
28
|
+
!s.endsWith(".") &&
|
|
29
|
+
!s.endsWith(" ") &&
|
|
30
|
+
s !== ".." &&
|
|
31
|
+
!RESERVED_NAMES.test(s)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safePath(rel, assetsDir) {
|
|
36
|
+
if (!rel || typeof rel !== "string") return null;
|
|
37
|
+
const segments = rel.split("/").filter(Boolean);
|
|
38
|
+
if (segments.length === 0 || segments.length > MAX_PATH_DEPTH) return null;
|
|
39
|
+
if (!segments.every(isSafePathSegment)) return null;
|
|
40
|
+
const resolved = path.resolve(assetsDir, ...segments);
|
|
41
|
+
if (!resolved.startsWith(assetsDir + path.sep) && resolved !== assetsDir)
|
|
42
|
+
return null;
|
|
43
|
+
try {
|
|
44
|
+
const real = fs.realpathSync(resolved);
|
|
45
|
+
if (!real.startsWith(assetsDir + path.sep) && real !== assetsDir)
|
|
46
|
+
return null;
|
|
47
|
+
} catch (_e) { /* path doesn't exist yet — OK for mkdir/upload */ }
|
|
48
|
+
return resolved;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Filesystem helpers ────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function scanDir(dir, prefix) {
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
56
|
+
if (entry.isSymbolicLink()) continue;
|
|
57
|
+
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
results.push({ name: rel, type: "dir" });
|
|
60
|
+
results.push(...scanDir(path.join(dir, entry.name), rel));
|
|
61
|
+
} else if (entry.isFile()) {
|
|
62
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
63
|
+
results.push({ name: rel, type: "file", size: stat.size, mtime: stat.mtimeMs });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getAssetsStats(assetsDir) {
|
|
70
|
+
let size = 0, count = 0;
|
|
71
|
+
function walk(dir) {
|
|
72
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
73
|
+
if (e.isSymbolicLink()) continue;
|
|
74
|
+
const p = path.join(dir, e.name);
|
|
75
|
+
if (e.isDirectory()) walk(p);
|
|
76
|
+
else if (e.isFile()) { size += fs.statSync(p).size; count++; }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
try { walk(assetsDir); } catch (_e) { /* ignore */ }
|
|
80
|
+
return { size, count };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Route registration ────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function registerAssets(RED, express, assetsDir) {
|
|
86
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
// Security middleware for public serving
|
|
89
|
+
RED.httpNode.use(
|
|
90
|
+
"/fromcubes/public",
|
|
91
|
+
(req, res, next) => {
|
|
92
|
+
res.set("X-Content-Type-Options", "nosniff");
|
|
93
|
+
res.set("Content-Security-Policy", "default-src 'none'");
|
|
94
|
+
const ext = path.extname(req.path).toLowerCase();
|
|
95
|
+
if (UNSAFE_EXTS.has(ext)) {
|
|
96
|
+
res.set("Content-Disposition", "attachment");
|
|
97
|
+
}
|
|
98
|
+
next();
|
|
99
|
+
},
|
|
100
|
+
express.static(assetsDir, { maxAge: "1d" }),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// List assets
|
|
104
|
+
RED.httpAdmin.get("/portal-react/assets", (_req, res) => {
|
|
105
|
+
try {
|
|
106
|
+
res.json(scanDir(assetsDir, ""));
|
|
107
|
+
} catch (e) {
|
|
108
|
+
res.json([]);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Create directory
|
|
113
|
+
RED.httpAdmin.post("/portal-react/assets/mkdir", express.json(), (req, res) => {
|
|
114
|
+
const target = safePath(req.body && req.body.path, assetsDir);
|
|
115
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(target, { recursive: true });
|
|
118
|
+
res.json({ ok: true });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
RED.log.error("portal-react assets mkdir: " + e.message);
|
|
121
|
+
res.status(500).json({ error: "internal error" });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Move / rename
|
|
126
|
+
RED.httpAdmin.post("/portal-react/assets/move", express.json(), (req, res) => {
|
|
127
|
+
const from = safePath(req.body && req.body.from, assetsDir);
|
|
128
|
+
const to = safePath(req.body && req.body.to, assetsDir);
|
|
129
|
+
if (!from || !to) return res.status(400).json({ error: "invalid path" });
|
|
130
|
+
const toName = path.basename(to);
|
|
131
|
+
if (!toName || !toName.trim()) return res.status(400).json({ error: "name cannot be empty" });
|
|
132
|
+
try {
|
|
133
|
+
const toDir = path.dirname(to);
|
|
134
|
+
fs.mkdirSync(toDir, { recursive: true });
|
|
135
|
+
fs.renameSync(from, to);
|
|
136
|
+
res.json({ ok: true });
|
|
137
|
+
} catch (e) {
|
|
138
|
+
RED.log.error("portal-react assets move: " + e.message);
|
|
139
|
+
res.status(500).json({ error: "internal error" });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Upload
|
|
144
|
+
RED.httpAdmin.post(
|
|
145
|
+
"/portal-react/assets/upload/*",
|
|
146
|
+
express.raw({ type: "*/*", limit: "100mb" }),
|
|
147
|
+
(req, res) => {
|
|
148
|
+
const rel = req.params[0];
|
|
149
|
+
const target = safePath(rel, assetsDir);
|
|
150
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
151
|
+
const stats = getAssetsStats(assetsDir);
|
|
152
|
+
if (stats.size + req.body.length > MAX_ASSETS_BYTES)
|
|
153
|
+
return res.status(413).json({ error: "storage limit exceeded (500MB)" });
|
|
154
|
+
if (stats.count >= MAX_ASSETS_FILES)
|
|
155
|
+
return res.status(413).json({ error: "file count limit exceeded (1000)" });
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
158
|
+
fs.writeFileSync(target, req.body);
|
|
159
|
+
res.json({ ok: true });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
RED.log.error("portal-react assets upload: " + e.message);
|
|
162
|
+
res.status(500).json({ error: "internal error" });
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Delete
|
|
168
|
+
RED.httpAdmin.delete("/portal-react/assets/*", (req, res) => {
|
|
169
|
+
const rel = req.params[0];
|
|
170
|
+
const target = safePath(rel, assetsDir);
|
|
171
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
172
|
+
try {
|
|
173
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
174
|
+
res.json({ ok: true });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
RED.log.error("portal-react assets delete: " + e.message);
|
|
177
|
+
res.status(404).json({ error: "not found" });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Download
|
|
182
|
+
RED.httpAdmin.get("/portal-react/assets/download/*", (req, res) => {
|
|
183
|
+
const rel = req.params[0];
|
|
184
|
+
const target = safePath(rel, assetsDir);
|
|
185
|
+
if (!target) return res.status(400).json({ error: "invalid path" });
|
|
186
|
+
try {
|
|
187
|
+
const stat = fs.statSync(target);
|
|
188
|
+
if (stat.isDirectory()) return res.status(400).json({ error: "is a directory" });
|
|
189
|
+
const filename = path.basename(target);
|
|
190
|
+
res.set({
|
|
191
|
+
"Content-Disposition": 'attachment; filename="' + filename.replace(/"/g, '\\"') + '"',
|
|
192
|
+
"Content-Length": stat.size,
|
|
193
|
+
});
|
|
194
|
+
fs.createReadStream(target).pipe(res);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
res.status(404).json({ error: "not found" });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
UNSAFE_EXTS,
|
|
203
|
+
RESERVED_NAMES,
|
|
204
|
+
MAX_PATH_DEPTH,
|
|
205
|
+
MAX_ASSETS_BYTES,
|
|
206
|
+
MAX_ASSETS_FILES,
|
|
207
|
+
isSafePathSegment,
|
|
208
|
+
safePath,
|
|
209
|
+
scanDir,
|
|
210
|
+
getAssetsStats,
|
|
211
|
+
registerAssets,
|
|
212
|
+
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper functions for portal-react.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const esbuild = require("esbuild");
|
|
9
|
+
|
|
10
|
+
function hash(str) {
|
|
11
|
+
return crypto.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const twCompile = require("tailwindcss").compile;
|
|
15
|
+
const CANDIDATE_RE = /[a-zA-Z0-9_\-:.\/\[\]#%]+/g;
|
|
16
|
+
|
|
17
|
+
let twCompiled = null;
|
|
18
|
+
async function getTwCompiled() {
|
|
19
|
+
if (twCompiled) return twCompiled;
|
|
20
|
+
twCompiled = await twCompile(`@import 'tailwindcss';`, {
|
|
21
|
+
loadStylesheet: async (id, base) => {
|
|
22
|
+
let resolved;
|
|
23
|
+
if (id === "tailwindcss") {
|
|
24
|
+
resolved = require.resolve("tailwindcss/index.css");
|
|
25
|
+
} else {
|
|
26
|
+
resolved = require.resolve(id, { paths: [base || __dirname] });
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
content: fs.readFileSync(resolved, "utf8"),
|
|
30
|
+
base: path.dirname(resolved),
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return twCompiled;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function generateCSS(source) {
|
|
38
|
+
const cssHash = hash(source);
|
|
39
|
+
const compiled = await getTwCompiled();
|
|
40
|
+
const candidates = [...new Set(source.match(CANDIDATE_RE) || [])];
|
|
41
|
+
const css = compiled.build(candidates);
|
|
42
|
+
return { css, cssHash };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
46
|
+
|
|
47
|
+
function isSafeName(name) {
|
|
48
|
+
return (
|
|
49
|
+
typeof name === "string" && name.length > 0 && !FORBIDDEN_KEYS.has(name)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractPortalUser(headers) {
|
|
54
|
+
const user = {};
|
|
55
|
+
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
56
|
+
if (headers["x-portal-user-name"])
|
|
57
|
+
user.userName = headers["x-portal-user-name"];
|
|
58
|
+
if (headers["x-portal-user-username"])
|
|
59
|
+
user.username = headers["x-portal-user-username"];
|
|
60
|
+
if (headers["x-portal-user-email"])
|
|
61
|
+
user.email = headers["x-portal-user-email"];
|
|
62
|
+
if (headers["x-portal-user-role"]) user.role = headers["x-portal-user-role"];
|
|
63
|
+
if (headers["x-portal-user-groups"]) {
|
|
64
|
+
try {
|
|
65
|
+
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
66
|
+
} catch (_) {
|
|
67
|
+
user.groups = headers["x-portal-user-groups"];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return Object.keys(user).length > 0 ? user : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function removeRoute(router, path) {
|
|
74
|
+
if (!router || !router.stack) return;
|
|
75
|
+
router.stack = router.stack.filter(
|
|
76
|
+
(layer) => !(layer.route && layer.route.path === path),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function esc(s) {
|
|
81
|
+
return String(s)
|
|
82
|
+
.replace(/&/g, "&")
|
|
83
|
+
.replace(/</g, "<")
|
|
84
|
+
.replace(/>/g, ">")
|
|
85
|
+
.replace(/"/g, """);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escScript(s) {
|
|
89
|
+
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = function (RED) {
|
|
93
|
+
// Package root — where react/react-dom live (this package's own node_modules)
|
|
94
|
+
const pkgRoot = path.join(__dirname, "../..");
|
|
95
|
+
// userDir — where dynamicModuleList installs user packages
|
|
96
|
+
const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
|
|
97
|
+
|
|
98
|
+
// Skip npm install for packages already present in node_modules (offline/Docker)
|
|
99
|
+
RED.hooks.add("preInstall.fcPortal", (event) => {
|
|
100
|
+
try {
|
|
101
|
+
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
102
|
+
if (fs.existsSync(modDir)) {
|
|
103
|
+
RED.log.info(
|
|
104
|
+
`[portal-react] ${event.module} already in node_modules, skipping install`,
|
|
105
|
+
);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Disk cache for JS bundles and CSS ────────────────────────
|
|
112
|
+
const cacheDir = path.join(userDir, "fromcubes", "cache");
|
|
113
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
function readCachedJS(jsxHash) {
|
|
116
|
+
try {
|
|
117
|
+
const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
|
|
118
|
+
let metafile = null;
|
|
119
|
+
try {
|
|
120
|
+
metafile = JSON.parse(
|
|
121
|
+
fs.readFileSync(path.join(cacheDir, jsxHash + ".meta.json"), "utf8"),
|
|
122
|
+
);
|
|
123
|
+
} catch (_) {}
|
|
124
|
+
return { js, metafile, error: null };
|
|
125
|
+
} catch (_) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeCachedJS(jsxHash, js, metafile) {
|
|
131
|
+
try {
|
|
132
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
|
|
133
|
+
if (metafile) {
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
path.join(cacheDir, jsxHash + ".meta.json"),
|
|
136
|
+
JSON.stringify(metafile),
|
|
137
|
+
"utf8",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
} catch (e) {
|
|
141
|
+
RED.log.warn("[portal-react] cache write failed: " + e.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readCachedCSS(jsxHash) {
|
|
146
|
+
try {
|
|
147
|
+
const css = fs.readFileSync(
|
|
148
|
+
path.join(cacheDir, jsxHash + ".css"),
|
|
149
|
+
"utf8",
|
|
150
|
+
);
|
|
151
|
+
return { css, cssHash: jsxHash };
|
|
152
|
+
} catch (_) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function writeCachedCSS(jsxHash, css) {
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
RED.log.warn("[portal-react] CSS cache write failed: " + e.message);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function deleteCacheFiles(jsxHash) {
|
|
166
|
+
if (!jsxHash) return;
|
|
167
|
+
for (const ext of [".js", ".css", ".meta.json"]) {
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(path.join(cacheDir, jsxHash + ext));
|
|
170
|
+
} catch (_) {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isHashInUse(jsxHash, pageState, excludeEndpoint) {
|
|
175
|
+
for (const ep in pageState) {
|
|
176
|
+
if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function transpile(jsx) {
|
|
183
|
+
// Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
|
|
184
|
+
try {
|
|
185
|
+
esbuild.transformSync(jsx, {
|
|
186
|
+
loader: "jsx",
|
|
187
|
+
jsx: "transform",
|
|
188
|
+
jsxFactory: "React.createElement",
|
|
189
|
+
jsxFragment: "React.Fragment",
|
|
190
|
+
logLevel: "silent",
|
|
191
|
+
});
|
|
192
|
+
} catch (e) {
|
|
193
|
+
const msg = e.errors?.length
|
|
194
|
+
? e.errors
|
|
195
|
+
.map(
|
|
196
|
+
(err) =>
|
|
197
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
198
|
+
)
|
|
199
|
+
.join("\n")
|
|
200
|
+
: e.message;
|
|
201
|
+
return { js: null, error: msg };
|
|
202
|
+
}
|
|
203
|
+
// Syntax OK — bundle with full resolution
|
|
204
|
+
try {
|
|
205
|
+
const buildResult = esbuild.buildSync({
|
|
206
|
+
stdin: {
|
|
207
|
+
contents: jsx,
|
|
208
|
+
resolveDir: pkgRoot,
|
|
209
|
+
loader: "jsx",
|
|
210
|
+
},
|
|
211
|
+
bundle: true,
|
|
212
|
+
format: "iife",
|
|
213
|
+
minify: true,
|
|
214
|
+
write: false,
|
|
215
|
+
target: ["es2020"],
|
|
216
|
+
jsx: "transform",
|
|
217
|
+
jsxFactory: "React.createElement",
|
|
218
|
+
jsxFragment: "React.Fragment",
|
|
219
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
220
|
+
metafile: true,
|
|
221
|
+
logLevel: "silent",
|
|
222
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
223
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
224
|
+
alias: {
|
|
225
|
+
react: path.dirname(
|
|
226
|
+
require.resolve("react/package.json", { paths: [pkgRoot] }),
|
|
227
|
+
),
|
|
228
|
+
"react-dom": path.dirname(
|
|
229
|
+
require.resolve("react-dom/package.json", { paths: [pkgRoot] }),
|
|
230
|
+
),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
js: buildResult.outputFiles[0].text,
|
|
235
|
+
metafile: buildResult.metafile,
|
|
236
|
+
error: null,
|
|
237
|
+
};
|
|
238
|
+
} catch (e) {
|
|
239
|
+
const msg = e.errors?.length
|
|
240
|
+
? e.errors
|
|
241
|
+
.map(
|
|
242
|
+
(err) =>
|
|
243
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
244
|
+
)
|
|
245
|
+
.join("\n")
|
|
246
|
+
: e.message;
|
|
247
|
+
return { js: null, error: msg };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
hash,
|
|
253
|
+
transpile,
|
|
254
|
+
generateCSS,
|
|
255
|
+
extractPortalUser,
|
|
256
|
+
removeRoute,
|
|
257
|
+
isSafeName,
|
|
258
|
+
esc,
|
|
259
|
+
escScript,
|
|
260
|
+
pkgRoot,
|
|
261
|
+
userDir,
|
|
262
|
+
cacheDir,
|
|
263
|
+
readCachedJS,
|
|
264
|
+
writeCachedJS,
|
|
265
|
+
readCachedCSS,
|
|
266
|
+
writeCachedCSS,
|
|
267
|
+
deleteCacheFiles,
|
|
268
|
+
isHashInUse,
|
|
269
|
+
};
|
|
270
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML page builders for portal-react.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function esc(s) {
|
|
6
|
+
return String(s)
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/</g, "<")
|
|
9
|
+
.replace(/>/g, ">")
|
|
10
|
+
.replace(/"/g, """);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function escScript(s) {
|
|
14
|
+
return String(s).replace(/<\/(script)/gi, "<\\/$1");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
|
|
18
|
+
return `<!DOCTYPE html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="UTF-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
23
|
+
<title>${esc(title)}</title>
|
|
24
|
+
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
25
|
+
${escScript(customHead)}
|
|
26
|
+
${showWsStatus ? `<style>
|
|
27
|
+
#__cs {
|
|
28
|
+
position: fixed; bottom: 6px; right: 6px;
|
|
29
|
+
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
30
|
+
z-index: 99999; background: #111; border: 1px solid #333;
|
|
31
|
+
opacity: .7; transition: opacity .2s;
|
|
32
|
+
}
|
|
33
|
+
#__cs:hover { opacity: 1 }
|
|
34
|
+
#__cs.ok { color: #4ade80 }
|
|
35
|
+
#__cs.err { color: #f87171 }
|
|
36
|
+
</style>` : ""}
|
|
37
|
+
</head>
|
|
38
|
+
<body>
|
|
39
|
+
<div id="root"></div>
|
|
40
|
+
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
41
|
+
<script>
|
|
42
|
+
window.__NR = {
|
|
43
|
+
_ws: null,
|
|
44
|
+
_listeners: new Set(),
|
|
45
|
+
_lastData: null,
|
|
46
|
+
_retries: 0,
|
|
47
|
+
_wasConnected: false,
|
|
48
|
+
_version: null,
|
|
49
|
+
_portalClient: null,
|
|
50
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
51
|
+
|
|
52
|
+
connect() {
|
|
53
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
54
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
55
|
+
this._ws = ws;
|
|
56
|
+
const s = document.getElementById('__cs');
|
|
57
|
+
|
|
58
|
+
ws.onopen = () => {
|
|
59
|
+
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
60
|
+
this._retries = 0;
|
|
61
|
+
this._wasConnected = true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ws.onmessage = (e) => {
|
|
65
|
+
try {
|
|
66
|
+
const m = JSON.parse(e.data);
|
|
67
|
+
if (m.type === 'hello') {
|
|
68
|
+
this._portalClient = m.portalClient;
|
|
69
|
+
}
|
|
70
|
+
if (m.type === 'version') {
|
|
71
|
+
var hasOverlay = document.getElementById('__building_overlay') || document.getElementById('__error_overlay');
|
|
72
|
+
if (hasOverlay || (this._version && this._version !== m.hash)) { location.reload(); return; }
|
|
73
|
+
this._version = m.hash;
|
|
74
|
+
}
|
|
75
|
+
if (m.type === 'building') {
|
|
76
|
+
document.getElementById('root').style.display = 'none';
|
|
77
|
+
var eo = document.getElementById('__error_overlay');
|
|
78
|
+
if (eo) eo.remove();
|
|
79
|
+
if (!document.getElementById('__building_overlay')) {
|
|
80
|
+
var ov = document.createElement('div');
|
|
81
|
+
ov.id = '__building_overlay';
|
|
82
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#111;color:#888;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace';
|
|
83
|
+
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
84
|
+
+ '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
|
|
85
|
+
+ '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
|
|
86
|
+
document.body.appendChild(ov);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (m.type === 'error') {
|
|
90
|
+
document.getElementById('root').style.display = 'none';
|
|
91
|
+
var bo = document.getElementById('__building_overlay');
|
|
92
|
+
if (bo) bo.remove();
|
|
93
|
+
var ov = document.getElementById('__error_overlay');
|
|
94
|
+
if (!ov) {
|
|
95
|
+
ov = document.createElement('div');
|
|
96
|
+
ov.id = '__error_overlay';
|
|
97
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:#1a0000;color:#f87171;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:monospace;padding:40px';
|
|
98
|
+
document.body.appendChild(ov);
|
|
99
|
+
}
|
|
100
|
+
ov.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px;font-size:24px">JSX Transpile Error</h1>'
|
|
101
|
+
+ '<p style="color:#888;margin-bottom:16px">Fix the component code in Node-RED and deploy again.</p>'
|
|
102
|
+
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;max-width:90vw;max-height:60vh;overflow:auto;white-space:pre-wrap">'
|
|
103
|
+
+ m.message.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
104
|
+
+ '</pre>'
|
|
105
|
+
+ '<p style="color:#4ade80;font-size:12px;margin-top:24px">Connected \\u2014 will reload on redeploy</p>';
|
|
106
|
+
}
|
|
107
|
+
if (m.type === 'data') {
|
|
108
|
+
this._lastData = m.payload;
|
|
109
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
110
|
+
}
|
|
111
|
+
} catch (err) { console.error('WS parse', err); }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
ws.onclose = () => {
|
|
115
|
+
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
116
|
+
this._ws = null;
|
|
117
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
118
|
+
this._retries++;
|
|
119
|
+
setTimeout(() => this.connect(), delay);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
ws.onerror = () => ws.close();
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
subscribe(fn) {
|
|
126
|
+
this._listeners.add(fn);
|
|
127
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
128
|
+
return () => this._listeners.delete(fn);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
send(payload, topic) {
|
|
132
|
+
if (this._ws && this._ws.readyState === 1)
|
|
133
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
window.__NR.connect();
|
|
137
|
+
<\/script>
|
|
138
|
+
<script>
|
|
139
|
+
try { ${escScript(transpiledJs)}
|
|
140
|
+
} catch(__e) {
|
|
141
|
+
var __r = document.getElementById('root');
|
|
142
|
+
__r.style.cssText = 'font-family:monospace;background:#1a0000;color:#f87171;padding:40px;min-height:100vh;margin:0';
|
|
143
|
+
__r.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px">Runtime Error</h1>'
|
|
144
|
+
+ '<p style="color:#888">Fix the component code in Node-RED and deploy again.</p>'
|
|
145
|
+
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;white-space:pre-wrap">'
|
|
146
|
+
+ (__e.message || __e).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
|
147
|
+
}
|
|
148
|
+
<\/script>
|
|
149
|
+
</body>
|
|
150
|
+
</html>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildErrorPage(title, error, wsPath) {
|
|
154
|
+
return `<!DOCTYPE html>
|
|
155
|
+
<html lang="en">
|
|
156
|
+
<head>
|
|
157
|
+
<meta charset="UTF-8">
|
|
158
|
+
<title>${esc(title)} — Error</title>
|
|
159
|
+
<style>
|
|
160
|
+
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
161
|
+
h1 { color: #ff4444; margin-bottom: 16px }
|
|
162
|
+
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
163
|
+
.status { color: #888; font-size: 12px; margin-top: 24px }
|
|
164
|
+
.status.ok { color: #4ade80 }
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<h1>JSX Transpile Error</h1>
|
|
169
|
+
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
170
|
+
<pre>${esc(error)}</pre>
|
|
171
|
+
<p class="status" id="st">Waiting for redeploy…</p>
|
|
172
|
+
<script>
|
|
173
|
+
(function() {
|
|
174
|
+
var st = document.getElementById('st');
|
|
175
|
+
var retries = 0;
|
|
176
|
+
function connect() {
|
|
177
|
+
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
178
|
+
var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
179
|
+
ws.onopen = function() {
|
|
180
|
+
retries = 0;
|
|
181
|
+
if (st) { st.textContent = 'Connected \\u2014 will reload on redeploy'; st.className = 'status ok'; }
|
|
182
|
+
};
|
|
183
|
+
ws.onmessage = function(e) {
|
|
184
|
+
try {
|
|
185
|
+
var m = JSON.parse(e.data);
|
|
186
|
+
if (m.type === 'version' && m.hash) location.reload();
|
|
187
|
+
if (m.type === 'error') { var pre = document.querySelector('pre'); if (pre) pre.textContent = m.message; }
|
|
188
|
+
} catch(_) {}
|
|
189
|
+
};
|
|
190
|
+
ws.onclose = function() {
|
|
191
|
+
if (st) { st.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; st.className = 'status'; }
|
|
192
|
+
var delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
193
|
+
retries++;
|
|
194
|
+
setTimeout(connect, delay);
|
|
195
|
+
};
|
|
196
|
+
ws.onerror = function() { ws.close(); };
|
|
197
|
+
}
|
|
198
|
+
${wsPath ? "connect();" : ""}
|
|
199
|
+
setInterval(function() {
|
|
200
|
+
fetch(location.href, { method: 'HEAD', cache: 'no-store' })
|
|
201
|
+
.then(function(r) { if (r.ok) location.reload(); })
|
|
202
|
+
.catch(function() {});
|
|
203
|
+
}, 3000);
|
|
204
|
+
})();
|
|
205
|
+
<\/script>
|
|
206
|
+
</body>
|
|
207
|
+
</html>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = { buildPage, buildErrorPage };
|