@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.21
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/LICENSE +1 -1
- package/README.md +154 -79
- package/examples/001-shared-components-flow.json +68 -0
- package/examples/{sensor-portal-flow.json → 002-sensor-portal-flow.json} +3 -3
- package/examples/003-chart-portal-flow.json +93 -0
- package/examples/004-d3-poland-flow.json +80 -0
- package/examples/005-threejs-portal-flow.json +87 -0
- package/examples/006-pixi-portal-flow.json +86 -0
- package/examples/007-webgpu-tsl-flow.json +85 -0
- package/nodes/lib/assets.js +212 -0
- package/nodes/lib/helpers.js +314 -0
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +347 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +1143 -196
- package/nodes/portal-react.js +911 -353
- package/package.json +21 -11
- package/nodes/vendor/react-19.production.min.js +0 -55
- package/scripts/bundle-react.js +0 -31
|
@@ -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,314 @@
|
|
|
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
|
+
const SUB_PATH_SEGMENT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
54
|
+
const SUB_PATH_RESERVED = new Set(["public", "_ws"]);
|
|
55
|
+
|
|
56
|
+
function validateSubPath(input) {
|
|
57
|
+
if (typeof input !== "string") {
|
|
58
|
+
return { ok: false, error: "Sub-path is required" };
|
|
59
|
+
}
|
|
60
|
+
const trimmed = input.trim();
|
|
61
|
+
if (trimmed.length === 0) {
|
|
62
|
+
return { ok: false, error: "Sub-path is required" };
|
|
63
|
+
}
|
|
64
|
+
if (/\s/.test(trimmed)) {
|
|
65
|
+
return { ok: false, error: "Sub-path must not contain whitespace" };
|
|
66
|
+
}
|
|
67
|
+
if (trimmed.startsWith("/")) {
|
|
68
|
+
return { ok: false, error: "Sub-path must not start with /" };
|
|
69
|
+
}
|
|
70
|
+
if (trimmed.endsWith("/")) {
|
|
71
|
+
return { ok: false, error: "Sub-path must not end with /" };
|
|
72
|
+
}
|
|
73
|
+
const segments = trimmed.split("/");
|
|
74
|
+
for (const seg of segments) {
|
|
75
|
+
if (seg.length === 0) {
|
|
76
|
+
return { ok: false, error: "Sub-path must not contain empty segments" };
|
|
77
|
+
}
|
|
78
|
+
if (seg === "." || seg === "..") {
|
|
79
|
+
return { ok: false, error: "Path traversal not allowed in sub-path" };
|
|
80
|
+
}
|
|
81
|
+
if (SUB_PATH_RESERVED.has(seg.toLowerCase())) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `Sub-path segment "${seg}" is reserved`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!SUB_PATH_SEGMENT_RE.test(seg)) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `Sub-path segment "${seg}" contains invalid characters`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, value: trimmed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractPortalUser(headers) {
|
|
98
|
+
const user = {};
|
|
99
|
+
if (headers["x-portal-user-id"]) user.userId = headers["x-portal-user-id"];
|
|
100
|
+
if (headers["x-portal-user-name"])
|
|
101
|
+
user.userName = headers["x-portal-user-name"];
|
|
102
|
+
if (headers["x-portal-user-username"])
|
|
103
|
+
user.username = headers["x-portal-user-username"];
|
|
104
|
+
if (headers["x-portal-user-email"])
|
|
105
|
+
user.email = headers["x-portal-user-email"];
|
|
106
|
+
if (headers["x-portal-user-role"]) user.role = headers["x-portal-user-role"];
|
|
107
|
+
if (headers["x-portal-user-groups"]) {
|
|
108
|
+
try {
|
|
109
|
+
user.groups = JSON.parse(headers["x-portal-user-groups"]);
|
|
110
|
+
} catch (_) {
|
|
111
|
+
user.groups = headers["x-portal-user-groups"];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Object.keys(user).length > 0 ? user : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeRoute(router, path) {
|
|
118
|
+
if (!router || !router.stack) return;
|
|
119
|
+
router.stack = router.stack.filter(
|
|
120
|
+
(layer) => !(layer.route && layer.route.path === path),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatEsbuildError(e) {
|
|
125
|
+
return e.errors?.length
|
|
126
|
+
? e.errors
|
|
127
|
+
.map(
|
|
128
|
+
(err) =>
|
|
129
|
+
`${err.text}${err.location ? ` (line ${err.location.line})` : ""}`,
|
|
130
|
+
)
|
|
131
|
+
.join("\n")
|
|
132
|
+
: e.message;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fast JSX syntax validation (no bundling). Returns null on OK, error string on fail.
|
|
136
|
+
function quickCheckSyntax(jsx) {
|
|
137
|
+
if (!jsx || !jsx.trim()) return null;
|
|
138
|
+
try {
|
|
139
|
+
esbuild.transformSync(jsx, {
|
|
140
|
+
loader: "jsx",
|
|
141
|
+
jsx: "transform",
|
|
142
|
+
jsxFactory: "React.createElement",
|
|
143
|
+
jsxFragment: "React.Fragment",
|
|
144
|
+
logLevel: "silent",
|
|
145
|
+
});
|
|
146
|
+
return null;
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return formatEsbuildError(e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = function (RED) {
|
|
153
|
+
return createHelpers(RED);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
module.exports.validateSubPath = validateSubPath;
|
|
157
|
+
module.exports.isSafeName = isSafeName;
|
|
158
|
+
module.exports.quickCheckSyntax = quickCheckSyntax;
|
|
159
|
+
module.exports.formatEsbuildError = formatEsbuildError;
|
|
160
|
+
|
|
161
|
+
function createHelpers(RED) {
|
|
162
|
+
// Package root — where react/react-dom live (this package's own node_modules)
|
|
163
|
+
const pkgRoot = path.join(__dirname, "../..");
|
|
164
|
+
// userDir — where dynamicModuleList installs user packages
|
|
165
|
+
const userDir = RED.settings.userDir || path.join(__dirname, "../../../..");
|
|
166
|
+
|
|
167
|
+
// Skip npm install for packages already present in node_modules (offline/Docker)
|
|
168
|
+
RED.hooks.add("preInstall.fcPortal", (event) => {
|
|
169
|
+
try {
|
|
170
|
+
const modDir = path.join(event.dir, "node_modules", event.module);
|
|
171
|
+
if (fs.existsSync(modDir)) {
|
|
172
|
+
RED.log.info(
|
|
173
|
+
`[portal-react] ${event.module} already in node_modules, skipping install`,
|
|
174
|
+
);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
} catch (_) {}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Disk cache for JS bundles and CSS ────────────────────────
|
|
181
|
+
const cacheDir = path.join(userDir, "fromcubes", "cache");
|
|
182
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
183
|
+
|
|
184
|
+
function readCachedJS(jsxHash) {
|
|
185
|
+
try {
|
|
186
|
+
const js = fs.readFileSync(path.join(cacheDir, jsxHash + ".js"), "utf8");
|
|
187
|
+
let metafile = null;
|
|
188
|
+
try {
|
|
189
|
+
metafile = JSON.parse(
|
|
190
|
+
fs.readFileSync(path.join(cacheDir, jsxHash + ".meta.json"), "utf8"),
|
|
191
|
+
);
|
|
192
|
+
} catch (_) {}
|
|
193
|
+
return { js, metafile, error: null };
|
|
194
|
+
} catch (_) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function writeCachedJS(jsxHash, js, metafile) {
|
|
200
|
+
try {
|
|
201
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".js"), js, "utf8");
|
|
202
|
+
if (metafile) {
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
path.join(cacheDir, jsxHash + ".meta.json"),
|
|
205
|
+
JSON.stringify(metafile),
|
|
206
|
+
"utf8",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
RED.log.warn("[portal-react] cache write failed: " + e.message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readCachedCSS(jsxHash) {
|
|
215
|
+
try {
|
|
216
|
+
const css = fs.readFileSync(
|
|
217
|
+
path.join(cacheDir, jsxHash + ".css"),
|
|
218
|
+
"utf8",
|
|
219
|
+
);
|
|
220
|
+
return { css, cssHash: jsxHash };
|
|
221
|
+
} catch (_) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeCachedCSS(jsxHash, css) {
|
|
227
|
+
try {
|
|
228
|
+
fs.writeFileSync(path.join(cacheDir, jsxHash + ".css"), css, "utf8");
|
|
229
|
+
} catch (e) {
|
|
230
|
+
RED.log.warn("[portal-react] CSS cache write failed: " + e.message);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function deleteCacheFiles(jsxHash) {
|
|
235
|
+
if (!jsxHash) return;
|
|
236
|
+
for (const ext of [".js", ".css", ".meta.json"]) {
|
|
237
|
+
try {
|
|
238
|
+
fs.unlinkSync(path.join(cacheDir, jsxHash + ext));
|
|
239
|
+
} catch (_) {}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isHashInUse(jsxHash, pageState, excludeEndpoint) {
|
|
244
|
+
for (const ep in pageState) {
|
|
245
|
+
if (ep !== excludeEndpoint && pageState[ep]?.jsxHash === jsxHash)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function transpile(jsx) {
|
|
252
|
+
// Pre-validate with transformSync (fast, no bundling) to avoid esbuild buildSync deadlock on syntax errors
|
|
253
|
+
const syntaxErr = quickCheckSyntax(jsx);
|
|
254
|
+
if (syntaxErr) return { js: null, error: syntaxErr };
|
|
255
|
+
// Syntax OK — bundle with full resolution
|
|
256
|
+
try {
|
|
257
|
+
const buildResult = esbuild.buildSync({
|
|
258
|
+
stdin: {
|
|
259
|
+
contents: jsx,
|
|
260
|
+
resolveDir: pkgRoot,
|
|
261
|
+
loader: "jsx",
|
|
262
|
+
},
|
|
263
|
+
bundle: true,
|
|
264
|
+
format: "iife",
|
|
265
|
+
minify: true,
|
|
266
|
+
write: false,
|
|
267
|
+
target: ["es2020"],
|
|
268
|
+
jsx: "transform",
|
|
269
|
+
jsxFactory: "React.createElement",
|
|
270
|
+
jsxFragment: "React.Fragment",
|
|
271
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
272
|
+
metafile: true,
|
|
273
|
+
logLevel: "silent",
|
|
274
|
+
logOverride: { "import-is-undefined": "silent" },
|
|
275
|
+
nodePaths: [path.join(userDir, "node_modules")],
|
|
276
|
+
alias: {
|
|
277
|
+
react: path.dirname(
|
|
278
|
+
require.resolve("react/package.json", { paths: [pkgRoot] }),
|
|
279
|
+
),
|
|
280
|
+
"react-dom": path.dirname(
|
|
281
|
+
require.resolve("react-dom/package.json", { paths: [pkgRoot] }),
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
return {
|
|
286
|
+
js: buildResult.outputFiles[0].text,
|
|
287
|
+
metafile: buildResult.metafile,
|
|
288
|
+
error: null,
|
|
289
|
+
};
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return { js: null, error: formatEsbuildError(e) };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
hash,
|
|
297
|
+
transpile,
|
|
298
|
+
quickCheckSyntax,
|
|
299
|
+
generateCSS,
|
|
300
|
+
extractPortalUser,
|
|
301
|
+
removeRoute,
|
|
302
|
+
isSafeName,
|
|
303
|
+
validateSubPath,
|
|
304
|
+
pkgRoot,
|
|
305
|
+
userDir,
|
|
306
|
+
cacheDir,
|
|
307
|
+
readCachedJS,
|
|
308
|
+
writeCachedJS,
|
|
309
|
+
readCachedCSS,
|
|
310
|
+
writeCachedCSS,
|
|
311
|
+
deleteCacheFiles,
|
|
312
|
+
isHashInUse,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin hook system for @aaqu/fromcubes-portal-react.
|
|
3
|
+
*
|
|
4
|
+
* Plugins register with Node-RED via:
|
|
5
|
+
* RED.plugins.registerPlugin("my-plugin", {
|
|
6
|
+
* type: "fromcubes-portal-react",
|
|
7
|
+
* hooks: {
|
|
8
|
+
* onIsValidConnection(request) { return true },
|
|
9
|
+
* onCanSendTo(ws, msg) { return true },
|
|
10
|
+
* onInbound(msg, ws) { return msg },
|
|
11
|
+
* },
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* Semantics:
|
|
15
|
+
* - allow(name, ...args): every registered hook must return !== false
|
|
16
|
+
* (no hooks registered -> allowed). AND logic across plugins.
|
|
17
|
+
* - transform(name, msg, ...args): runs each hook sequentially, each
|
|
18
|
+
* may return a new msg. Returning undefined keeps the current msg.
|
|
19
|
+
* - Any thrown exception is treated as `false` for allow hooks and
|
|
20
|
+
* logged via RED.log.error. Transform hooks log and skip the step.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PLUGIN_TYPE = "fromcubes-portal-react";
|
|
24
|
+
|
|
25
|
+
module.exports = function (RED) {
|
|
26
|
+
function getHooks(name) {
|
|
27
|
+
let plugins = [];
|
|
28
|
+
try {
|
|
29
|
+
plugins = RED.plugins.getByType(PLUGIN_TYPE) || [];
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const p of plugins) {
|
|
35
|
+
const fn = p && p.hooks && p.hooks[name];
|
|
36
|
+
if (typeof fn === "function") out.push({ fn, id: p.id || p.name || "?" });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function allow(name, ...args) {
|
|
42
|
+
const hooks = getHooks(name);
|
|
43
|
+
if (hooks.length === 0) return true;
|
|
44
|
+
for (const h of hooks) {
|
|
45
|
+
let result;
|
|
46
|
+
try {
|
|
47
|
+
result = h.fn(...args);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
RED.log.error(
|
|
50
|
+
`[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
|
|
51
|
+
);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (result === false) return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function transform(name, msg, ...args) {
|
|
60
|
+
const hooks = getHooks(name);
|
|
61
|
+
let current = msg;
|
|
62
|
+
for (const h of hooks) {
|
|
63
|
+
try {
|
|
64
|
+
const next = h.fn(current, ...args);
|
|
65
|
+
if (next !== undefined) current = next;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
RED.log.error(
|
|
68
|
+
`[portal-react] hook ${name} (${h.id}) threw: ${e.message}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasHook(name) {
|
|
76
|
+
return getHooks(name).length > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { allow, transform, hasHook, PLUGIN_TYPE };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
module.exports.PLUGIN_TYPE = PLUGIN_TYPE;
|