@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,347 @@
|
|
|
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
|
+
const ERROR_OVERLAY_CSS = `
|
|
18
|
+
#__error_overlay {
|
|
19
|
+
position: fixed; inset: 0; z-index: 99999;
|
|
20
|
+
background: #1a0000; color: #f87171;
|
|
21
|
+
font-family: monospace; padding: 40px;
|
|
22
|
+
overflow: auto;
|
|
23
|
+
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
|
|
24
|
+
}
|
|
25
|
+
#__error_overlay h1 { color: #ff4444; margin: 0 0 16px; font-size: 24px }
|
|
26
|
+
#__error_overlay p.__hint { color: #888; margin: 0 0 16px }
|
|
27
|
+
#__error_overlay pre {
|
|
28
|
+
background: #0a0a0a; border: 1px solid #ff4444; border-radius: 8px;
|
|
29
|
+
padding: 20px; color: #fca5a5;
|
|
30
|
+
max-width: 90vw; max-height: 60vh; overflow: auto;
|
|
31
|
+
white-space: pre-wrap; margin: 0;
|
|
32
|
+
}
|
|
33
|
+
#__error_overlay p.__status { color: #4ade80; font-size: 12px; margin: 24px 0 0 }
|
|
34
|
+
#__error_overlay p.__status.__off { color: #888 }
|
|
35
|
+
#__error_banner {
|
|
36
|
+
position: fixed; top: 8px; right: 8px; z-index: 99998;
|
|
37
|
+
max-width: 360px; padding: 8px 12px;
|
|
38
|
+
background: #1a0000; color: #fca5a5;
|
|
39
|
+
border: 1px solid #ff4444; border-radius: 6px;
|
|
40
|
+
font-family: monospace; font-size: 12px;
|
|
41
|
+
box-shadow: 0 4px 12px rgba(0,0,0,.4);
|
|
42
|
+
cursor: pointer; user-select: none;
|
|
43
|
+
}
|
|
44
|
+
#__error_banner b { color: #ff4444; display: block; margin-bottom: 2px }
|
|
45
|
+
#__error_banner.__expanded {
|
|
46
|
+
max-width: 70vw; max-height: 60vh; overflow: auto;
|
|
47
|
+
cursor: default;
|
|
48
|
+
}
|
|
49
|
+
#__error_banner pre {
|
|
50
|
+
margin: 8px 0 0; padding: 8px; background: #0a0a0a;
|
|
51
|
+
border-radius: 4px; white-space: pre-wrap; display: none;
|
|
52
|
+
}
|
|
53
|
+
#__error_banner.__expanded pre { display: block }
|
|
54
|
+
#__error_banner .__close {
|
|
55
|
+
float: right; padding: 0 4px; color: #888; cursor: pointer;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
// Shared error overlay markup (HTML inside #__error_overlay).
|
|
60
|
+
// Used by buildPage (WS error frame, runtime try/catch) and buildErrorPage.
|
|
61
|
+
function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
|
|
62
|
+
return (
|
|
63
|
+
`<h1>${esc(title)}</h1>` +
|
|
64
|
+
(hint ? `<p class="__hint">${esc(hint)}</p>` : "") +
|
|
65
|
+
`<pre>${esc(message)}</pre>` +
|
|
66
|
+
(statusLine
|
|
67
|
+
? `<p class="__status${statusOk ? "" : " __off"}" id="__err_status">${esc(statusLine)}</p>`
|
|
68
|
+
: "")
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
|
|
73
|
+
|
|
74
|
+
function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
|
|
75
|
+
return `<!DOCTYPE html>
|
|
76
|
+
<html lang="en">
|
|
77
|
+
<head>
|
|
78
|
+
<meta charset="UTF-8">
|
|
79
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
80
|
+
<title>${esc(title)}</title>
|
|
81
|
+
${cssHash ? `<link rel="stylesheet" href="${adminRoot}/portal-react/css/${cssHash}.css">` : ""}
|
|
82
|
+
${escScript(customHead)}
|
|
83
|
+
<style>${ERROR_OVERLAY_CSS}</style>
|
|
84
|
+
${showWsStatus ? `<style>
|
|
85
|
+
#__cs {
|
|
86
|
+
position: fixed; bottom: 6px; right: 6px;
|
|
87
|
+
padding: 3px 8px; font-size: 10px; border-radius: 3px;
|
|
88
|
+
z-index: 99999; background: #111; border: 1px solid #333;
|
|
89
|
+
opacity: .7; transition: opacity .2s;
|
|
90
|
+
}
|
|
91
|
+
#__cs:hover { opacity: 1 }
|
|
92
|
+
#__cs.ok { color: #4ade80 }
|
|
93
|
+
#__cs.err { color: #f87171 }
|
|
94
|
+
</style>` : ""}
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div id="root"></div>
|
|
98
|
+
${showWsStatus ? `<div id="__cs" class="err">fromcubes</div>` : ""}
|
|
99
|
+
<script>
|
|
100
|
+
function __safe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
101
|
+
function __renderErrorOverlay(title, message, hint) {
|
|
102
|
+
var root = document.getElementById('root');
|
|
103
|
+
if (root) root.style.display = 'none';
|
|
104
|
+
var bo = document.getElementById('__building_overlay');
|
|
105
|
+
if (bo) bo.remove();
|
|
106
|
+
var bn = document.getElementById('__error_banner');
|
|
107
|
+
if (bn) bn.remove();
|
|
108
|
+
var ov = document.getElementById('__error_overlay');
|
|
109
|
+
if (!ov) { ov = document.createElement('div'); ov.id = '__error_overlay'; document.body.appendChild(ov); }
|
|
110
|
+
ov.innerHTML = '<h1>' + __safe(title) + '</h1>'
|
|
111
|
+
+ (hint ? '<p class="__hint">' + __safe(hint) + '</p>' : '')
|
|
112
|
+
+ '<pre>' + __safe(message) + '</pre>'
|
|
113
|
+
+ '<p class="__status __off" id="__err_status">Waiting for redeploy\\u2026</p>';
|
|
114
|
+
}
|
|
115
|
+
function __renderErrorBanner(message) {
|
|
116
|
+
var ov = document.getElementById('__error_overlay');
|
|
117
|
+
if (ov) ov.remove();
|
|
118
|
+
var bo = document.getElementById('__building_overlay');
|
|
119
|
+
if (bo) bo.remove();
|
|
120
|
+
var root = document.getElementById('root');
|
|
121
|
+
if (root) root.style.display = '';
|
|
122
|
+
var bn = document.getElementById('__error_banner');
|
|
123
|
+
if (!bn) {
|
|
124
|
+
bn = document.createElement('div');
|
|
125
|
+
bn.id = '__error_banner';
|
|
126
|
+
document.body.appendChild(bn);
|
|
127
|
+
bn.addEventListener('click', function(e) {
|
|
128
|
+
if (e.target && e.target.className === '__close') { bn.remove(); return; }
|
|
129
|
+
bn.classList.toggle('__expanded');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
bn.innerHTML = '<span class="__close" title="Dismiss">\\u00d7</span>'
|
|
133
|
+
+ '<b>\\u26a0 Latest deploy failed</b>'
|
|
134
|
+
+ '<span>Running previous version. Click for details.</span>'
|
|
135
|
+
+ '<pre>' + __safe(message) + '</pre>';
|
|
136
|
+
}
|
|
137
|
+
function __clearErrorOverlay() {
|
|
138
|
+
var ov = document.getElementById('__error_overlay');
|
|
139
|
+
if (ov) ov.remove();
|
|
140
|
+
var bn = document.getElementById('__error_banner');
|
|
141
|
+
if (bn) bn.remove();
|
|
142
|
+
var root = document.getElementById('root');
|
|
143
|
+
if (root) root.style.display = '';
|
|
144
|
+
}
|
|
145
|
+
window.__NR = {
|
|
146
|
+
_ws: null,
|
|
147
|
+
_listeners: new Set(),
|
|
148
|
+
_lastData: null,
|
|
149
|
+
_ignoreRecovery: false,
|
|
150
|
+
_retries: 0,
|
|
151
|
+
_wasConnected: false,
|
|
152
|
+
_version: null,
|
|
153
|
+
_portalClient: null,
|
|
154
|
+
// Set on building/error WS frames. Next version with real hash reloads,
|
|
155
|
+
// independent of hash diff (build may produce same hash again).
|
|
156
|
+
_buildErrorActive: false,
|
|
157
|
+
// Pending runtime error message captured before WS opened.
|
|
158
|
+
// Flushed in onopen so node status can go red even when the
|
|
159
|
+
// exception fires synchronously during initial bundle execution.
|
|
160
|
+
_pendingRuntimeError: null,
|
|
161
|
+
_user: ${user ? escScript(JSON.stringify(user)) : "null"},
|
|
162
|
+
|
|
163
|
+
connect() {
|
|
164
|
+
const p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
165
|
+
const ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
166
|
+
this._ws = ws;
|
|
167
|
+
const s = document.getElementById('__cs');
|
|
168
|
+
|
|
169
|
+
ws.onopen = () => {
|
|
170
|
+
if (s) { s.textContent = 'fromcubes • connected'; s.className = 'ok'; }
|
|
171
|
+
this._retries = 0;
|
|
172
|
+
this._wasConnected = true;
|
|
173
|
+
var es = document.getElementById('__err_status');
|
|
174
|
+
if (es) { es.textContent = 'Connected \\u2014 will reload on redeploy'; es.className = '__status'; }
|
|
175
|
+
if (this._pendingRuntimeError) {
|
|
176
|
+
try { ws.send(JSON.stringify({ type: 'runtime_error', message: this._pendingRuntimeError })); } catch(_) {}
|
|
177
|
+
this._pendingRuntimeError = null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
ws.onmessage = (e) => {
|
|
182
|
+
try {
|
|
183
|
+
const m = JSON.parse(e.data);
|
|
184
|
+
if (m.type === 'hello') {
|
|
185
|
+
this._portalClient = m.portalClient;
|
|
186
|
+
}
|
|
187
|
+
if (m.type === 'version') {
|
|
188
|
+
// Empty hash = server still in building/error state, ignore (overlay stays).
|
|
189
|
+
if (!m.hash) return;
|
|
190
|
+
// Reload when server was in build-error/building (recover regardless of
|
|
191
|
+
// hash) OR when hash differs from prior known hash (deploy detected).
|
|
192
|
+
// Do NOT reload merely because an overlay is in the DOM: a runtime
|
|
193
|
+
// exception caught locally renders the same overlay node and would
|
|
194
|
+
// otherwise loop reload to runtime-error to reload forever.
|
|
195
|
+
if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
|
|
196
|
+
location.reload();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this._version = m.hash;
|
|
200
|
+
}
|
|
201
|
+
if (m.type === 'building') {
|
|
202
|
+
this._buildErrorActive = true;
|
|
203
|
+
document.getElementById('root').style.display = 'none';
|
|
204
|
+
var eo = document.getElementById('__error_overlay');
|
|
205
|
+
if (eo) eo.remove();
|
|
206
|
+
if (!document.getElementById('__building_overlay')) {
|
|
207
|
+
var ov = document.createElement('div');
|
|
208
|
+
ov.id = '__building_overlay';
|
|
209
|
+
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';
|
|
210
|
+
ov.innerHTML = '<div style="font-size:24px;margin-bottom:16px">Building\\u2026</div>'
|
|
211
|
+
+ '<div style="width:40px;height:40px;border:3px solid #333;border-top-color:#888;border-radius:50%;animation:__sp .8s linear infinite"></div>'
|
|
212
|
+
+ '<style>@keyframes __sp{to{transform:rotate(360deg)}}</style>';
|
|
213
|
+
document.body.appendChild(ov);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (m.type === 'error') {
|
|
217
|
+
this._buildErrorActive = true;
|
|
218
|
+
if (m.degraded) {
|
|
219
|
+
__renderErrorBanner(m.message);
|
|
220
|
+
} else {
|
|
221
|
+
__renderErrorOverlay('Build Error', m.message, ${JSON.stringify(DEFAULT_HINT)});
|
|
222
|
+
var es2 = document.getElementById('__err_status');
|
|
223
|
+
if (es2) { es2.textContent = 'Connected \\u2014 will reload on redeploy'; es2.className = '__status'; }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (m.type === 'data') {
|
|
227
|
+
this._lastData = m.payload;
|
|
228
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
229
|
+
}
|
|
230
|
+
if (m.type === 'recovery') {
|
|
231
|
+
// Cached last broadcast at connect time. Seeded into
|
|
232
|
+
// _lastData unless the page opted out via
|
|
233
|
+
// useNodeRed({ ignoreRecovery: true }).
|
|
234
|
+
if (this._ignoreRecovery) return;
|
|
235
|
+
this._lastData = m.payload;
|
|
236
|
+
this._listeners.forEach(fn => fn(m.payload));
|
|
237
|
+
}
|
|
238
|
+
} catch (err) { console.error('WS parse', err); }
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
ws.onclose = () => {
|
|
242
|
+
if (s) { s.textContent = 'fromcubes • disconnected'; s.className = 'err'; }
|
|
243
|
+
this._ws = null;
|
|
244
|
+
var es = document.getElementById('__err_status');
|
|
245
|
+
if (es) { es.textContent = 'Disconnected \\u2014 reconnecting\\u2026'; es.className = '__status __off'; }
|
|
246
|
+
const delay = Math.min(500 * Math.pow(2, this._retries), 8000);
|
|
247
|
+
this._retries++;
|
|
248
|
+
setTimeout(() => this.connect(), delay);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
ws.onerror = () => ws.close();
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
subscribe(fn) {
|
|
255
|
+
this._listeners.add(fn);
|
|
256
|
+
if (this._lastData !== null) fn(this._lastData);
|
|
257
|
+
return () => this._listeners.delete(fn);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
send(payload, topic) {
|
|
261
|
+
if (this._ws && this._ws.readyState === 1)
|
|
262
|
+
this._ws.send(JSON.stringify({ type: 'output', payload, topic: topic || '' }));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
window.__NR.connect();
|
|
266
|
+
<\/script>
|
|
267
|
+
<script>
|
|
268
|
+
try { ${escScript(transpiledJs)}
|
|
269
|
+
} catch(__e) {
|
|
270
|
+
var __m = (__e && (__e.stack || __e.message)) || String(__e);
|
|
271
|
+
__renderErrorOverlay('Runtime Error', __m, ${JSON.stringify(DEFAULT_HINT)});
|
|
272
|
+
// Report back to server so node status goes red. WS may not be open
|
|
273
|
+
// yet (sync throw during initial bundle); queue until onopen.
|
|
274
|
+
try {
|
|
275
|
+
if (window.__NR && window.__NR._ws && window.__NR._ws.readyState === 1) {
|
|
276
|
+
window.__NR._ws.send(JSON.stringify({ type: 'runtime_error', message: __m }));
|
|
277
|
+
} else if (window.__NR) {
|
|
278
|
+
window.__NR._pendingRuntimeError = __m;
|
|
279
|
+
}
|
|
280
|
+
} catch(_) {}
|
|
281
|
+
}
|
|
282
|
+
<\/script>
|
|
283
|
+
</body>
|
|
284
|
+
</html>`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function buildErrorPage(title, error, wsPath) {
|
|
288
|
+
return `<!DOCTYPE html>
|
|
289
|
+
<html lang="en">
|
|
290
|
+
<head>
|
|
291
|
+
<meta charset="UTF-8">
|
|
292
|
+
<title>${esc(title)} — Error</title>
|
|
293
|
+
<style>${ERROR_OVERLAY_CSS}</style>
|
|
294
|
+
</head>
|
|
295
|
+
<body>
|
|
296
|
+
<div id="__error_overlay">${errorOverlayInnerHtml({
|
|
297
|
+
title: "Build Error",
|
|
298
|
+
hint: DEFAULT_HINT,
|
|
299
|
+
message: error,
|
|
300
|
+
statusLine: "Waiting for redeploy…",
|
|
301
|
+
statusOk: false,
|
|
302
|
+
})}</div>
|
|
303
|
+
<script>
|
|
304
|
+
(function() {
|
|
305
|
+
var st = document.getElementById('__err_status');
|
|
306
|
+
var pre = document.querySelector('#__error_overlay pre');
|
|
307
|
+
var retries = 0;
|
|
308
|
+
function setStatus(text, ok) {
|
|
309
|
+
if (!st) return;
|
|
310
|
+
st.textContent = text;
|
|
311
|
+
st.className = '__status' + (ok ? '' : ' __off');
|
|
312
|
+
}
|
|
313
|
+
function connect() {
|
|
314
|
+
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
315
|
+
var ws = new WebSocket(p + '//' + location.host + '${wsPath}');
|
|
316
|
+
ws.onopen = function() {
|
|
317
|
+
retries = 0;
|
|
318
|
+
setStatus('Connected \\u2014 will reload on redeploy', true);
|
|
319
|
+
};
|
|
320
|
+
ws.onmessage = function(e) {
|
|
321
|
+
try {
|
|
322
|
+
var m = JSON.parse(e.data);
|
|
323
|
+
if (m.type === 'version' && m.hash) location.reload();
|
|
324
|
+
if (m.type === 'error' && pre) pre.textContent = m.message;
|
|
325
|
+
} catch(_) {}
|
|
326
|
+
};
|
|
327
|
+
ws.onclose = function() {
|
|
328
|
+
setStatus('Disconnected \\u2014 reconnecting\\u2026', false);
|
|
329
|
+
var delay = Math.min(500 * Math.pow(2, retries), 8000);
|
|
330
|
+
retries++;
|
|
331
|
+
setTimeout(connect, delay);
|
|
332
|
+
};
|
|
333
|
+
ws.onerror = function() { ws.close(); };
|
|
334
|
+
}
|
|
335
|
+
${wsPath ? "connect();" : ""}
|
|
336
|
+
setInterval(function() {
|
|
337
|
+
fetch(location.href, { method: 'HEAD', cache: 'no-store' })
|
|
338
|
+
.then(function(r) { if (r.ok) location.reload(); })
|
|
339
|
+
.catch(function() {});
|
|
340
|
+
}, 3000);
|
|
341
|
+
})();
|
|
342
|
+
<\/script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
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 };
|