@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +308 -0
- package/nodes/lib/hooks.js +82 -0
- package/nodes/lib/page-builder.js +219 -0
- package/nodes/lib/router.js +56 -0
- package/nodes/portal-react.html +1139 -195
- package/nodes/portal-react.js +720 -349
- 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,219 @@
|
|
|
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
|
+
_ignoreRecovery: false,
|
|
47
|
+
_retries: 0,
|
|
48
|
+
_wasConnected: false,
|
|
49
|
+
_version: null,
|
|
50
|
+
_portalClient: null,
|
|
51
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
52
|
+
|
|
53
|
+
connect() {
|
|
54
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
55
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
56
|
+
this._ws = ws;
|
|
57
|
+
const s = document.getElementById('__cs');
|
|
58
|
+
|
|
59
|
+
ws.onopen = () => {
|
|
60
|
+
if (s) { s.textContent = 'fromcubes \u2022 connected'; s.className = 'ok'; }
|
|
61
|
+
this._retries = 0;
|
|
62
|
+
this._wasConnected = true;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
ws.onmessage = (e) => {
|
|
66
|
+
try {
|
|
67
|
+
const m = JSON.parse(e.data);
|
|
68
|
+
if (m.type === 'hello') {
|
|
69
|
+
this._portalClient = m.portalClient;
|
|
70
|
+
}
|
|
71
|
+
if (m.type === 'version') {
|
|
72
|
+
var hasOverlay = document.getElementById('__building_overlay') || document.getElementById('__error_overlay');
|
|
73
|
+
if (hasOverlay || (this._version && this._version !== m.hash)) { location.reload(); return; }
|
|
74
|
+
this._version = m.hash;
|
|
75
|
+
}
|
|
76
|
+
if (m.type === 'building') {
|
|
77
|
+
document.getElementById('root').style.display = 'none';
|
|
78
|
+
var eo = document.getElementById('__error_overlay');
|
|
79
|
+
if (eo) eo.remove();
|
|
80
|
+
if (!document.getElementById('__building_overlay')) {
|
|
81
|
+
var ov = document.createElement('div');
|
|
82
|
+
ov.id = '__building_overlay';
|
|
83
|
+
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';
|
|
84
|
+
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
85
|
+
+ '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
|
|
86
|
+
+ '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
|
|
87
|
+
document.body.appendChild(ov);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (m.type === 'error') {
|
|
91
|
+
document.getElementById('root').style.display = 'none';
|
|
92
|
+
var bo = document.getElementById('__building_overlay');
|
|
93
|
+
if (bo) bo.remove();
|
|
94
|
+
var ov = document.getElementById('__error_overlay');
|
|
95
|
+
if (!ov) {
|
|
96
|
+
ov = document.createElement('div');
|
|
97
|
+
ov.id = '__error_overlay';
|
|
98
|
+
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';
|
|
99
|
+
document.body.appendChild(ov);
|
|
100
|
+
}
|
|
101
|
+
ov.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px;font-size:24px">JSX Transpile Error</h1>'
|
|
102
|
+
+ '<p style="color:#888;margin-bottom:16px">Fix the component code in Node-RED and deploy again.</p>'
|
|
103
|
+
+ '<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">'
|
|
104
|
+
+ m.message.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
105
|
+
+ '</pre>'
|
|
106
|
+
+ '<p style="color:#4ade80;font-size:12px;margin-top:24px">Connected \\u2014 will reload on redeploy</p>';
|
|
107
|
+
}
|
|
108
|
+
if (m.type === 'data') {
|
|
109
|
+
this._lastData = m.payload;
|
|
110
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
111
|
+
}
|
|
112
|
+
if (m.type === 'recovery') {
|
|
113
|
+
// Cached last broadcast at connect time. Seeded into
|
|
114
|
+
// _lastData unless the page opted out via
|
|
115
|
+
// useNodeRed({ ignoreRecovery: true }).
|
|
116
|
+
if (this._ignoreRecovery) return;
|
|
117
|
+
this._lastData = m.payload;
|
|
118
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
119
|
+
}
|
|
120
|
+
} catch (err) { console.error('WS parse', err); }
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
ws.onclose = () => {
|
|
124
|
+
if (s) { s.textContent = 'fromcubes \u2022 disconnected'; s.className = 'err'; }
|
|
125
|
+
this._ws = null;
|
|
126
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
127
|
+
this._retries++;
|
|
128
|
+
setTimeout(() => this.connect(), delay);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
ws.onerror = () => ws.close();
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
subscribe(fn) {
|
|
135
|
+
this._listeners.add(fn);
|
|
136
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
137
|
+
return () => this._listeners.delete(fn);
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
send(payload, topic) {
|
|
141
|
+
if (this._ws && this._ws.readyState === 1)
|
|
142
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
window.__NR.connect();
|
|
146
|
+
<\/script>
|
|
147
|
+
<script>
|
|
148
|
+
try { ${escScript(transpiledJs)}
|
|
149
|
+
} catch(__e) {
|
|
150
|
+
var __r = document.getElementById('root');
|
|
151
|
+
__r.style.cssText = 'font-family:monospace;background:#1a0000;color:#f87171;padding:40px;min-height:100vh;margin:0';
|
|
152
|
+
__r.innerHTML = '<h1 style="color:#ff4444;margin-bottom:16px">Runtime Error</h1>'
|
|
153
|
+
+ '<p style="color:#888">Fix the component code in Node-RED and deploy again.</p>'
|
|
154
|
+
+ '<pre style="background:#0a0a0a;border:1px solid #ff4444;border-radius:8px;padding:20px;overflow-x:auto;color:#fca5a5;white-space:pre-wrap">'
|
|
155
|
+
+ (__e.message || __e).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
|
156
|
+
}
|
|
157
|
+
<\/script>
|
|
158
|
+
</body>
|
|
159
|
+
</html>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildErrorPage(title, error, wsPath) {
|
|
163
|
+
return `<!DOCTYPE html>
|
|
164
|
+
<html lang="en">
|
|
165
|
+
<head>
|
|
166
|
+
<meta charset="UTF-8">
|
|
167
|
+
<title>${esc(title)} — Error</title>
|
|
168
|
+
<style>
|
|
169
|
+
body { font-family: monospace; background: #1a0000; color: #f87171; padding: 40px; line-height: 1.6 }
|
|
170
|
+
h1 { color: #ff4444; margin-bottom: 16px }
|
|
171
|
+
pre { background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px; padding: 20px; overflow-x: auto; color: #fca5a5 }
|
|
172
|
+
.status { color: #888; font-size: 12px; margin-top: 24px }
|
|
173
|
+
.status.ok { color: #4ade80 }
|
|
174
|
+
</style>
|
|
175
|
+
</head>
|
|
176
|
+
<body>
|
|
177
|
+
<h1>JSX Transpile Error</h1>
|
|
178
|
+
<p>Fix the component code in Node-RED and deploy again.</p>
|
|
179
|
+
<pre>${esc(error)}</pre>
|
|
180
|
+
<p class="status" id="st">Waiting for redeploy…</p>
|
|
181
|
+
<script>
|
|
182
|
+
(function() {
|
|
183
|
+
var st = document.getElementById('st');
|
|
184
|
+
var retries = 0;
|
|
185
|
+
function connect() {
|
|
186
|
+
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
187
|
+
var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
188
|
+
ws.onopen = function() {
|
|
189
|
+
retries = 0;
|
|
190
|
+
if (st) { st.textContent = 'Connected \\u2014 will reload on redeploy'; st.className = 'status ok'; }
|
|
191
|
+
};
|
|
192
|
+
ws.onmessage = function(e) {
|
|
193
|
+
try {
|
|
194
|
+
var m = JSON.parse(e.data);
|
|
195
|
+
if (m.type === 'version' && m.hash) location.reload();
|
|
196
|
+
if (m.type === 'error') { var pre = document.querySelector('pre'); if (pre) pre.textContent = m.message; }
|
|
197
|
+
} catch(_) {}
|
|
198
|
+
};
|
|
199
|
+
ws.onclose = function() {
|
|
200
|
+
if (st) { st.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; st.className = 'status'; }
|
|
201
|
+
var delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
202
|
+
retries++;
|
|
203
|
+
setTimeout(connect, delay);
|
|
204
|
+
};
|
|
205
|
+
ws.onerror = function() { ws.close(); };
|
|
206
|
+
}
|
|
207
|
+
${wsPath ? "connect();" : ""}
|
|
208
|
+
setInterval(function() {
|
|
209
|
+
fetch(location.href, { method: 'HEAD', cache: 'no-store' })
|
|
210
|
+
.then(function(r) { if (r.ok) location.reload(); })
|
|
211
|
+
.catch(function() {});
|
|
212
|
+
}, 3000);
|
|
213
|
+
})();
|
|
214
|
+
<\/script>
|
|
215
|
+
</body>
|
|
216
|
+
</html>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { buildPage, buildErrorPage };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure routing function for portal-react WS outbound messages.
|
|
3
|
+
*
|
|
4
|
+
* Split out from portal-react.js so it can be unit-tested without a full
|
|
5
|
+
* Node-RED runtime.
|
|
6
|
+
*
|
|
7
|
+
* Routing modes, in priority order:
|
|
8
|
+
* 1. msg._client.portalClient → unicast to that one session
|
|
9
|
+
* 2. msg._client.userId → user-cast (O(1) via userIndex)
|
|
10
|
+
* 3. msg._client.username → user-cast fallback (O(N) scan)
|
|
11
|
+
* 4. otherwise → broadcast
|
|
12
|
+
*
|
|
13
|
+
* Returns a shallow summary { mode, delivered } for observability/tests.
|
|
14
|
+
* The caller is responsible for any side-effects keyed off the mode
|
|
15
|
+
* (e.g. caching the last broadcast payload for new-client recovery).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function route(msg, ctx) {
|
|
19
|
+
const { clients, userIndex, sendTo } = ctx;
|
|
20
|
+
const target = msg && msg._client;
|
|
21
|
+
const frame = JSON.stringify({ type: "data", payload: msg.payload });
|
|
22
|
+
|
|
23
|
+
let delivered = 0;
|
|
24
|
+
|
|
25
|
+
if (target && target.portalClient) {
|
|
26
|
+
if (sendTo(clients.get(target.portalClient), frame, msg)) delivered++;
|
|
27
|
+
return { mode: "unicast", delivered };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (target && target.userId) {
|
|
31
|
+
const set = userIndex.get(target.userId);
|
|
32
|
+
if (set) {
|
|
33
|
+
set.forEach((ws) => {
|
|
34
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { mode: "user-cast", delivered };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (target && target.username) {
|
|
41
|
+
clients.forEach((ws) => {
|
|
42
|
+
if (ws._portalUser && ws._portalUser.username === target.username) {
|
|
43
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return { mode: "user-cast", delivered };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Broadcast.
|
|
50
|
+
clients.forEach((ws) => {
|
|
51
|
+
if (sendTo(ws, frame, msg)) delivered++;
|
|
52
|
+
});
|
|
53
|
+
return { mode: "broadcast", delivered };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { route };
|