@aaqu/fromcubes-portal-react 0.1.0-alpha.25 → 0.1.0-alpha.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/portal-page-route.js +128 -0
- package/nodes/lib/registry-nodes.js +222 -0
- package/nodes/lib/ws-heartbeat.js +58 -0
- package/nodes/portal-react.html +35 -15
- package/nodes/portal-react.js +66 -628
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ The opt-out is page-wide — the strictest call wins. If any component on the pa
|
|
|
88
88
|
| Page Title | Browser tab title |
|
|
89
89
|
| npm Packages | Comma-separated, e.g. `d3, three, @react-three/fiber` |
|
|
90
90
|
| Portal Auth | Enable portal user header extraction (see Multi-user) |
|
|
91
|
-
| Head HTML | Extra `<head>` tags (CDN, fonts, CSS) |
|
|
91
|
+
| Head HTML | Extra trusted-author `<head>` tags (CDN, fonts, CSS, scripts). Runs in the public portal page. |
|
|
92
92
|
| Code Editor | Monaco with JSX — must define `<App />` |
|
|
93
93
|
|
|
94
94
|
There is also a config node, **fc-portal-component**, that lets you define reusable React components once and reference them by name from any portal-react node. Referenced components (and their transitive dependencies) are injected at transpile time, so unused ones add nothing to the bundle.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register editor/admin/public support routes for portal-react.
|
|
5
|
+
*
|
|
6
|
+
* This module intentionally owns only HTTP route registration. Runtime page
|
|
7
|
+
* state and registries remain owned by portal-react.js and are passed in so
|
|
8
|
+
* deploy/rebuild lifecycle stays in one place.
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} RED
|
|
11
|
+
* @param {Object} deps
|
|
12
|
+
* @param {import("express")} deps.express
|
|
13
|
+
* @param {Function} deps.permRead
|
|
14
|
+
* @param {Function} deps.permWrite
|
|
15
|
+
* @param {Function} deps.csrfGuard
|
|
16
|
+
* @param {Function} deps.rateLimit
|
|
17
|
+
* @param {string} deps.jsonBodyLimit
|
|
18
|
+
* @param {string} deps.userDir
|
|
19
|
+
* @param {Object<string, Object>} deps.pageState
|
|
20
|
+
* @param {Object<string, Object>} deps.registry
|
|
21
|
+
* @param {Object<string, Object>} deps.utilities
|
|
22
|
+
* @param {(code: string) => Set<string>} deps.extractUtilitySymbols
|
|
23
|
+
* @returns {void}
|
|
24
|
+
*/
|
|
25
|
+
function registerAdminApi(RED, deps) {
|
|
26
|
+
const {
|
|
27
|
+
express,
|
|
28
|
+
permRead,
|
|
29
|
+
permWrite,
|
|
30
|
+
csrfGuard,
|
|
31
|
+
rateLimit,
|
|
32
|
+
jsonBodyLimit,
|
|
33
|
+
userDir,
|
|
34
|
+
pageState,
|
|
35
|
+
registry,
|
|
36
|
+
utilities,
|
|
37
|
+
extractUtilitySymbols,
|
|
38
|
+
} = deps;
|
|
39
|
+
|
|
40
|
+
const monacoPath = path.dirname(
|
|
41
|
+
require.resolve("monaco-editor/package.json"),
|
|
42
|
+
);
|
|
43
|
+
RED.httpAdmin.use(
|
|
44
|
+
"/portal-react/vs",
|
|
45
|
+
permRead,
|
|
46
|
+
express.static(path.join(monacoPath, "min", "vs")),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const { generateCandidates } = require("../tw-candidates");
|
|
50
|
+
let twClassesCache = null;
|
|
51
|
+
RED.httpAdmin.get("/portal-react/tw-classes", permRead, (_req, res) => {
|
|
52
|
+
if (!twClassesCache) {
|
|
53
|
+
twClassesCache = generateCandidates();
|
|
54
|
+
}
|
|
55
|
+
res.json(twClassesCache);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const CSS_HASH_RE = /^[a-f0-9]{1,64}$/;
|
|
59
|
+
function findCssByHash(reqHash) {
|
|
60
|
+
for (const ep in pageState) {
|
|
61
|
+
if (pageState[ep]?.cssHash === reqHash) return pageState[ep].css;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function serveCss(req, res) {
|
|
66
|
+
const reqHash = req.params.hash;
|
|
67
|
+
if (!CSS_HASH_RE.test(reqHash)) {
|
|
68
|
+
res.status(400).send("Bad request");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const css = findCssByHash(reqHash);
|
|
72
|
+
if (!css) {
|
|
73
|
+
res.status(404).send("Not found");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
res.set({
|
|
77
|
+
"Content-Type": "text/css",
|
|
78
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
79
|
+
});
|
|
80
|
+
res.send(css);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
RED.httpNode.get("/fromcubes/css/:hash.css", serveCss);
|
|
84
|
+
RED.httpAdmin.get("/portal-react/css/:hash.css", serveCss);
|
|
85
|
+
|
|
86
|
+
const { registerAssets } = require("./assets");
|
|
87
|
+
registerAssets(RED, express, path.join(userDir, "fromcubes", "public"), {
|
|
88
|
+
csrfGuard,
|
|
89
|
+
rateLimit,
|
|
90
|
+
jsonLimit: jsonBodyLimit,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
RED.httpAdmin.get("/portal-react/registry", permRead, (_req, res) => {
|
|
94
|
+
res.json(registry);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
RED.httpAdmin.post(
|
|
98
|
+
"/portal-react/registry",
|
|
99
|
+
permWrite,
|
|
100
|
+
csrfGuard,
|
|
101
|
+
rateLimit,
|
|
102
|
+
express.json({ limit: jsonBodyLimit }),
|
|
103
|
+
(_req, res) => {
|
|
104
|
+
res.status(410).json({
|
|
105
|
+
error: "registry writes are deprecated; use fc-portal-component nodes",
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
RED.httpAdmin.delete(
|
|
111
|
+
"/portal-react/registry/:name",
|
|
112
|
+
permWrite,
|
|
113
|
+
csrfGuard,
|
|
114
|
+
rateLimit,
|
|
115
|
+
(_req, res) => {
|
|
116
|
+
res.status(410).json({
|
|
117
|
+
error: "registry writes are deprecated; use fc-portal-component nodes",
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
RED.httpAdmin.get("/portal-react/utilities", permRead, (_req, res) => {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (const [name, u] of Object.entries(utilities)) {
|
|
125
|
+
out[name] = {
|
|
126
|
+
code: u.code,
|
|
127
|
+
error: u.error || null,
|
|
128
|
+
symbols: [...extractUtilitySymbols(u.code || "")],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
res.json(out);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
RED.httpAdmin.post(
|
|
135
|
+
"/portal-react/utilities",
|
|
136
|
+
permWrite,
|
|
137
|
+
csrfGuard,
|
|
138
|
+
rateLimit,
|
|
139
|
+
express.json({ limit: jsonBodyLimit }),
|
|
140
|
+
(_req, res) => {
|
|
141
|
+
res.status(410).json({
|
|
142
|
+
error: "utility writes are deprecated; use fc-portal-utility nodes",
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
RED.httpAdmin.delete(
|
|
148
|
+
"/portal-react/utilities/:name",
|
|
149
|
+
permWrite,
|
|
150
|
+
csrfGuard,
|
|
151
|
+
rateLimit,
|
|
152
|
+
(_req, res) => {
|
|
153
|
+
res.status(410).json({
|
|
154
|
+
error: "utility writes are deprecated; use fc-portal-utility nodes",
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { registerAdminApi };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public portal page route factory.
|
|
3
|
+
*
|
|
4
|
+
* Owns only the HTTP response branching for a portal endpoint:
|
|
5
|
+
* building/teardown, hard build error, degraded last-good build, and fresh
|
|
6
|
+
* successful build. Runtime lifecycle, WebSocket handling, and pageState
|
|
7
|
+
* mutation stay in portal-react.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function buildBuildingPage(wsPath) {
|
|
11
|
+
return `<!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(){let r=0;function c(){const p=location.protocol==='https:'?'wss:':'ws:';const ws=new WebSocket(p+'//'+location.host+'${wsPath}');ws.onmessage=function(e){try{const m=JSON.parse(e.data);if((m.type==='version'&&m.hash)||m.type==='error')location.reload();}catch(_){}};ws.onclose=function(){const d=Math.min(500*Math.pow(2,r),8000);r++;setTimeout(c,d);};ws.onerror=function(){ws.close();};}c();})()</script></body></html>`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withNoStore(res) {
|
|
15
|
+
return res.set("Cache-Control", "no-store");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Object} opts
|
|
20
|
+
* @param {string} opts.endpoint
|
|
21
|
+
* @param {Object<string, Object>} opts.pageState
|
|
22
|
+
* @param {string} opts.wsPath
|
|
23
|
+
* @param {string} opts.pageTitle
|
|
24
|
+
* @param {string} opts.adminRoot
|
|
25
|
+
* @param {Function} opts.buildPage
|
|
26
|
+
* @param {Function} opts.buildErrorPage
|
|
27
|
+
* @param {Function} opts.extractPortalUser
|
|
28
|
+
* @returns {Function}
|
|
29
|
+
*/
|
|
30
|
+
function createPortalPageHandler(opts) {
|
|
31
|
+
const {
|
|
32
|
+
endpoint,
|
|
33
|
+
pageState,
|
|
34
|
+
wsPath,
|
|
35
|
+
pageTitle,
|
|
36
|
+
adminRoot,
|
|
37
|
+
buildPage,
|
|
38
|
+
buildErrorPage,
|
|
39
|
+
extractPortalUser,
|
|
40
|
+
} = opts;
|
|
41
|
+
|
|
42
|
+
return async function portalPageHandler(req, res) {
|
|
43
|
+
try {
|
|
44
|
+
const state = pageState[endpoint];
|
|
45
|
+
if (!state || state.building || !state.compiled) {
|
|
46
|
+
const bWsPath = state?.wsPath || wsPath;
|
|
47
|
+
withNoStore(res)
|
|
48
|
+
.type("text/html")
|
|
49
|
+
.send(buildBuildingPage(bWsPath));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
withNoStore(res);
|
|
54
|
+
if (state.compiled.error) {
|
|
55
|
+
if (state.lastGood) {
|
|
56
|
+
const user = state.portalAuth
|
|
57
|
+
? extractPortalUser(req.headers)
|
|
58
|
+
: null;
|
|
59
|
+
res
|
|
60
|
+
.type("text/html")
|
|
61
|
+
.send(
|
|
62
|
+
buildPage(
|
|
63
|
+
state.lastGood.pageTitle,
|
|
64
|
+
state.lastGood.compiledJs,
|
|
65
|
+
state.wsPath,
|
|
66
|
+
state.lastGood.customHead,
|
|
67
|
+
state.lastGood.cssHash,
|
|
68
|
+
user,
|
|
69
|
+
state.showWsStatus,
|
|
70
|
+
adminRoot,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res
|
|
77
|
+
.status(500)
|
|
78
|
+
.type("text/html")
|
|
79
|
+
.send(
|
|
80
|
+
buildErrorPage(
|
|
81
|
+
state.pageTitle,
|
|
82
|
+
state.compiled.error,
|
|
83
|
+
state.wsPath,
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { cssHash } = await Promise.race([
|
|
90
|
+
state.cssReady,
|
|
91
|
+
new Promise((_, reject) =>
|
|
92
|
+
setTimeout(
|
|
93
|
+
() => reject(new Error("CSS generation timeout")),
|
|
94
|
+
15000,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
]);
|
|
98
|
+
const user = state.portalAuth ? extractPortalUser(req.headers) : null;
|
|
99
|
+
res
|
|
100
|
+
.type("text/html")
|
|
101
|
+
.send(
|
|
102
|
+
buildPage(
|
|
103
|
+
state.pageTitle,
|
|
104
|
+
state.compiled.js,
|
|
105
|
+
state.wsPath,
|
|
106
|
+
state.customHead,
|
|
107
|
+
cssHash,
|
|
108
|
+
user,
|
|
109
|
+
state.showWsStatus,
|
|
110
|
+
adminRoot,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
res
|
|
115
|
+
.status(500)
|
|
116
|
+
.type("text/html")
|
|
117
|
+
.send(
|
|
118
|
+
buildErrorPage(
|
|
119
|
+
pageTitle,
|
|
120
|
+
"Page build failed: " + e.message,
|
|
121
|
+
wsPath,
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { createPortalPageHandler };
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const MAX_UTIL_CODE_BYTES = 1_000_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scan utility code for top-level `function`/`const`/`let`/`var`/`class`
|
|
5
|
+
* names. Used for selective inclusion, symbol collision checks, and the
|
|
6
|
+
* editor Utilities dialog. Oversize input is ignored so the regex cannot be
|
|
7
|
+
* weaponized with multi-MB code.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} code
|
|
10
|
+
* @returns {Set<string>}
|
|
11
|
+
*/
|
|
12
|
+
function extractUtilitySymbols(code) {
|
|
13
|
+
const names = new Set();
|
|
14
|
+
if (!code || code.length > MAX_UTIL_CODE_BYTES) return names;
|
|
15
|
+
const re = /^(?:export\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
16
|
+
let m;
|
|
17
|
+
while ((m = re.exec(code))) names.add(m[1]);
|
|
18
|
+
return names;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register fc-portal-component and fc-portal-utility config nodes.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} RED
|
|
25
|
+
* @param {Object} deps
|
|
26
|
+
* @returns {void}
|
|
27
|
+
*/
|
|
28
|
+
function registerRegistryNodes(RED, deps) {
|
|
29
|
+
const {
|
|
30
|
+
registry,
|
|
31
|
+
utilities,
|
|
32
|
+
compNameOwners,
|
|
33
|
+
utilSymbolOwners,
|
|
34
|
+
isSafeName,
|
|
35
|
+
quickCheckSyntax,
|
|
36
|
+
shortStatus,
|
|
37
|
+
scheduleRebuildUsing,
|
|
38
|
+
} = deps;
|
|
39
|
+
|
|
40
|
+
function PortalComponentNode(config) {
|
|
41
|
+
RED.nodes.createNode(this, config);
|
|
42
|
+
const node = this;
|
|
43
|
+
const compName = (config.compName || "").trim();
|
|
44
|
+
|
|
45
|
+
if (!isSafeName(compName)) {
|
|
46
|
+
node.error("Invalid component name: " + compName);
|
|
47
|
+
node.status({ fill: "red", shape: "dot", text: "invalid name" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const existingOwner = compNameOwners[compName];
|
|
52
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
53
|
+
node.error(
|
|
54
|
+
`Component name "${compName}" is already used by another node`,
|
|
55
|
+
);
|
|
56
|
+
node.status({
|
|
57
|
+
fill: "red",
|
|
58
|
+
shape: "ring",
|
|
59
|
+
text: shortStatus("dup: " + compName),
|
|
60
|
+
});
|
|
61
|
+
node.on("close", function (_removed, done) {
|
|
62
|
+
done();
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const utilSymOwner = utilSymbolOwners[compName];
|
|
68
|
+
if (utilSymOwner) {
|
|
69
|
+
node.error(
|
|
70
|
+
`Component name "${compName}" conflicts with a top-level symbol declared in utility "${utilSymOwner}"`,
|
|
71
|
+
);
|
|
72
|
+
node.status({
|
|
73
|
+
fill: "red",
|
|
74
|
+
shape: "ring",
|
|
75
|
+
text: shortStatus("dup sym: " + compName),
|
|
76
|
+
});
|
|
77
|
+
node.on("close", function (_removed, done) {
|
|
78
|
+
done();
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
compNameOwners[compName] = node.id;
|
|
83
|
+
|
|
84
|
+
const newCode = config.compCode || "";
|
|
85
|
+
const prevCode = registry[compName]?.code;
|
|
86
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
87
|
+
registry[compName] = { code: newCode, error: syntaxErr };
|
|
88
|
+
|
|
89
|
+
if (syntaxErr) {
|
|
90
|
+
node.error(`Component "${compName}" syntax error: ${syntaxErr}`);
|
|
91
|
+
const short = syntaxErr.split("\n")[0];
|
|
92
|
+
node.status({
|
|
93
|
+
fill: "red",
|
|
94
|
+
shape: "dot",
|
|
95
|
+
text: shortStatus("syntax: " + short),
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
node.status({ fill: "green", shape: "dot", text: shortStatus(compName) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (prevCode !== newCode) scheduleRebuildUsing(compName);
|
|
102
|
+
|
|
103
|
+
node.on("close", function (_removed, done) {
|
|
104
|
+
if (compNameOwners[compName] === node.id) {
|
|
105
|
+
delete compNameOwners[compName];
|
|
106
|
+
}
|
|
107
|
+
delete registry[compName];
|
|
108
|
+
scheduleRebuildUsing(compName);
|
|
109
|
+
done();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
RED.nodes.registerType("fc-portal-component", PortalComponentNode);
|
|
113
|
+
|
|
114
|
+
function PortalUtilityNode(config) {
|
|
115
|
+
RED.nodes.createNode(this, config);
|
|
116
|
+
const node = this;
|
|
117
|
+
const utilName = (config.utilName || "").trim();
|
|
118
|
+
|
|
119
|
+
if (!isSafeName(utilName)) {
|
|
120
|
+
node.error("Invalid utility name: " + utilName);
|
|
121
|
+
node.status({ fill: "red", shape: "dot", text: "invalid name" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const existingOwner = compNameOwners[utilName];
|
|
126
|
+
if (existingOwner && existingOwner !== node.id) {
|
|
127
|
+
node.error(
|
|
128
|
+
`Name "${utilName}" is already used by another component or utility`,
|
|
129
|
+
);
|
|
130
|
+
node.status({
|
|
131
|
+
fill: "red",
|
|
132
|
+
shape: "ring",
|
|
133
|
+
text: shortStatus("dup: " + utilName),
|
|
134
|
+
});
|
|
135
|
+
node.on("close", function (_removed, done) {
|
|
136
|
+
done();
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
compNameOwners[utilName] = node.id;
|
|
141
|
+
|
|
142
|
+
const newCode = config.utilCode || "";
|
|
143
|
+
const prevCode = utilities[utilName]?.code;
|
|
144
|
+
const prevSyms = extractUtilitySymbols(prevCode || "");
|
|
145
|
+
const newSyms = extractUtilitySymbols(newCode);
|
|
146
|
+
|
|
147
|
+
for (const s of prevSyms) {
|
|
148
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const conflicts = [];
|
|
152
|
+
for (const s of newSyms) {
|
|
153
|
+
if (Object.prototype.hasOwnProperty.call(registry, s)) {
|
|
154
|
+
conflicts.push(`${s} (component)`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const symOwner = utilSymbolOwners[s];
|
|
158
|
+
if (symOwner && symOwner !== utilName) {
|
|
159
|
+
conflicts.push(`${s} (utility ${symOwner})`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const syntaxErr = quickCheckSyntax(newCode);
|
|
164
|
+
const dupErr =
|
|
165
|
+
conflicts.length > 0
|
|
166
|
+
? "duplicate symbols: " + conflicts.join(", ")
|
|
167
|
+
: null;
|
|
168
|
+
const combinedErr = syntaxErr || dupErr;
|
|
169
|
+
|
|
170
|
+
utilities[utilName] = { code: newCode, error: combinedErr };
|
|
171
|
+
|
|
172
|
+
if (combinedErr) {
|
|
173
|
+
const msgs = [syntaxErr, dupErr].filter(Boolean).join(" | ");
|
|
174
|
+
node.error(`Utility "${utilName}": ${msgs}`);
|
|
175
|
+
if (syntaxErr) {
|
|
176
|
+
const short = syntaxErr.split("\n")[0];
|
|
177
|
+
node.status({
|
|
178
|
+
fill: "red",
|
|
179
|
+
shape: "dot",
|
|
180
|
+
text: shortStatus("syntax: " + short),
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
const firstSym = conflicts[0].split(" ")[0];
|
|
184
|
+
node.status({
|
|
185
|
+
fill: "red",
|
|
186
|
+
shape: "ring",
|
|
187
|
+
text: shortStatus("dup sym: " + firstSym),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
for (const s of newSyms) utilSymbolOwners[s] = utilName;
|
|
192
|
+
node.status({
|
|
193
|
+
fill: "green",
|
|
194
|
+
shape: "dot",
|
|
195
|
+
text: shortStatus(utilName),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (prevCode !== newCode) {
|
|
200
|
+
scheduleRebuildUsing(utilName);
|
|
201
|
+
for (const s of newSyms) scheduleRebuildUsing(s);
|
|
202
|
+
for (const s of prevSyms) if (!newSyms.has(s)) scheduleRebuildUsing(s);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
node.on("close", function (_removed, done) {
|
|
206
|
+
if (compNameOwners[utilName] === node.id) {
|
|
207
|
+
delete compNameOwners[utilName];
|
|
208
|
+
}
|
|
209
|
+
for (const s of Object.keys(utilSymbolOwners)) {
|
|
210
|
+
if (utilSymbolOwners[s] === utilName) delete utilSymbolOwners[s];
|
|
211
|
+
}
|
|
212
|
+
const removedSyms = extractUtilitySymbols(utilities[utilName]?.code || "");
|
|
213
|
+
delete utilities[utilName];
|
|
214
|
+
scheduleRebuildUsing(utilName);
|
|
215
|
+
for (const s of removedSyms) scheduleRebuildUsing(s);
|
|
216
|
+
done();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
RED.nodes.registerType("fc-portal-utility", PortalUtilityNode);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { extractUtilitySymbols, registerRegistryNodes };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const PING_INTERVAL_MS = 30_000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared WebSocket heartbeat manager. One interval walks all registered
|
|
5
|
+
* WebSocket.Server instances, replacing per-client timers.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} RED
|
|
8
|
+
* @returns {{ registerPingedServer: Function, unregisterPingedServer: Function }}
|
|
9
|
+
*/
|
|
10
|
+
function createWsHeartbeat(RED) {
|
|
11
|
+
if (!RED.settings.portalReactPingedServers) {
|
|
12
|
+
RED.settings.portalReactPingedServers = new Set();
|
|
13
|
+
}
|
|
14
|
+
const pingedServers = RED.settings.portalReactPingedServers;
|
|
15
|
+
|
|
16
|
+
if (!RED.settings.portalReactPingTick) {
|
|
17
|
+
RED.settings.portalReactPingTick = { iv: null };
|
|
18
|
+
}
|
|
19
|
+
const pingTick = RED.settings.portalReactPingTick;
|
|
20
|
+
|
|
21
|
+
function pingSweep() {
|
|
22
|
+
for (const srv of pingedServers) {
|
|
23
|
+
try {
|
|
24
|
+
srv.clients.forEach((ws) => {
|
|
25
|
+
if (ws._isAlive === false) {
|
|
26
|
+
try { ws.terminate(); } catch (e) { RED.log.trace("[portal-react] ws terminate: " + e.message); }
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
ws._isAlive = false;
|
|
30
|
+
try { ws.ping(); } catch (e) { RED.log.trace("[portal-react] ws ping: " + e.message); }
|
|
31
|
+
});
|
|
32
|
+
} catch (e) {
|
|
33
|
+
RED.log.trace("[portal-react] ping sweep: " + e.message);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function registerPingedServer(wsServer) {
|
|
39
|
+
if (pingedServers.has(wsServer)) return;
|
|
40
|
+
pingedServers.add(wsServer);
|
|
41
|
+
if (!pingTick.iv) {
|
|
42
|
+
pingTick.iv = setInterval(pingSweep, PING_INTERVAL_MS);
|
|
43
|
+
pingTick.iv.unref?.();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function unregisterPingedServer(wsServer) {
|
|
48
|
+
if (!pingedServers.delete(wsServer)) return;
|
|
49
|
+
if (pingedServers.size === 0 && pingTick.iv) {
|
|
50
|
+
clearInterval(pingTick.iv);
|
|
51
|
+
pingTick.iv = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { registerPingedServer, unregisterPingedServer };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { createWsHeartbeat };
|
package/nodes/portal-react.html
CHANGED
|
@@ -740,7 +740,12 @@
|
|
|
740
740
|
required: true,
|
|
741
741
|
validate: fcValidateName,
|
|
742
742
|
},
|
|
743
|
-
compCode: {
|
|
743
|
+
compCode: {
|
|
744
|
+
value: COMP_STARTER,
|
|
745
|
+
validate: function (v) {
|
|
746
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
747
|
+
},
|
|
748
|
+
},
|
|
744
749
|
},
|
|
745
750
|
inputs: 0,
|
|
746
751
|
outputs: 0,
|
|
@@ -768,7 +773,8 @@
|
|
|
768
773
|
*/
|
|
769
774
|
oneditprepare: function () {
|
|
770
775
|
const node = this;
|
|
771
|
-
const code =
|
|
776
|
+
const code =
|
|
777
|
+
typeof node.compCode === "string" ? node.compCode : COMP_STARTER;
|
|
772
778
|
console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
|
|
773
779
|
|
|
774
780
|
if (!window.__fcTwClasses) {
|
|
@@ -1035,7 +1041,12 @@
|
|
|
1035
1041
|
required: true,
|
|
1036
1042
|
validate: fcValidateName,
|
|
1037
1043
|
},
|
|
1038
|
-
utilCode: {
|
|
1044
|
+
utilCode: {
|
|
1045
|
+
value: UTIL_STARTER,
|
|
1046
|
+
validate: function (v) {
|
|
1047
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1048
|
+
},
|
|
1049
|
+
},
|
|
1039
1050
|
},
|
|
1040
1051
|
inputs: 0,
|
|
1041
1052
|
outputs: 0,
|
|
@@ -1059,7 +1070,8 @@
|
|
|
1059
1070
|
*/
|
|
1060
1071
|
oneditprepare: function () {
|
|
1061
1072
|
const node = this;
|
|
1062
|
-
const code =
|
|
1073
|
+
const code =
|
|
1074
|
+
typeof node.utilCode === "string" ? node.utilCode : UTIL_STARTER;
|
|
1063
1075
|
console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
|
|
1064
1076
|
|
|
1065
1077
|
if (!window.__fcTwClasses) {
|
|
@@ -1275,27 +1287,27 @@ function useDebounce(value, ms = 300) {
|
|
|
1275
1287
|
<label style="width:auto;">
|
|
1276
1288
|
<input
|
|
1277
1289
|
type="checkbox"
|
|
1278
|
-
id="node-input-
|
|
1290
|
+
id="node-input-portalAuth"
|
|
1279
1291
|
style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
|
|
1280
1292
|
/>
|
|
1281
|
-
|
|
1293
|
+
Enable Portal Auth headers
|
|
1282
1294
|
</label>
|
|
1283
1295
|
<div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
|
|
1284
|
-
|
|
1285
|
-
showing connection state.
|
|
1296
|
+
Read <code>X-Portal-*</code> headers from reverse proxy.
|
|
1286
1297
|
</div>
|
|
1287
1298
|
</div>
|
|
1288
1299
|
<div class="form-row" style="margin-top:12px;">
|
|
1289
1300
|
<label style="width:auto;">
|
|
1290
1301
|
<input
|
|
1291
1302
|
type="checkbox"
|
|
1292
|
-
id="node-input-
|
|
1303
|
+
id="node-input-showWsStatus"
|
|
1293
1304
|
style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
|
|
1294
1305
|
/>
|
|
1295
|
-
|
|
1306
|
+
Show WebSocket status indicator
|
|
1296
1307
|
</label>
|
|
1297
1308
|
<div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
|
|
1298
|
-
|
|
1309
|
+
Displays a small <em>fromcubes</em> badge in the bottom-right corner
|
|
1310
|
+
showing connection state.
|
|
1299
1311
|
</div>
|
|
1300
1312
|
</div>
|
|
1301
1313
|
</div>
|
|
@@ -1305,7 +1317,8 @@ function useDebounce(value, ms = 300) {
|
|
|
1305
1317
|
<div class="form-row">
|
|
1306
1318
|
<div style="font-size:11px;opacity:.6;margin-bottom:6px;">
|
|
1307
1319
|
Extra tags injected into <code><head></code>. CDN links, fonts,
|
|
1308
|
-
stylesheets, meta tags.
|
|
1320
|
+
stylesheets, meta tags. Trusted authors only: scripts and event
|
|
1321
|
+
handlers run in the public portal page.
|
|
1309
1322
|
</div>
|
|
1310
1323
|
<div
|
|
1311
1324
|
id="fc-head-wrap"
|
|
@@ -1385,7 +1398,12 @@ function useDebounce(value, ms = 300) {
|
|
|
1385
1398
|
},
|
|
1386
1399
|
endpoint: { value: "" },
|
|
1387
1400
|
pageTitle: { value: "fromcubes" },
|
|
1388
|
-
componentCode: {
|
|
1401
|
+
componentCode: {
|
|
1402
|
+
value: STARTER,
|
|
1403
|
+
validate: function (v) {
|
|
1404
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1389
1407
|
customHead: { value: "" },
|
|
1390
1408
|
portalAuth: { value: false },
|
|
1391
1409
|
showWsStatus: { value: false },
|
|
@@ -1423,7 +1441,8 @@ function useDebounce(value, ms = 300) {
|
|
|
1423
1441
|
*/
|
|
1424
1442
|
oneditprepare: function () {
|
|
1425
1443
|
const node = this;
|
|
1426
|
-
const code =
|
|
1444
|
+
const code =
|
|
1445
|
+
typeof node.componentCode === "string" ? node.componentCode : STARTER;
|
|
1427
1446
|
const headCode = node.customHead || "";
|
|
1428
1447
|
console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
|
|
1429
1448
|
|
|
@@ -2062,7 +2081,8 @@ const { data, send, user, portalClient } = useNodeRed();
|
|
|
2062
2081
|
<h3>Custom Head HTML</h3>
|
|
2063
2082
|
<p>
|
|
2064
2083
|
Inject CDN links, fonts, or extra stylesheets into
|
|
2065
|
-
<code><head></code>.
|
|
2084
|
+
<code><head></code>. This is raw trusted-author HTML; scripts and
|
|
2085
|
+
event handlers run in the public portal page. Example:
|
|
2066
2086
|
</p>
|
|
2067
2087
|
<pre>
|
|
2068
2088
|
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></pre
|