@aaqu/fromcubes-portal-react 0.1.0-alpha.24 → 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 +3 -2
- package/nodes/lib/admin-api.js +160 -0
- package/nodes/lib/helpers.js +16 -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 +72 -630
- 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.
|
|
@@ -227,7 +227,8 @@ Import **Shared Components** first — it provides the UI building blocks (Page,
|
|
|
227
227
|
| Red status "legacy endpoint" on deploy | Flow was saved before the `/fromcubes/` prefix became hardcoded. Open the node, set **Sub-path**, redeploy. Automatic migration is disabled to avoid silent URL changes. |
|
|
228
228
|
| Red status "bad sub-path" | Sub-path is empty or violates the rules (no leading `/`, no whitespace, no `..`, segments must start alphanumerically, `public`/`_ws` reserved). |
|
|
229
229
|
| Yellow status "css-fail" | Tailwind generation failed (usually an invalid class in JSX). Page still loads but unstyled. Fix the class, redeploy — the status clears on the next successful build. |
|
|
230
|
-
|
|
|
230
|
+
| Code editor stays blank / `portal-react/vs/loader.js` 404s | Monaco is served live from the package's own `node_modules` at `/portal-react/vs` (no postinstall copy step). The 404 means `RED.httpAdmin` is mounted under a non-root `httpAdminRoot` the editor didn't account for, or `monaco-editor` failed to install. Reinstall the package and hard-refresh the editor (`Cmd/Ctrl-Shift-R`). |
|
|
231
|
+
| `npm install @aaqu/fromcubes-portal-react` ends with `EACCES` | The Node-RED `userDir/node_modules` install path is not writable by the user running Node-RED. Re-run `npm install` from a shell with the right ownership. |
|
|
231
232
|
| Browser request `/portal-react/css/<hash>.css` returns 404 | The portal's deploy hasn't produced a CSS bundle yet — open the editor, redeploy. If the URL is bookmarked from before a deploy, the hash is stale; reload the portal page itself. |
|
|
232
233
|
| WebSocket reconnects in an endless loop | Reverse-proxy is not forwarding `Upgrade: websocket` on `/fromcubes/<sub-path>/_ws`. Check the proxy config — nginx needs `proxy_set_header Upgrade $http_upgrade`, Traefik needs the `websocket` middleware. |
|
|
233
234
|
| `libs` packages fail to install on deploy | The user-installed npm packages declared in **Libs** install via Node-RED's `dynamicModuleList` mechanism, which needs network access from `userDir` and a writable `node_modules`. Behind a corporate proxy set `npm config set proxy …` for the Node-RED user. |
|
|
@@ -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 };
|
package/nodes/lib/helpers.js
CHANGED
|
@@ -387,6 +387,20 @@ function serveableHash(state) {
|
|
|
387
387
|
return state.compiled.js ? state.contentHash || "" : "";
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* True only when `state` holds a real, serveable build. Used by the no-op
|
|
392
|
+
* redeploy guard to decide whether a rebuild can be skipped. MUST be false
|
|
393
|
+
* after close() nulls `compiled` (`{ ...compiled: null }` teardown window),
|
|
394
|
+
* otherwise the guard treats the destroyed build as valid, skips the rebuild,
|
|
395
|
+
* and the GET route serves the holding page forever (permanent spinner).
|
|
396
|
+
*
|
|
397
|
+
* @param {?PageState} state pageState[endpoint] (may be null/torn down).
|
|
398
|
+
* @returns {boolean}
|
|
399
|
+
*/
|
|
400
|
+
function hasFreshBuild(state) {
|
|
401
|
+
return !!state && !state.building && !!state.compiled && !state.compiled.error;
|
|
402
|
+
}
|
|
403
|
+
|
|
390
404
|
module.exports = function (RED) {
|
|
391
405
|
return createHelpers(RED);
|
|
392
406
|
};
|
|
@@ -398,6 +412,7 @@ module.exports.formatEsbuildError = formatEsbuildError;
|
|
|
398
412
|
module.exports.extractPortalUser = extractPortalUser;
|
|
399
413
|
module.exports.findMissingComponentRefs = findMissingComponentRefs;
|
|
400
414
|
module.exports.serveableHash = serveableHash;
|
|
415
|
+
module.exports.hasFreshBuild = hasFreshBuild;
|
|
401
416
|
module.exports.NAME_MAX_LEN = NAME_MAX_LEN;
|
|
402
417
|
module.exports.MAX_GROUPS_HEADER_BYTES = MAX_GROUPS_HEADER_BYTES;
|
|
403
418
|
|
|
@@ -619,6 +634,7 @@ function createHelpers(RED) {
|
|
|
619
634
|
generateCSS,
|
|
620
635
|
extractPortalUser,
|
|
621
636
|
serveableHash,
|
|
637
|
+
hasFreshBuild,
|
|
622
638
|
removeRoute,
|
|
623
639
|
isSafeName,
|
|
624
640
|
validateSubPath,
|
|
@@ -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 };
|