cynx-lib 1.3.83 → 1.3.85
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/gen.js +77 -77
- package/package.json +5 -1
- package/ui/components/Drawer.jsx +208 -208
- package/ui/components/Layout.jsx +37 -0
- package/ui/components/Pagination.jsx +145 -145
- package/ui/components/PrivacyBlur.jsx +603 -603
- package/ui/components/Sidebar.jsx +300 -134
- package/utils/index.cjs +5 -0
- package/utils/index.js +22 -20
- package/utils/utils/duration.js +139 -139
- package/utils/utils/oidc.js +214 -214
- package/utils/utils/realtime.js +1634 -1634
- package/utils/utils/session.cjs +15 -2
- package/utils/utils/session.js +67 -58
- package/utils/utils/session_manager.cjs +490 -0
- package/utils/utils/session_manager.js +549 -0
- package/utils/utils/storage.js +393 -393
- package/utils/utils/ui.js +111 -111
- package/vue/components/Badge.vue +79 -79
- package/vue/components/Button.vue +209 -209
- package/vue/components/Checkbox.vue +103 -103
- package/vue/components/CircularProgress.vue +383 -383
- package/vue/components/Divider.vue +94 -94
- package/vue/components/Drawer.vue +243 -243
- package/vue/components/InfoBadge.vue +223 -223
- package/vue/components/Input.vue +341 -341
- package/vue/components/LinearProgress.vue +268 -268
- package/vue/components/Modal.vue +226 -226
- package/vue/components/Pagination.vue +199 -199
- package/vue/components/Select.vue +713 -713
- package/vue/components/Sidebar.vue +955 -955
- package/vue/components/Skeleton.vue +91 -91
- package/vue/components/Spinner.vue +263 -263
- package/vue/components/StatusTag.vue +89 -89
- package/vue/components/TabBar.vue +176 -176
- package/vue/components/Table.vue +388 -388
- package/vue/components/TimePicker.vue +399 -399
- package/vue/components/Toggle.vue +115 -115
- package/vue/components/XTable.vue +119 -119
- package/vue/composables/useAutoSkeletonRows.ts +71 -71
- package/vue/index.d.ts +67 -67
- package/vue/index.js +86 -86
package/utils/utils/realtime.js
CHANGED
|
@@ -1,1634 +1,1634 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* cynx/utils — Universal Realtime Engine (realtime.js)
|
|
3
|
-
*
|
|
4
|
-
* All-in-one realtime utility covering:
|
|
5
|
-
* 1. Smart WebSocket client (auto-reconnect with backoff, heartbeat/ping-pong, offline queue, pub/sub, request-response)
|
|
6
|
-
* 2. SSE (Server-Sent Events) streamer with AI stream parser
|
|
7
|
-
* 3. Cross-Tab / Cross-Window Synchronization (BroadcastChannel + LocalStorage fallback)
|
|
8
|
-
* 4. Smart Poller (pause on hidden tab, backoff, jitter)
|
|
9
|
-
* 5. Lightweight Event Bus / PubSub
|
|
10
|
-
*
|
|
11
|
-
* Compatible with Browser, Node.js, SSR, Web Workers.
|
|
12
|
-
* Zero external dependencies.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { uid } from './uid.js';
|
|
16
|
-
|
|
17
|
-
// ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
const isBrowser = typeof window !== 'undefined';
|
|
20
|
-
const isDocAvailable = typeof document !== 'undefined';
|
|
21
|
-
|
|
22
|
-
function safeJSONParse(str, fallback = null) {
|
|
23
|
-
try {
|
|
24
|
-
return JSON.parse(str);
|
|
25
|
-
} catch {
|
|
26
|
-
return fallback !== null ? fallback : str;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function getWebSocketConstructor(customWs) {
|
|
31
|
-
if (customWs) return customWs;
|
|
32
|
-
if (typeof WebSocket !== 'undefined') return WebSocket;
|
|
33
|
-
if (typeof globalThis !== 'undefined' && globalThis.WebSocket) return globalThis.WebSocket;
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ─── 1. EVENT BUS (LIGHTWEIGHT PUB/SUB) ─────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
export class EventBus {
|
|
40
|
-
constructor() {
|
|
41
|
-
this._listeners = new Map();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
on(event, callback) {
|
|
45
|
-
if (typeof callback !== 'function') return () => {};
|
|
46
|
-
if (!this._listeners.has(event)) {
|
|
47
|
-
this._listeners.set(event, new Set());
|
|
48
|
-
}
|
|
49
|
-
this._listeners.get(event).add(callback);
|
|
50
|
-
return () => this.off(event, callback);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
once(event, callback) {
|
|
54
|
-
const unsub = this.on(event, (...args) => {
|
|
55
|
-
unsub();
|
|
56
|
-
callback(...args);
|
|
57
|
-
});
|
|
58
|
-
return unsub;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
off(event, callback) {
|
|
62
|
-
if (!this._listeners.has(event)) return;
|
|
63
|
-
if (!callback) {
|
|
64
|
-
this._listeners.delete(event);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
const set = this._listeners.get(event);
|
|
68
|
-
set.delete(callback);
|
|
69
|
-
if (set.size === 0) this._listeners.delete(event);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
emit(event, ...args) {
|
|
73
|
-
// Direct matches
|
|
74
|
-
if (this._listeners.has(event)) {
|
|
75
|
-
for (const cb of this._listeners.get(event)) {
|
|
76
|
-
try {
|
|
77
|
-
cb(...args);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
console.error(`[cynx/realtime] Error in handler for event "${event}":`, err);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Wildcard matches (e.g. '*')
|
|
84
|
-
if (event !== '*' && this._listeners.has('*')) {
|
|
85
|
-
for (const cb of this._listeners.get('*')) {
|
|
86
|
-
try {
|
|
87
|
-
cb(event, ...args);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
console.error('[cynx/realtime] Error in wildcard handler:', err);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
clear() {
|
|
96
|
-
this._listeners.clear();
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function createEventBus() {
|
|
101
|
-
return new EventBus();
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ─── 2. SMART WEBSOCKET CLIENT ──────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Universal WebSocket Client with:
|
|
108
|
-
* - Auto-reconnect with exponential backoff & random jitter
|
|
109
|
-
* - Heartbeat (Ping/Pong keep-alive)
|
|
110
|
-
* - Offline message queue
|
|
111
|
-
* - Event-based messaging (topic/event pub-sub)
|
|
112
|
-
* - Request-Response pattern over WebSocket
|
|
113
|
-
*/
|
|
114
|
-
export class RealtimeSocket {
|
|
115
|
-
constructor(url, options = {}) {
|
|
116
|
-
this.url = url;
|
|
117
|
-
this.options = {
|
|
118
|
-
reconnect: true,
|
|
119
|
-
maxRetries: Infinity,
|
|
120
|
-
initialRetryDelay: 1000,
|
|
121
|
-
maxRetryDelay: 30000,
|
|
122
|
-
backoffFactor: 1.5,
|
|
123
|
-
jitter: true,
|
|
124
|
-
protocols: undefined,
|
|
125
|
-
heartbeat: {
|
|
126
|
-
enabled: true,
|
|
127
|
-
interval: 25000,
|
|
128
|
-
pingPayload: JSON.stringify({ type: 'ping' }),
|
|
129
|
-
pongPattern: 'pong',
|
|
130
|
-
timeout: 10000,
|
|
131
|
-
},
|
|
132
|
-
queueOffline: true,
|
|
133
|
-
maxQueueSize: 100,
|
|
134
|
-
autoConnect: true,
|
|
135
|
-
WebSocket: undefined,
|
|
136
|
-
...options,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
this.events = new EventBus();
|
|
140
|
-
this.ws = null;
|
|
141
|
-
this.status = 'DISCONNECTED'; // 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'RECONNECTING'
|
|
142
|
-
this.retries = 0;
|
|
143
|
-
this._offlineQueue = [];
|
|
144
|
-
this._pendingRequests = new Map();
|
|
145
|
-
this._heartbeatTimer = null;
|
|
146
|
-
this._heartbeatTimeoutTimer = null;
|
|
147
|
-
this._reconnectTimer = null;
|
|
148
|
-
this._isExplicitClose = false;
|
|
149
|
-
|
|
150
|
-
if (this.options.autoConnect) {
|
|
151
|
-
this.connect();
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
connect() {
|
|
156
|
-
if (!isBrowser && !this.options.WebSocket && typeof globalThis.WebSocket === 'undefined') {
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
this._isExplicitClose = false;
|
|
164
|
-
this._setStatus(this.retries > 0 ? 'RECONNECTING' : 'CONNECTING');
|
|
165
|
-
|
|
166
|
-
const WS = getWebSocketConstructor(this.options.WebSocket);
|
|
167
|
-
if (!WS) {
|
|
168
|
-
console.warn('[cynx/realtime] WebSocket implementation not found in this environment.');
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
try {
|
|
173
|
-
const targetUrl = typeof this.url === 'function' ? this.url() : this.url;
|
|
174
|
-
this.ws = this.options.protocols
|
|
175
|
-
? new WS(targetUrl, this.options.protocols)
|
|
176
|
-
: new WS(targetUrl);
|
|
177
|
-
|
|
178
|
-
this.ws.onopen = (e) => this._onOpen(e);
|
|
179
|
-
this.ws.onmessage = (e) => this._onMessage(e);
|
|
180
|
-
this.ws.onerror = (e) => this._onError(e);
|
|
181
|
-
this.ws.onclose = (e) => this._onClose(e);
|
|
182
|
-
} catch (err) {
|
|
183
|
-
this._handleReconnect();
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
disconnect(code = 1000, reason = 'Client disconnected') {
|
|
188
|
-
this._isExplicitClose = true;
|
|
189
|
-
this._clearTimers();
|
|
190
|
-
if (this.ws) {
|
|
191
|
-
this.ws.close(code, reason);
|
|
192
|
-
this.ws = null;
|
|
193
|
-
}
|
|
194
|
-
this._setStatus('DISCONNECTED');
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Send data or a typed event payload.
|
|
199
|
-
* If offline and queueOffline=true, queues the message until reconnect.
|
|
200
|
-
*/
|
|
201
|
-
send(eventOrData, payload = undefined) {
|
|
202
|
-
let message;
|
|
203
|
-
if (payload !== undefined) {
|
|
204
|
-
message = JSON.stringify({ event: eventOrData, data: payload });
|
|
205
|
-
} else if (typeof eventOrData === 'object') {
|
|
206
|
-
message = JSON.stringify(eventOrData);
|
|
207
|
-
} else {
|
|
208
|
-
message = String(eventOrData);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (this.isConnected()) {
|
|
212
|
-
this.ws.send(message);
|
|
213
|
-
return true;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (this.options.queueOffline) {
|
|
217
|
-
if (this._offlineQueue.length >= this.options.maxQueueSize) {
|
|
218
|
-
this._offlineQueue.shift(); // Drop oldest message
|
|
219
|
-
}
|
|
220
|
-
this._offlineQueue.push(message);
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Perform a Request-Response interaction over WebSocket.
|
|
229
|
-
* Attaches a correlation `requestId` and resolves when server replies.
|
|
230
|
-
*/
|
|
231
|
-
request(action, data = {}, { timeout = 10000 } = {}) {
|
|
232
|
-
return new Promise((resolve, reject) => {
|
|
233
|
-
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
234
|
-
const timer = setTimeout(() => {
|
|
235
|
-
if (this._pendingRequests.has(requestId)) {
|
|
236
|
-
this._pendingRequests.delete(requestId);
|
|
237
|
-
reject(new Error(`[cynx/realtime] Request "${action}" timed out after ${timeout}ms`));
|
|
238
|
-
}
|
|
239
|
-
}, timeout);
|
|
240
|
-
|
|
241
|
-
this._pendingRequests.set(requestId, { resolve, reject, timer });
|
|
242
|
-
|
|
243
|
-
const sent = this.send({
|
|
244
|
-
action,
|
|
245
|
-
requestId,
|
|
246
|
-
data,
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
if (!sent && !this.options.queueOffline) {
|
|
250
|
-
clearTimeout(timer);
|
|
251
|
-
this._pendingRequests.delete(requestId);
|
|
252
|
-
reject(new Error('[cynx/realtime] WebSocket not connected'));
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
on(event, callback) {
|
|
258
|
-
return this.events.on(event, callback);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
once(event, callback) {
|
|
262
|
-
return this.events.once(event, callback);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
off(event, callback) {
|
|
266
|
-
this.events.off(event, callback);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
isConnected() {
|
|
270
|
-
return this.ws !== null && this.ws.readyState === 1;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
getState() {
|
|
274
|
-
return this.status;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ── Private Handlers ──
|
|
278
|
-
|
|
279
|
-
_setStatus(status) {
|
|
280
|
-
if (this.status !== status) {
|
|
281
|
-
this.status = status;
|
|
282
|
-
this.events.emit('status', status);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
_onOpen(event) {
|
|
287
|
-
this.retries = 0;
|
|
288
|
-
this._setStatus('CONNECTED');
|
|
289
|
-
this.events.emit('open', event);
|
|
290
|
-
this.events.emit('connect', event);
|
|
291
|
-
|
|
292
|
-
this._startHeartbeat();
|
|
293
|
-
this._flushQueue();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
_onMessage(event) {
|
|
297
|
-
this._resetHeartbeatTimeout();
|
|
298
|
-
const raw = event.data;
|
|
299
|
-
const parsed = safeJSONParse(raw, null);
|
|
300
|
-
|
|
301
|
-
// Handle Request-Response correlation
|
|
302
|
-
if (parsed && typeof parsed === 'object' && parsed.requestId && this._pendingRequests.has(parsed.requestId)) {
|
|
303
|
-
const req = this._pendingRequests.get(parsed.requestId);
|
|
304
|
-
clearTimeout(req.timer);
|
|
305
|
-
this._pendingRequests.delete(parsed.requestId);
|
|
306
|
-
if (parsed.error) {
|
|
307
|
-
req.reject(parsed.error);
|
|
308
|
-
} else {
|
|
309
|
-
req.resolve(parsed.data !== undefined ? parsed.data : parsed);
|
|
310
|
-
}
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Handle typed events: { event: 'name', data: ... } or { type: 'name', ... }
|
|
315
|
-
if (parsed && typeof parsed === 'object') {
|
|
316
|
-
const eventName = parsed.event || parsed.type;
|
|
317
|
-
if (eventName) {
|
|
318
|
-
this.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// General message event
|
|
323
|
-
this.events.emit('message', parsed !== null ? parsed : raw, raw);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
_onError(event) {
|
|
327
|
-
this.events.emit('error', event);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
_onClose(event) {
|
|
331
|
-
this._clearTimers();
|
|
332
|
-
this._setStatus('DISCONNECTED');
|
|
333
|
-
this.events.emit('close', event);
|
|
334
|
-
this.events.emit('disconnect', event);
|
|
335
|
-
|
|
336
|
-
if (!this._isExplicitClose && this.options.reconnect) {
|
|
337
|
-
this._handleReconnect();
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
_flushQueue() {
|
|
342
|
-
while (this._offlineQueue.length > 0 && this.isConnected()) {
|
|
343
|
-
const msg = this._offlineQueue.shift();
|
|
344
|
-
this.ws.send(msg);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
_handleReconnect() {
|
|
349
|
-
if (this.retries >= this.options.maxRetries) {
|
|
350
|
-
this.events.emit('max_retries_reached', this.retries);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
let delay = this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries);
|
|
355
|
-
delay = Math.min(delay, this.options.maxRetryDelay);
|
|
356
|
-
|
|
357
|
-
if (this.options.jitter) {
|
|
358
|
-
delay = delay * (0.8 + Math.random() * 0.4); // ±20% jitter
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
this.retries += 1;
|
|
362
|
-
this._setStatus('RECONNECTING');
|
|
363
|
-
this.events.emit('reconnect_attempt', { attempt: this.retries, delay: Math.round(delay) });
|
|
364
|
-
|
|
365
|
-
clearTimeout(this._reconnectTimer);
|
|
366
|
-
this._reconnectTimer = setTimeout(() => {
|
|
367
|
-
this.connect();
|
|
368
|
-
}, delay);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
_startHeartbeat() {
|
|
372
|
-
if (!this.options.heartbeat || !this.options.heartbeat.enabled) return;
|
|
373
|
-
this._clearHeartbeat();
|
|
374
|
-
|
|
375
|
-
this._heartbeatTimer = setInterval(() => {
|
|
376
|
-
if (this.isConnected()) {
|
|
377
|
-
try {
|
|
378
|
-
this.ws.send(this.options.heartbeat.pingPayload);
|
|
379
|
-
this._heartbeatTimeoutTimer = setTimeout(() => {
|
|
380
|
-
// Server did not reply in time, force reconnect
|
|
381
|
-
if (this.ws) {
|
|
382
|
-
this.ws.close(4000, 'Heartbeat timeout');
|
|
383
|
-
}
|
|
384
|
-
}, this.options.heartbeat.timeout);
|
|
385
|
-
} catch {
|
|
386
|
-
// ignore
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}, this.options.heartbeat.interval);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
_resetHeartbeatTimeout() {
|
|
393
|
-
if (this._heartbeatTimeoutTimer) {
|
|
394
|
-
clearTimeout(this._heartbeatTimeoutTimer);
|
|
395
|
-
this._heartbeatTimeoutTimer = null;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
_clearHeartbeat() {
|
|
400
|
-
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
401
|
-
if (this._heartbeatTimeoutTimer) clearTimeout(this._heartbeatTimeoutTimer);
|
|
402
|
-
this._heartbeatTimer = null;
|
|
403
|
-
this._heartbeatTimeoutTimer = null;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
_clearTimers() {
|
|
407
|
-
this._clearHeartbeat();
|
|
408
|
-
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
409
|
-
this._reconnectTimer = null;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export function createRealtimeSocket(url, options) {
|
|
414
|
-
return new RealtimeSocket(url, options);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ─── 3. SERVER-SENT EVENTS (SSE) & AI STREAMING ─────────────────────────────────
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Universal SSE (Server-Sent Events) client with AI text stream support.
|
|
421
|
-
*/
|
|
422
|
-
export class SSEStream {
|
|
423
|
-
constructor(url, options = {}) {
|
|
424
|
-
this.url = url;
|
|
425
|
-
this.options = {
|
|
426
|
-
withCredentials: false,
|
|
427
|
-
reconnect: true,
|
|
428
|
-
maxRetries: Infinity,
|
|
429
|
-
retryDelay: 3000,
|
|
430
|
-
autoConnect: true,
|
|
431
|
-
...options,
|
|
432
|
-
};
|
|
433
|
-
|
|
434
|
-
this.events = new EventBus();
|
|
435
|
-
this.eventSource = null;
|
|
436
|
-
this._isClosed = false;
|
|
437
|
-
this.retries = 0;
|
|
438
|
-
|
|
439
|
-
if (this.options.autoConnect) {
|
|
440
|
-
this.connect();
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
connect() {
|
|
445
|
-
if (typeof EventSource === 'undefined') {
|
|
446
|
-
console.warn('[cynx/realtime] EventSource is not supported in this environment.');
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (this.eventSource) this.close();
|
|
450
|
-
|
|
451
|
-
this._isClosed = false;
|
|
452
|
-
this.eventSource = new EventSource(this.url, {
|
|
453
|
-
withCredentials: this.options.withCredentials,
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
this.eventSource.onopen = (e) => {
|
|
457
|
-
this.retries = 0;
|
|
458
|
-
this.events.emit('open', e);
|
|
459
|
-
};
|
|
460
|
-
|
|
461
|
-
this.eventSource.onmessage = (e) => {
|
|
462
|
-
const parsed = safeJSONParse(e.data, e.data);
|
|
463
|
-
this.events.emit('message', parsed, e);
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
this.eventSource.onerror = (e) => {
|
|
467
|
-
this.events.emit('error', e);
|
|
468
|
-
if (this.eventSource.readyState === 2 && !this._isClosed && this.options.reconnect) {
|
|
469
|
-
this.eventSource.close();
|
|
470
|
-
this._retry();
|
|
471
|
-
}
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
addEventListener(eventName, handler) {
|
|
476
|
-
if (!this.eventSource) return;
|
|
477
|
-
this.eventSource.addEventListener(eventName, (e) => {
|
|
478
|
-
const parsed = safeJSONParse(e.data, e.data);
|
|
479
|
-
handler(parsed, e);
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
on(event, callback) {
|
|
484
|
-
return this.events.on(event, callback);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
close() {
|
|
488
|
-
this._isClosed = true;
|
|
489
|
-
if (this.eventSource) {
|
|
490
|
-
this.eventSource.close();
|
|
491
|
-
this.eventSource = null;
|
|
492
|
-
}
|
|
493
|
-
this.events.emit('close');
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
_retry() {
|
|
497
|
-
if (this.retries >= this.options.maxRetries) return;
|
|
498
|
-
this.retries += 1;
|
|
499
|
-
setTimeout(() => {
|
|
500
|
-
if (!this._isClosed) this.connect();
|
|
501
|
-
}, this.options.retryDelay);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
export function createSSE(url, options) {
|
|
506
|
-
return new SSEStream(url, options);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Helper to consume async AI/LLM readable streams (Fetch SSE / NDJSON streams).
|
|
511
|
-
*
|
|
512
|
-
* Example:
|
|
513
|
-
* await parseAIStream(response, {
|
|
514
|
-
* onChunk: (text) => console.log(text),
|
|
515
|
-
* onDone: (full) => console.log('Finished:', full),
|
|
516
|
-
* });
|
|
517
|
-
*/
|
|
518
|
-
export async function parseAIStream(response, { onChunk, onDone, onError } = {}) {
|
|
519
|
-
if (!response || !response.body) {
|
|
520
|
-
throw new Error('[cynx/realtime] Invalid response object for streaming');
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const reader = response.body.getReader();
|
|
524
|
-
const decoder = new TextDecoder('utf-8');
|
|
525
|
-
let accumulated = '';
|
|
526
|
-
|
|
527
|
-
try {
|
|
528
|
-
while (true) {
|
|
529
|
-
const { done, value } = await reader.read();
|
|
530
|
-
if (done) break;
|
|
531
|
-
|
|
532
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
533
|
-
accumulated += chunk;
|
|
534
|
-
if (onChunk) onChunk(chunk, accumulated);
|
|
535
|
-
}
|
|
536
|
-
if (onDone) onDone(accumulated);
|
|
537
|
-
return accumulated;
|
|
538
|
-
} catch (err) {
|
|
539
|
-
if (onError) onError(err);
|
|
540
|
-
throw err;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// ─── 4. CROSS-TAB / WINDOW SYNCHRONIZATION ──────────────────────────────────────
|
|
545
|
-
|
|
546
|
-
/**
|
|
547
|
-
* Cross-Tab realtime sync channel using BroadcastChannel with LocalStorage fallback.
|
|
548
|
-
* Allows instant communication between open browser tabs/windows on the same domain.
|
|
549
|
-
*/
|
|
550
|
-
export class TabChannel {
|
|
551
|
-
constructor(name = 'cynx_channel') {
|
|
552
|
-
this.name = name;
|
|
553
|
-
this.events = new EventBus();
|
|
554
|
-
this.hasBroadcast = isBrowser && typeof BroadcastChannel !== 'undefined';
|
|
555
|
-
this._bc = null;
|
|
556
|
-
this._storageHandler = null;
|
|
557
|
-
|
|
558
|
-
if (this.hasBroadcast) {
|
|
559
|
-
this._bc = new BroadcastChannel(this.name);
|
|
560
|
-
this._bc.onmessage = (e) => {
|
|
561
|
-
const { event, data } = e.data || {};
|
|
562
|
-
if (event) {
|
|
563
|
-
this.events.emit(event, data);
|
|
564
|
-
}
|
|
565
|
-
this.events.emit('message', e.data);
|
|
566
|
-
};
|
|
567
|
-
} else if (isBrowser) {
|
|
568
|
-
// Fallback to localStorage storage event
|
|
569
|
-
this._storageKey = `cynx_tab_msg_${this.name}`;
|
|
570
|
-
this._storageHandler = (e) => {
|
|
571
|
-
if (e.key === this._storageKey && e.newValue) {
|
|
572
|
-
const parsed = safeJSONParse(e.newValue, {});
|
|
573
|
-
const { event, data } = parsed;
|
|
574
|
-
if (event) {
|
|
575
|
-
this.events.emit(event, data);
|
|
576
|
-
}
|
|
577
|
-
this.events.emit('message', parsed);
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
window.addEventListener('storage', this._storageHandler);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
emit(event, data = {}) {
|
|
585
|
-
const payload = { event, data, _t: Date.now() };
|
|
586
|
-
if (this._bc) {
|
|
587
|
-
this._bc.postMessage(payload);
|
|
588
|
-
} else if (isBrowser && window.localStorage) {
|
|
589
|
-
try {
|
|
590
|
-
localStorage.setItem(this._storageKey, JSON.stringify(payload));
|
|
591
|
-
} catch {
|
|
592
|
-
// ignore quota errors
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
on(event, callback) {
|
|
598
|
-
return this.events.on(event, callback);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
off(event, callback) {
|
|
602
|
-
this.events.off(event, callback);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
close() {
|
|
606
|
-
if (this._bc) {
|
|
607
|
-
this._bc.close();
|
|
608
|
-
this._bc = null;
|
|
609
|
-
}
|
|
610
|
-
if (this._storageHandler && isBrowser) {
|
|
611
|
-
window.removeEventListener('storage', this._storageHandler);
|
|
612
|
-
this._storageHandler = null;
|
|
613
|
-
}
|
|
614
|
-
this.events.clear();
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
export function createTabChannel(channelName) {
|
|
619
|
-
return new TabChannel(channelName);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
// ─── 5. SMART POLLER (BATTERY & TAB-AWARE) ──────────────────────────────────────
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Smart Poller that automatically pauses when the browser tab is hidden/inactive,
|
|
626
|
-
* and handles execution delays safely without interval drift.
|
|
627
|
-
*/
|
|
628
|
-
export class SmartPoller {
|
|
629
|
-
constructor(taskFn, options = {}) {
|
|
630
|
-
this.taskFn = taskFn;
|
|
631
|
-
this.options = {
|
|
632
|
-
interval: 5000,
|
|
633
|
-
immediate: true,
|
|
634
|
-
pauseWhenHidden: true,
|
|
635
|
-
...options,
|
|
636
|
-
};
|
|
637
|
-
|
|
638
|
-
this.isRunning = false;
|
|
639
|
-
this._timer = null;
|
|
640
|
-
this._visibilityHandler = null;
|
|
641
|
-
|
|
642
|
-
if (isDocAvailable && this.options.pauseWhenHidden) {
|
|
643
|
-
this._visibilityHandler = () => {
|
|
644
|
-
if (document.hidden) {
|
|
645
|
-
this._stopTimer();
|
|
646
|
-
} else if (this.isRunning) {
|
|
647
|
-
this._tick();
|
|
648
|
-
}
|
|
649
|
-
};
|
|
650
|
-
document.addEventListener('visibilitychange', this._visibilityHandler);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
if (this.options.immediate) {
|
|
654
|
-
this.start();
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
start() {
|
|
659
|
-
if (this.isRunning) return;
|
|
660
|
-
this.isRunning = true;
|
|
661
|
-
this._tick();
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
stop() {
|
|
665
|
-
this.isRunning = false;
|
|
666
|
-
this._stopTimer();
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
destroy() {
|
|
670
|
-
this.stop();
|
|
671
|
-
if (this._visibilityHandler && isDocAvailable) {
|
|
672
|
-
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
|
673
|
-
this._visibilityHandler = null;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async _tick() {
|
|
678
|
-
this._stopTimer();
|
|
679
|
-
if (!this.isRunning) return;
|
|
680
|
-
|
|
681
|
-
if (isDocAvailable && this.options.pauseWhenHidden && document.hidden) {
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
try {
|
|
686
|
-
await this.taskFn();
|
|
687
|
-
} catch (err) {
|
|
688
|
-
console.error('[cynx/realtime] Error during poll execution:', err);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
if (this.isRunning) {
|
|
692
|
-
this._timer = setTimeout(() => this._tick(), this.options.interval);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
_stopTimer() {
|
|
697
|
-
if (this._timer) {
|
|
698
|
-
clearTimeout(this._timer);
|
|
699
|
-
this._timer = null;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
export function createPoller(taskFn, options) {
|
|
705
|
-
return new SmartPoller(taskFn, options);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// ─── 6. BACKEND WEBSOCKET SERVER MANAGER ────────────────────────────────────────
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* Server-side wrapper around a connected client.
|
|
712
|
-
*/
|
|
713
|
-
export class ConnectedClient {
|
|
714
|
-
constructor(socket, server, options = {}) {
|
|
715
|
-
this.id = options.id || (typeof uid === 'function' ? uid(12) : `client_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
|
|
716
|
-
this.socket = socket;
|
|
717
|
-
this.server = server;
|
|
718
|
-
this.req = options.req || null;
|
|
719
|
-
this.isAlive = true;
|
|
720
|
-
this.rooms = new Set();
|
|
721
|
-
this.data = {}; // user-defined state/metadata (e.g. userId, roles, session)
|
|
722
|
-
this.events = new EventBus();
|
|
723
|
-
|
|
724
|
-
this._bindEvents();
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
send(eventOrData, payload = undefined) {
|
|
728
|
-
if (this.socket.readyState !== 1) return false;
|
|
729
|
-
let message;
|
|
730
|
-
if (payload !== undefined) {
|
|
731
|
-
message = JSON.stringify({ event: eventOrData, data: payload });
|
|
732
|
-
} else if (typeof eventOrData === 'object') {
|
|
733
|
-
message = JSON.stringify(eventOrData);
|
|
734
|
-
} else {
|
|
735
|
-
message = String(eventOrData);
|
|
736
|
-
}
|
|
737
|
-
this.socket.send(message);
|
|
738
|
-
return true;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
reply(requestId, data = null, error = null) {
|
|
742
|
-
return this.send({ requestId, data, error });
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
join(room) {
|
|
746
|
-
this.server.join(this, room);
|
|
747
|
-
return this;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
leave(room) {
|
|
751
|
-
this.server.leave(this, room);
|
|
752
|
-
return this;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
broadcast(eventOrData, payload = undefined) {
|
|
756
|
-
this.server.broadcast(eventOrData, payload, this);
|
|
757
|
-
return this;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
broadcastTo(room, eventOrData, payload = undefined) {
|
|
761
|
-
this.server.broadcastTo(room, eventOrData, payload, this);
|
|
762
|
-
return this;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
on(event, callback) {
|
|
766
|
-
return this.events.on(event, callback);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
once(event, callback) {
|
|
770
|
-
return this.events.once(event, callback);
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
off(event, callback) {
|
|
774
|
-
this.events.off(event, callback);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
close(code = 1000, reason = 'Server closed connection') {
|
|
778
|
-
if (this.socket.readyState === 1 || this.socket.readyState === 0) {
|
|
779
|
-
this.socket.close(code, reason);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
_bindEvents() {
|
|
784
|
-
if (typeof this.socket.on === 'function') {
|
|
785
|
-
this.socket.on('message', (raw) => {
|
|
786
|
-
this.isAlive = true;
|
|
787
|
-
const str = raw instanceof Buffer ? raw.toString('utf-8') : String(raw);
|
|
788
|
-
const parsed = safeJSONParse(str, null);
|
|
789
|
-
|
|
790
|
-
if (parsed && typeof parsed === 'object') {
|
|
791
|
-
// Heartbeat ping/pong response
|
|
792
|
-
if (parsed.type === 'ping') {
|
|
793
|
-
this.send({ type: 'pong' });
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Typed event handling
|
|
798
|
-
const eventName = parsed.event || parsed.type;
|
|
799
|
-
if (eventName) {
|
|
800
|
-
this.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed, this);
|
|
801
|
-
this.server.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed, this);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
this.events.emit('message', parsed !== null ? parsed : str, this);
|
|
806
|
-
this.server.events.emit('message', parsed !== null ? parsed : str, this);
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
this.socket.on('pong', () => {
|
|
810
|
-
this.isAlive = true;
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
this.socket.on('close', (code, reason) => {
|
|
814
|
-
this.server._handleClientDisconnect(this, code, reason);
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
this.socket.on('error', (err) => {
|
|
818
|
-
this.events.emit('error', err, this);
|
|
819
|
-
this.server.events.emit('client_error', err, this);
|
|
820
|
-
});
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Universal WebSocket Server Manager for Node.js / Bun / Express / Fastify / HTTP Server.
|
|
827
|
-
* Supports rooms, broadcasting, automatic heartbeat cleanup, and request-response handling.
|
|
828
|
-
*/
|
|
829
|
-
export class RealtimeServer {
|
|
830
|
-
constructor(options = {}) {
|
|
831
|
-
this.options = {
|
|
832
|
-
server: undefined, // HTTP/HTTPS server instance
|
|
833
|
-
port: undefined, // Standalone port
|
|
834
|
-
path: '/ws',
|
|
835
|
-
heartbeatInterval: 30000,
|
|
836
|
-
WebSocketServer: undefined, // ws.WebSocketServer constructor
|
|
837
|
-
wss: undefined, // Existing WebSocketServer instance
|
|
838
|
-
authenticate: undefined, // (req) => Promise<boolean|object>
|
|
839
|
-
...options,
|
|
840
|
-
};
|
|
841
|
-
|
|
842
|
-
this.clients = new Map(); // id -> ConnectedClient
|
|
843
|
-
this.rooms = new Map(); // room -> Set<ConnectedClient>
|
|
844
|
-
this.events = new EventBus();
|
|
845
|
-
this.wss = null;
|
|
846
|
-
this._heartbeatTimer = null;
|
|
847
|
-
|
|
848
|
-
if (this.options.wss) {
|
|
849
|
-
this.attachWSS(this.options.wss);
|
|
850
|
-
} else if (this.options.server || this.options.port || this.options.WebSocketServer) {
|
|
851
|
-
this.initServer();
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
initServer() {
|
|
856
|
-
let WSS = this.options.WebSocketServer;
|
|
857
|
-
if (!WSS && typeof globalThis !== 'undefined' && globalThis.WebSocketServer) {
|
|
858
|
-
WSS = globalThis.WebSocketServer;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
if (!WSS) {
|
|
862
|
-
throw new Error(
|
|
863
|
-
'[cynx/realtime] WebSocketServer constructor not found. Pass `{ WebSocketServer: ws.WebSocketServer }` or an existing `wss` instance.'
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
const wssOptions = {};
|
|
868
|
-
if (this.options.server) wssOptions.server = this.options.server;
|
|
869
|
-
if (this.options.port) wssOptions.port = this.options.port;
|
|
870
|
-
if (this.options.path) wssOptions.path = this.options.path;
|
|
871
|
-
|
|
872
|
-
this.wss = new WSS(wssOptions);
|
|
873
|
-
this.attachWSS(this.wss);
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
attachWSS(wss) {
|
|
877
|
-
this.wss = wss;
|
|
878
|
-
|
|
879
|
-
this.wss.on('connection', async (rawSocket, req) => {
|
|
880
|
-
try {
|
|
881
|
-
if (typeof this.options.authenticate === 'function') {
|
|
882
|
-
const authResult = await this.options.authenticate(req);
|
|
883
|
-
if (!authResult) {
|
|
884
|
-
rawSocket.close(4001, 'Unauthorized');
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
if (typeof authResult === 'object') {
|
|
888
|
-
rawSocket._authData = authResult;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
const client = new ConnectedClient(rawSocket, this, { req });
|
|
893
|
-
if (rawSocket._authData) {
|
|
894
|
-
client.data = { ...rawSocket._authData };
|
|
895
|
-
}
|
|
896
|
-
|
|
897
|
-
this.clients.set(client.id, client);
|
|
898
|
-
this.events.emit('connection', client);
|
|
899
|
-
client.events.emit('connect', client);
|
|
900
|
-
} catch (err) {
|
|
901
|
-
this.events.emit('error', err);
|
|
902
|
-
rawSocket.close(4000, 'Connection error');
|
|
903
|
-
}
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
this.wss.on('error', (err) => {
|
|
907
|
-
this.events.emit('error', err);
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
this._startHeartbeat();
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
broadcast(eventOrData, payload = undefined, excludeClient = null) {
|
|
914
|
-
const excludeId = excludeClient ? (excludeClient.id || excludeClient) : null;
|
|
915
|
-
for (const client of this.clients.values()) {
|
|
916
|
-
if (client.id !== excludeId) {
|
|
917
|
-
client.send(eventOrData, payload);
|
|
918
|
-
}
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
broadcastTo(room, eventOrData, payload = undefined, excludeClient = null) {
|
|
923
|
-
if (!this.rooms.has(room)) return;
|
|
924
|
-
const excludeId = excludeClient ? (excludeClient.id || excludeClient) : null;
|
|
925
|
-
for (const client of this.rooms.get(room)) {
|
|
926
|
-
if (client.id !== excludeId) {
|
|
927
|
-
client.send(eventOrData, payload);
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
join(clientOrId, room) {
|
|
933
|
-
const client = typeof clientOrId === 'string' ? this.clients.get(clientOrId) : clientOrId;
|
|
934
|
-
if (!client) return;
|
|
935
|
-
|
|
936
|
-
if (!this.rooms.has(room)) {
|
|
937
|
-
this.rooms.set(room, new Set());
|
|
938
|
-
}
|
|
939
|
-
this.rooms.get(room).add(client);
|
|
940
|
-
client.rooms.add(room);
|
|
941
|
-
this.events.emit('join_room', { client, room });
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
leave(clientOrId, room) {
|
|
945
|
-
const client = typeof clientOrId === 'string' ? this.clients.get(clientOrId) : clientOrId;
|
|
946
|
-
if (!client) return;
|
|
947
|
-
|
|
948
|
-
if (this.rooms.has(room)) {
|
|
949
|
-
const roomSet = this.rooms.get(room);
|
|
950
|
-
roomSet.delete(client);
|
|
951
|
-
if (roomSet.size === 0) this.rooms.delete(room);
|
|
952
|
-
}
|
|
953
|
-
client.rooms.delete(room);
|
|
954
|
-
this.events.emit('leave_room', { client, room });
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
getClient(id) {
|
|
958
|
-
return this.clients.get(id);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
getRoomClients(room) {
|
|
962
|
-
return Array.from(this.rooms.get(room) || []);
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
getOnlineCount() {
|
|
966
|
-
return this.clients.size;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
on(event, callback) {
|
|
970
|
-
return this.events.on(event, callback);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
off(event, callback) {
|
|
974
|
-
this.events.off(event, callback);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
close() {
|
|
978
|
-
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
979
|
-
for (const client of this.clients.values()) {
|
|
980
|
-
client.close(1000, 'Server shutdown');
|
|
981
|
-
}
|
|
982
|
-
this.clients.clear();
|
|
983
|
-
this.rooms.clear();
|
|
984
|
-
if (this.wss && typeof this.wss.close === 'function') {
|
|
985
|
-
this.wss.close();
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
_handleClientDisconnect(client, code, reason) {
|
|
990
|
-
for (const room of client.rooms) {
|
|
991
|
-
this.leave(client, room);
|
|
992
|
-
}
|
|
993
|
-
this.clients.delete(client.id);
|
|
994
|
-
client.events.emit('close', code, reason);
|
|
995
|
-
this.events.emit('disconnection', client, code, reason);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
_startHeartbeat() {
|
|
999
|
-
if (!this.options.heartbeatInterval) return;
|
|
1000
|
-
this._heartbeatTimer = setInterval(() => {
|
|
1001
|
-
for (const client of this.clients.values()) {
|
|
1002
|
-
if (!client.isAlive) {
|
|
1003
|
-
client.close(4000, 'Heartbeat timeout');
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
client.isAlive = false;
|
|
1007
|
-
if (typeof client.socket.ping === 'function') {
|
|
1008
|
-
client.socket.ping();
|
|
1009
|
-
} else {
|
|
1010
|
-
client.send({ type: 'ping' });
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
}, this.options.heartbeatInterval);
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
export function createRealtimeServer(options) {
|
|
1018
|
-
return new RealtimeServer(options);
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// ─── 7. UNIVERSAL URL NORMALIZER ────────────────────────────────────────────────
|
|
1022
|
-
/**
|
|
1023
|
-
* Normalizes HTTP/HTTPS/WS/WSS and relative URLs into valid WebSocket endpoints.
|
|
1024
|
-
* Automatically removes accidental duplicate /ws paths or trailing slashes.
|
|
1025
|
-
*/
|
|
1026
|
-
export function normalizeWsUrl(rawUrl, defaultPath = '/ws') {
|
|
1027
|
-
if (!rawUrl || typeof rawUrl !== 'string') {
|
|
1028
|
-
if (isBrowser) {
|
|
1029
|
-
const isHttps = window.location.protocol === 'https:';
|
|
1030
|
-
const defaultScheme = isHttps ? 'wss:' : 'ws:';
|
|
1031
|
-
return `${defaultScheme}//${window.location.host}${defaultPath}`;
|
|
1032
|
-
}
|
|
1033
|
-
return '';
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
let url = rawUrl.trim();
|
|
1037
|
-
const isHttps = isBrowser ? window.location.protocol === 'https:' : false;
|
|
1038
|
-
const defaultScheme = isHttps ? 'wss:' : 'ws:';
|
|
1039
|
-
|
|
1040
|
-
// 1. Protocol-relative URL: //example.com/ws
|
|
1041
|
-
if (url.startsWith('//')) {
|
|
1042
|
-
url = `${defaultScheme}${url}`;
|
|
1043
|
-
}
|
|
1044
|
-
// 2. Relative path: /ws
|
|
1045
|
-
else if (url.startsWith('/')) {
|
|
1046
|
-
if (isBrowser) {
|
|
1047
|
-
url = `${defaultScheme}//${window.location.host}${url}`;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
// 3. HTTP -> WS
|
|
1051
|
-
else if (/^http:\/\//i.test(url)) {
|
|
1052
|
-
url = url.replace(/^http:\/\//i, 'ws://');
|
|
1053
|
-
}
|
|
1054
|
-
// 4. HTTPS -> WSS
|
|
1055
|
-
else if (/^https:\/\//i.test(url)) {
|
|
1056
|
-
url = url.replace(/^https:\/\//i, 'wss://');
|
|
1057
|
-
}
|
|
1058
|
-
// 5. Raw host without scheme
|
|
1059
|
-
else if (!/^wss?:\/\//i.test(url)) {
|
|
1060
|
-
const isLocal = url.includes('localhost') || url.includes('127.0.0.1');
|
|
1061
|
-
const scheme = isLocal && !isHttps ? 'ws://' : 'wss://';
|
|
1062
|
-
url = `${scheme}${url}`;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// Ensure path is normalized without duplicate /ws/ws
|
|
1066
|
-
try {
|
|
1067
|
-
const httpEquivalent = url.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
|
|
1068
|
-
const parsed = new URL(httpEquivalent);
|
|
1069
|
-
let pathname = parsed.pathname || defaultPath;
|
|
1070
|
-
if (!pathname || pathname === '/') {
|
|
1071
|
-
pathname = defaultPath;
|
|
1072
|
-
}
|
|
1073
|
-
pathname = pathname.replace(/\/+/g, '/').replace(/\/ws\/ws(\/|$)/g, '/ws$1');
|
|
1074
|
-
const scheme = url.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
|
|
1075
|
-
return `${scheme}//${parsed.host}${pathname}`;
|
|
1076
|
-
} catch {
|
|
1077
|
-
return url;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// ─── 8. UNIVERSAL REALTIME FRONTEND CLIENT ──────────────────────────────────────
|
|
1082
|
-
/**
|
|
1083
|
-
* Universal Realtime Client for frontends/browsers.
|
|
1084
|
-
* Manages WebSocket connections through proxy or direct, user token resolution,
|
|
1085
|
-
* automatic heartbeat ping-pong, typed event bus, and global subscriptions.
|
|
1086
|
-
*/
|
|
1087
|
-
export class RealtimeClient {
|
|
1088
|
-
constructor(options = {}) {
|
|
1089
|
-
const defaultUrl = isBrowser ? (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws' : '/ws';
|
|
1090
|
-
this.options = {
|
|
1091
|
-
url: defaultUrl,
|
|
1092
|
-
token: undefined, // string or function: () => token
|
|
1093
|
-
room: undefined, // optional room name or function: () => room
|
|
1094
|
-
autoConnect: true,
|
|
1095
|
-
reconnect: true,
|
|
1096
|
-
maxRetries: Infinity,
|
|
1097
|
-
initialRetryDelay: 1000,
|
|
1098
|
-
maxRetryDelay: 30000,
|
|
1099
|
-
backoffFactor: 1.5,
|
|
1100
|
-
jitter: true,
|
|
1101
|
-
heartbeatInterval: 20000,
|
|
1102
|
-
pingPayload: { type: 'PING' },
|
|
1103
|
-
...options,
|
|
1104
|
-
};
|
|
1105
|
-
|
|
1106
|
-
this.events = new EventBus();
|
|
1107
|
-
this.ws = null;
|
|
1108
|
-
this.status = 'DISCONNECTED';
|
|
1109
|
-
this.retries = 0;
|
|
1110
|
-
this._pingTimer = null;
|
|
1111
|
-
this._reconnectTimer = null;
|
|
1112
|
-
this._isExplicitClose = false;
|
|
1113
|
-
this._connecting = false;
|
|
1114
|
-
|
|
1115
|
-
if (this.options.autoConnect) {
|
|
1116
|
-
this.init();
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
getToken() {
|
|
1121
|
-
if (typeof this.options.token === 'function') {
|
|
1122
|
-
return this.options.token();
|
|
1123
|
-
}
|
|
1124
|
-
if (typeof this.options.token === 'string') {
|
|
1125
|
-
return this.options.token;
|
|
1126
|
-
}
|
|
1127
|
-
if (isBrowser) {
|
|
1128
|
-
return localStorage.getItem('token') || sessionStorage.getItem('token') || '';
|
|
1129
|
-
}
|
|
1130
|
-
return '';
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
getRoom() {
|
|
1134
|
-
if (typeof this.options.room === 'function') {
|
|
1135
|
-
return this.options.room();
|
|
1136
|
-
}
|
|
1137
|
-
if (typeof this.options.room === 'string') {
|
|
1138
|
-
return this.options.room;
|
|
1139
|
-
}
|
|
1140
|
-
return '';
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
init() {
|
|
1144
|
-
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
const token = this.getToken();
|
|
1148
|
-
if (!token && !this.options.anonymous) {
|
|
1149
|
-
return; // Wait until token is available
|
|
1150
|
-
}
|
|
1151
|
-
this.connect();
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
connect() {
|
|
1155
|
-
if (this._connecting) return;
|
|
1156
|
-
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
|
1157
|
-
|
|
1158
|
-
this._connecting = true;
|
|
1159
|
-
this._isExplicitClose = false;
|
|
1160
|
-
this.cleanup();
|
|
1161
|
-
|
|
1162
|
-
const WS = getWebSocketConstructor(this.options.WebSocket);
|
|
1163
|
-
if (!WS) {
|
|
1164
|
-
this._connecting = false;
|
|
1165
|
-
console.warn('[cynx/realtime] WebSocket implementation not found in this environment.');
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
try {
|
|
1170
|
-
const rawUrl = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
|
|
1171
|
-
const normalized = normalizeWsUrl(rawUrl);
|
|
1172
|
-
const token = this.getToken();
|
|
1173
|
-
const room = this.getRoom();
|
|
1174
|
-
|
|
1175
|
-
const urlObj = new URL(normalized.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'));
|
|
1176
|
-
if (token && !urlObj.searchParams.has('token')) {
|
|
1177
|
-
urlObj.searchParams.set('token', token);
|
|
1178
|
-
}
|
|
1179
|
-
if (room && !urlObj.searchParams.has('room')) {
|
|
1180
|
-
urlObj.searchParams.set('room', room);
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
const finalScheme = normalized.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
|
|
1184
|
-
const finalUrl = `${finalScheme}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
|
|
1185
|
-
|
|
1186
|
-
this.ws = new WS(finalUrl);
|
|
1187
|
-
this.status = this.retries > 0 ? 'RECONNECTING' : 'CONNECTING';
|
|
1188
|
-
|
|
1189
|
-
this.ws.onopen = (e) => {
|
|
1190
|
-
this._connecting = false;
|
|
1191
|
-
this.retries = 0;
|
|
1192
|
-
this.status = 'CONNECTED';
|
|
1193
|
-
this.events.emit('open', e);
|
|
1194
|
-
this.events.emit('status', this.status);
|
|
1195
|
-
this._startHeartbeat();
|
|
1196
|
-
};
|
|
1197
|
-
|
|
1198
|
-
this.ws.onmessage = (event) => {
|
|
1199
|
-
try {
|
|
1200
|
-
const raw = typeof event.data === 'string' ? event.data : String(event.data);
|
|
1201
|
-
const parsed = safeJSONParse(raw, raw);
|
|
1202
|
-
const eventType = (parsed && typeof parsed === 'object') ? (parsed.type || parsed.event || 'message') : 'message';
|
|
1203
|
-
|
|
1204
|
-
if (parsed && parsed.type === 'PONG') {
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
this.events.emit(eventType, parsed);
|
|
1209
|
-
this.events.emit('message', parsed);
|
|
1210
|
-
this.events.emit('*', parsed);
|
|
1211
|
-
} catch (err) {
|
|
1212
|
-
console.warn('[cynx/realtime] Message parse warning:', err);
|
|
1213
|
-
}
|
|
1214
|
-
};
|
|
1215
|
-
|
|
1216
|
-
this.ws.onerror = (err) => {
|
|
1217
|
-
this.events.emit('error', err);
|
|
1218
|
-
};
|
|
1219
|
-
|
|
1220
|
-
this.ws.onclose = (e) => {
|
|
1221
|
-
this._connecting = false;
|
|
1222
|
-
this.status = 'DISCONNECTED';
|
|
1223
|
-
this._stopHeartbeat();
|
|
1224
|
-
this.events.emit('close', e);
|
|
1225
|
-
this.events.emit('status', this.status);
|
|
1226
|
-
|
|
1227
|
-
if (!this._isExplicitClose && this.options.reconnect && this.retries < this.options.maxRetries) {
|
|
1228
|
-
this._handleReconnect();
|
|
1229
|
-
}
|
|
1230
|
-
};
|
|
1231
|
-
} catch (err) {
|
|
1232
|
-
this._connecting = false;
|
|
1233
|
-
this._handleReconnect();
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
send(eventOrData, payload = undefined) {
|
|
1238
|
-
if (!this.ws || this.ws.readyState !== 1) return false;
|
|
1239
|
-
let message;
|
|
1240
|
-
if (payload !== undefined) {
|
|
1241
|
-
message = JSON.stringify({ type: eventOrData, payload });
|
|
1242
|
-
} else if (typeof eventOrData === 'object') {
|
|
1243
|
-
message = JSON.stringify(eventOrData);
|
|
1244
|
-
} else {
|
|
1245
|
-
message = String(eventOrData);
|
|
1246
|
-
}
|
|
1247
|
-
this.ws.send(message);
|
|
1248
|
-
return true;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
emit(event, payload = undefined) {
|
|
1252
|
-
return this.send(event, payload);
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
subscribe(callback) {
|
|
1256
|
-
const unsub = this.events.on('*', callback);
|
|
1257
|
-
this.init();
|
|
1258
|
-
return unsub;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
on(event, callback) {
|
|
1262
|
-
const unsub = this.events.on(event, callback);
|
|
1263
|
-
this.init();
|
|
1264
|
-
return unsub;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
once(event, callback) {
|
|
1268
|
-
const unsub = this.events.once(event, callback);
|
|
1269
|
-
this.init();
|
|
1270
|
-
return unsub;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
off(event, callback) {
|
|
1274
|
-
this.events.off(event, callback);
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
disconnect(code = 1000, reason = 'Client disconnected') {
|
|
1278
|
-
this._isExplicitClose = true;
|
|
1279
|
-
this.cleanup();
|
|
1280
|
-
if (this.ws) {
|
|
1281
|
-
try { this.ws.close(code, reason); } catch {}
|
|
1282
|
-
this.ws = null;
|
|
1283
|
-
}
|
|
1284
|
-
this.status = 'DISCONNECTED';
|
|
1285
|
-
this.events.emit('status', this.status);
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
cleanup() {
|
|
1289
|
-
this._stopHeartbeat();
|
|
1290
|
-
if (this._reconnectTimer) {
|
|
1291
|
-
clearTimeout(this._reconnectTimer);
|
|
1292
|
-
this._reconnectTimer = null;
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
_startHeartbeat() {
|
|
1297
|
-
this._stopHeartbeat();
|
|
1298
|
-
if (!this.options.heartbeatInterval) return;
|
|
1299
|
-
this._pingTimer = setInterval(() => {
|
|
1300
|
-
if (this.ws && this.ws.readyState === 1) {
|
|
1301
|
-
try {
|
|
1302
|
-
this.ws.send(JSON.stringify(this.options.pingPayload));
|
|
1303
|
-
} catch {}
|
|
1304
|
-
}
|
|
1305
|
-
}, this.options.heartbeatInterval);
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
_stopHeartbeat() {
|
|
1309
|
-
if (this._pingTimer) {
|
|
1310
|
-
clearInterval(this._pingTimer);
|
|
1311
|
-
this._pingTimer = null;
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
_handleReconnect() {
|
|
1316
|
-
if (this._reconnectTimer) return;
|
|
1317
|
-
this.retries++;
|
|
1318
|
-
const delay = Math.min(
|
|
1319
|
-
this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries - 1),
|
|
1320
|
-
this.options.maxRetryDelay
|
|
1321
|
-
);
|
|
1322
|
-
const finalDelay = this.options.jitter ? delay * (0.8 + Math.random() * 0.4) : delay;
|
|
1323
|
-
|
|
1324
|
-
this._reconnectTimer = setTimeout(() => {
|
|
1325
|
-
this._reconnectTimer = null;
|
|
1326
|
-
this.connect();
|
|
1327
|
-
}, finalDelay);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
export function createRealtimeClient(options) {
|
|
1332
|
-
return new RealtimeClient(options);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
// ─── 9. UNIVERSAL BACKEND WEBSOCKET PROXY / REPEATER ────────────────────────────
|
|
1336
|
-
/**
|
|
1337
|
-
* Universal WebSocket Repeater/Proxy for application backends.
|
|
1338
|
-
* Intercepts /ws upgrades, validates client sessions (protecting master WS_KEY),
|
|
1339
|
-
* and safely pipes traffic to/from upstream ws-server.
|
|
1340
|
-
*/
|
|
1341
|
-
export class RealtimeProxy {
|
|
1342
|
-
constructor(server, options = {}) {
|
|
1343
|
-
this.server = server;
|
|
1344
|
-
this.options = {
|
|
1345
|
-
path: '/ws',
|
|
1346
|
-
upstreamUrl: undefined, // process.env.WS_URL
|
|
1347
|
-
upstreamKey: undefined, // process.env.WS_KEY
|
|
1348
|
-
room: 'default', // process.env.WS_ROOM
|
|
1349
|
-
authenticate: undefined,// async (req, token) => ({ userId })
|
|
1350
|
-
WebSocket: undefined,
|
|
1351
|
-
WebSocketServer: undefined,
|
|
1352
|
-
...options,
|
|
1353
|
-
};
|
|
1354
|
-
|
|
1355
|
-
this.wss = null;
|
|
1356
|
-
this.events = new EventBus();
|
|
1357
|
-
|
|
1358
|
-
if (this.server) {
|
|
1359
|
-
this.attach(this.server);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
attach(server) {
|
|
1364
|
-
this.server = server;
|
|
1365
|
-
let WSS = this.options.WebSocketServer;
|
|
1366
|
-
if (!WSS && typeof globalThis !== 'undefined' && globalThis.WebSocketServer) {
|
|
1367
|
-
WSS = globalThis.WebSocketServer;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (!WSS) {
|
|
1371
|
-
try {
|
|
1372
|
-
const wsModule = typeof require === 'function' ? require('ws') : null;
|
|
1373
|
-
if (wsModule && wsModule.WebSocketServer) {
|
|
1374
|
-
WSS = wsModule.WebSocketServer;
|
|
1375
|
-
} else if (wsModule && wsModule.Server) {
|
|
1376
|
-
WSS = wsModule.Server;
|
|
1377
|
-
}
|
|
1378
|
-
} catch {}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
if (!WSS) {
|
|
1382
|
-
console.warn('[cynx/realtime] WebSocketServer not available for RealtimeProxy.');
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
this.wss = new WSS({ noServer: true });
|
|
1387
|
-
|
|
1388
|
-
server.on('upgrade', async (request, socket, head) => {
|
|
1389
|
-
try {
|
|
1390
|
-
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
|
|
1391
|
-
if (url.pathname !== this.options.path) {
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
const token = (
|
|
1396
|
-
url.searchParams.get('token') ||
|
|
1397
|
-
request.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
|
|
1398
|
-
request.headers['x-auth-token'] ||
|
|
1399
|
-
''
|
|
1400
|
-
).trim();
|
|
1401
|
-
|
|
1402
|
-
let authData = null;
|
|
1403
|
-
if (typeof this.options.authenticate === 'function') {
|
|
1404
|
-
authData = await this.options.authenticate(request, token);
|
|
1405
|
-
if (!authData) {
|
|
1406
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
1407
|
-
socket.destroy();
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
const room = (url.searchParams.get('room') || this.options.room || 'default').trim().toLowerCase();
|
|
1413
|
-
|
|
1414
|
-
this.wss.handleUpgrade(request, socket, head, (clientWs) => {
|
|
1415
|
-
this.wss.emit('connection', clientWs, request, { authData, room });
|
|
1416
|
-
});
|
|
1417
|
-
} catch (err) {
|
|
1418
|
-
console.warn('[RealtimeProxy Upgrade Error]:', err.message);
|
|
1419
|
-
try {
|
|
1420
|
-
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
|
1421
|
-
socket.destroy();
|
|
1422
|
-
} catch {}
|
|
1423
|
-
}
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
this.wss.on('connection', (clientWs, request, { authData, room }) => {
|
|
1427
|
-
this._handleConnection(clientWs, { authData, room });
|
|
1428
|
-
});
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
_handleConnection(clientWs, { authData, room }) {
|
|
1432
|
-
const WS_URL = this.options.upstreamUrl || (typeof process !== 'undefined' ? process.env.WS_URL : '');
|
|
1433
|
-
const WS_KEY = this.options.upstreamKey || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
|
|
1434
|
-
const targetRoom = room || this.options.room || 'default';
|
|
1435
|
-
|
|
1436
|
-
if (!WS_URL || !WS_KEY) {
|
|
1437
|
-
clientWs.close(1011, 'Real-Time upstream not configured');
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// Rock-solid URL normalization: strip duplicate /ws/
|
|
1442
|
-
const upstreamBase = WS_URL
|
|
1443
|
-
.replace(/^http:\/\//i, 'ws://')
|
|
1444
|
-
.replace(/^https:\/\//i, 'wss://')
|
|
1445
|
-
.replace(/\/ws\/?$/i, '')
|
|
1446
|
-
.replace(/\/$/, '');
|
|
1447
|
-
|
|
1448
|
-
const upstreamUrl = `${upstreamBase}/ws?room=${encodeURIComponent(targetRoom)}&token=${encodeURIComponent(WS_KEY)}`;
|
|
1449
|
-
|
|
1450
|
-
let WS = this.options.WebSocket;
|
|
1451
|
-
if (!WS && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
1452
|
-
WS = globalThis.WebSocket;
|
|
1453
|
-
}
|
|
1454
|
-
if (!WS) {
|
|
1455
|
-
try {
|
|
1456
|
-
const wsModule = typeof require === 'function' ? require('ws') : null;
|
|
1457
|
-
if (wsModule) WS = wsModule;
|
|
1458
|
-
} catch {}
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
if (!WS) {
|
|
1462
|
-
clientWs.close(1011, 'WebSocket client constructor not available');
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
let upstreamWs = null;
|
|
1467
|
-
try {
|
|
1468
|
-
upstreamWs = new WS(upstreamUrl);
|
|
1469
|
-
} catch (err) {
|
|
1470
|
-
clientWs.close(1011, 'Failed to connect upstream');
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
// Upstream -> Client
|
|
1475
|
-
upstreamWs.onmessage = (event) => {
|
|
1476
|
-
if (clientWs.readyState === 1) {
|
|
1477
|
-
clientWs.send(event.data);
|
|
1478
|
-
}
|
|
1479
|
-
};
|
|
1480
|
-
if (typeof upstreamWs.on === 'function') {
|
|
1481
|
-
upstreamWs.on('message', (data, isBinary) => {
|
|
1482
|
-
if (clientWs.readyState === 1) {
|
|
1483
|
-
clientWs.send(data, { binary: isBinary });
|
|
1484
|
-
}
|
|
1485
|
-
});
|
|
1486
|
-
upstreamWs.on('error', (err) => {
|
|
1487
|
-
console.warn('[RealtimeProxy Upstream Error]:', err.message);
|
|
1488
|
-
if (clientWs.readyState === 1) clientWs.close(1011, 'Upstream error');
|
|
1489
|
-
});
|
|
1490
|
-
upstreamWs.on('close', (code, reason) => {
|
|
1491
|
-
if (clientWs.readyState === 1) clientWs.close(code, reason);
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
// Client -> Upstream
|
|
1496
|
-
if (typeof clientWs.on === 'function') {
|
|
1497
|
-
clientWs.on('message', (data, isBinary) => {
|
|
1498
|
-
if (upstreamWs && upstreamWs.readyState === 1) {
|
|
1499
|
-
upstreamWs.send(data, { binary: isBinary });
|
|
1500
|
-
}
|
|
1501
|
-
});
|
|
1502
|
-
clientWs.on('error', () => {
|
|
1503
|
-
if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
|
|
1504
|
-
});
|
|
1505
|
-
clientWs.on('close', () => {
|
|
1506
|
-
if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
export function createRealtimeProxy(server, options) {
|
|
1513
|
-
return new RealtimeProxy(server, options);
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// ─── 10. UNIVERSAL REALTIME DISPATCHER (HTTP BROADCASTER) ───────────────────────
|
|
1517
|
-
/**
|
|
1518
|
-
* Universal Event Broadcaster for backend services and microservices.
|
|
1519
|
-
* Sends broadcast events to ws-server via HTTP REST (POST /api/broadcast and /broadcast).
|
|
1520
|
-
*/
|
|
1521
|
-
export class RealtimeDispatcher {
|
|
1522
|
-
constructor(options = {}) {
|
|
1523
|
-
this.options = {
|
|
1524
|
-
url: undefined, // process.env.WS_URL
|
|
1525
|
-
key: undefined, // process.env.WS_KEY
|
|
1526
|
-
room: undefined, // process.env.WS_ROOM
|
|
1527
|
-
fetch: typeof globalThis !== 'undefined' ? globalThis.fetch : undefined,
|
|
1528
|
-
...options,
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
async broadcast(eventType, payload = {}, options = {}) {
|
|
1533
|
-
const rawUrl = options.url || this.options.url || (typeof process !== 'undefined' ? process.env.WS_URL : '');
|
|
1534
|
-
const key = options.key || this.options.key || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
|
|
1535
|
-
const room = (options.room || this.options.room || (typeof process !== 'undefined' ? process.env.WS_ROOM : '') || '').trim().toLowerCase();
|
|
1536
|
-
|
|
1537
|
-
if (!rawUrl || !key || !room) {
|
|
1538
|
-
return { ok: false, error: 'Missing WS_URL, WS_KEY, or WS_ROOM configuration' };
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
const fetchFn = this.options.fetch || globalThis.fetch;
|
|
1542
|
-
if (!fetchFn) {
|
|
1543
|
-
return { ok: false, error: 'fetch function not found in environment' };
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
const baseUrl = rawUrl
|
|
1547
|
-
.replace(/^ws:\/\//i, 'http://')
|
|
1548
|
-
.replace(/^wss:\/\//i, 'https://')
|
|
1549
|
-
.replace(/\/ws\/?$/i, '')
|
|
1550
|
-
.replace(/\/$/, '');
|
|
1551
|
-
|
|
1552
|
-
const endpoint = `${baseUrl}/api/broadcast?room=${encodeURIComponent(room)}`;
|
|
1553
|
-
const body = JSON.stringify({
|
|
1554
|
-
type: eventType,
|
|
1555
|
-
room,
|
|
1556
|
-
payload,
|
|
1557
|
-
timestamp: Date.now(),
|
|
1558
|
-
...options.extra,
|
|
1559
|
-
});
|
|
1560
|
-
|
|
1561
|
-
const headers = {
|
|
1562
|
-
'Content-Type': 'application/json',
|
|
1563
|
-
'X-Secret-Key': key,
|
|
1564
|
-
'x-ws-token': key,
|
|
1565
|
-
'x-room': room,
|
|
1566
|
-
'Authorization': `Bearer ${key}`,
|
|
1567
|
-
};
|
|
1568
|
-
|
|
1569
|
-
try {
|
|
1570
|
-
let res = await fetchFn(endpoint, {
|
|
1571
|
-
method: 'POST',
|
|
1572
|
-
headers,
|
|
1573
|
-
body,
|
|
1574
|
-
});
|
|
1575
|
-
|
|
1576
|
-
// Fallback to legacy /broadcast if /api/broadcast returned 404
|
|
1577
|
-
if (res.status === 404) {
|
|
1578
|
-
const legacyEndpoint = `${baseUrl}/broadcast?room=${encodeURIComponent(room)}`;
|
|
1579
|
-
res = await fetchFn(legacyEndpoint, { method: 'POST', headers, body });
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
if (!res.ok) {
|
|
1583
|
-
const errText = await res.text().catch(() => '');
|
|
1584
|
-
console.warn(`[cynx/realtime] Broadcast warning (${res.status}):`, errText);
|
|
1585
|
-
return { ok: false, status: res.status, error: errText };
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
const data = await res.json().catch(() => ({ status: 'ok' }));
|
|
1589
|
-
return { ok: true, data };
|
|
1590
|
-
} catch (err) {
|
|
1591
|
-
console.warn('[cynx/realtime] Broadcast network error:', err.message);
|
|
1592
|
-
return { ok: false, error: err.message };
|
|
1593
|
-
}
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
async broadcastSession(token, eventType, payload = {}, options = {}) {
|
|
1597
|
-
return this.broadcast('session_event', payload, {
|
|
1598
|
-
...options,
|
|
1599
|
-
extra: { token, eventType, sessionToken: token },
|
|
1600
|
-
});
|
|
1601
|
-
}
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
export function createRealtimeDispatcher(options) {
|
|
1605
|
-
return new RealtimeDispatcher(options);
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
// ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
|
|
1609
|
-
|
|
1610
|
-
export default {
|
|
1611
|
-
// Client
|
|
1612
|
-
RealtimeSocket,
|
|
1613
|
-
createRealtimeSocket,
|
|
1614
|
-
RealtimeClient,
|
|
1615
|
-
createRealtimeClient,
|
|
1616
|
-
normalizeWsUrl,
|
|
1617
|
-
SSEStream,
|
|
1618
|
-
createSSE,
|
|
1619
|
-
parseAIStream,
|
|
1620
|
-
TabChannel,
|
|
1621
|
-
createTabChannel,
|
|
1622
|
-
SmartPoller,
|
|
1623
|
-
createPoller,
|
|
1624
|
-
EventBus,
|
|
1625
|
-
createEventBus,
|
|
1626
|
-
// Server
|
|
1627
|
-
ConnectedClient,
|
|
1628
|
-
RealtimeServer,
|
|
1629
|
-
createRealtimeServer,
|
|
1630
|
-
RealtimeProxy,
|
|
1631
|
-
createRealtimeProxy,
|
|
1632
|
-
RealtimeDispatcher,
|
|
1633
|
-
createRealtimeDispatcher,
|
|
1634
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* cynx/utils — Universal Realtime Engine (realtime.js)
|
|
3
|
+
*
|
|
4
|
+
* All-in-one realtime utility covering:
|
|
5
|
+
* 1. Smart WebSocket client (auto-reconnect with backoff, heartbeat/ping-pong, offline queue, pub/sub, request-response)
|
|
6
|
+
* 2. SSE (Server-Sent Events) streamer with AI stream parser
|
|
7
|
+
* 3. Cross-Tab / Cross-Window Synchronization (BroadcastChannel + LocalStorage fallback)
|
|
8
|
+
* 4. Smart Poller (pause on hidden tab, backoff, jitter)
|
|
9
|
+
* 5. Lightweight Event Bus / PubSub
|
|
10
|
+
*
|
|
11
|
+
* Compatible with Browser, Node.js, SSR, Web Workers.
|
|
12
|
+
* Zero external dependencies.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { uid } from './uid.js';
|
|
16
|
+
|
|
17
|
+
// ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const isBrowser = typeof window !== 'undefined';
|
|
20
|
+
const isDocAvailable = typeof document !== 'undefined';
|
|
21
|
+
|
|
22
|
+
function safeJSONParse(str, fallback = null) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(str);
|
|
25
|
+
} catch {
|
|
26
|
+
return fallback !== null ? fallback : str;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getWebSocketConstructor(customWs) {
|
|
31
|
+
if (customWs) return customWs;
|
|
32
|
+
if (typeof WebSocket !== 'undefined') return WebSocket;
|
|
33
|
+
if (typeof globalThis !== 'undefined' && globalThis.WebSocket) return globalThis.WebSocket;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── 1. EVENT BUS (LIGHTWEIGHT PUB/SUB) ─────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export class EventBus {
|
|
40
|
+
constructor() {
|
|
41
|
+
this._listeners = new Map();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
on(event, callback) {
|
|
45
|
+
if (typeof callback !== 'function') return () => {};
|
|
46
|
+
if (!this._listeners.has(event)) {
|
|
47
|
+
this._listeners.set(event, new Set());
|
|
48
|
+
}
|
|
49
|
+
this._listeners.get(event).add(callback);
|
|
50
|
+
return () => this.off(event, callback);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
once(event, callback) {
|
|
54
|
+
const unsub = this.on(event, (...args) => {
|
|
55
|
+
unsub();
|
|
56
|
+
callback(...args);
|
|
57
|
+
});
|
|
58
|
+
return unsub;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
off(event, callback) {
|
|
62
|
+
if (!this._listeners.has(event)) return;
|
|
63
|
+
if (!callback) {
|
|
64
|
+
this._listeners.delete(event);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const set = this._listeners.get(event);
|
|
68
|
+
set.delete(callback);
|
|
69
|
+
if (set.size === 0) this._listeners.delete(event);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
emit(event, ...args) {
|
|
73
|
+
// Direct matches
|
|
74
|
+
if (this._listeners.has(event)) {
|
|
75
|
+
for (const cb of this._listeners.get(event)) {
|
|
76
|
+
try {
|
|
77
|
+
cb(...args);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`[cynx/realtime] Error in handler for event "${event}":`, err);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Wildcard matches (e.g. '*')
|
|
84
|
+
if (event !== '*' && this._listeners.has('*')) {
|
|
85
|
+
for (const cb of this._listeners.get('*')) {
|
|
86
|
+
try {
|
|
87
|
+
cb(event, ...args);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error('[cynx/realtime] Error in wildcard handler:', err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clear() {
|
|
96
|
+
this._listeners.clear();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createEventBus() {
|
|
101
|
+
return new EventBus();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── 2. SMART WEBSOCKET CLIENT ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Universal WebSocket Client with:
|
|
108
|
+
* - Auto-reconnect with exponential backoff & random jitter
|
|
109
|
+
* - Heartbeat (Ping/Pong keep-alive)
|
|
110
|
+
* - Offline message queue
|
|
111
|
+
* - Event-based messaging (topic/event pub-sub)
|
|
112
|
+
* - Request-Response pattern over WebSocket
|
|
113
|
+
*/
|
|
114
|
+
export class RealtimeSocket {
|
|
115
|
+
constructor(url, options = {}) {
|
|
116
|
+
this.url = url;
|
|
117
|
+
this.options = {
|
|
118
|
+
reconnect: true,
|
|
119
|
+
maxRetries: Infinity,
|
|
120
|
+
initialRetryDelay: 1000,
|
|
121
|
+
maxRetryDelay: 30000,
|
|
122
|
+
backoffFactor: 1.5,
|
|
123
|
+
jitter: true,
|
|
124
|
+
protocols: undefined,
|
|
125
|
+
heartbeat: {
|
|
126
|
+
enabled: true,
|
|
127
|
+
interval: 25000,
|
|
128
|
+
pingPayload: JSON.stringify({ type: 'ping' }),
|
|
129
|
+
pongPattern: 'pong',
|
|
130
|
+
timeout: 10000,
|
|
131
|
+
},
|
|
132
|
+
queueOffline: true,
|
|
133
|
+
maxQueueSize: 100,
|
|
134
|
+
autoConnect: true,
|
|
135
|
+
WebSocket: undefined,
|
|
136
|
+
...options,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.events = new EventBus();
|
|
140
|
+
this.ws = null;
|
|
141
|
+
this.status = 'DISCONNECTED'; // 'CONNECTING' | 'CONNECTED' | 'DISCONNECTED' | 'RECONNECTING'
|
|
142
|
+
this.retries = 0;
|
|
143
|
+
this._offlineQueue = [];
|
|
144
|
+
this._pendingRequests = new Map();
|
|
145
|
+
this._heartbeatTimer = null;
|
|
146
|
+
this._heartbeatTimeoutTimer = null;
|
|
147
|
+
this._reconnectTimer = null;
|
|
148
|
+
this._isExplicitClose = false;
|
|
149
|
+
|
|
150
|
+
if (this.options.autoConnect) {
|
|
151
|
+
this.connect();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
connect() {
|
|
156
|
+
if (!isBrowser && !this.options.WebSocket && typeof globalThis.WebSocket === 'undefined') {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this._isExplicitClose = false;
|
|
164
|
+
this._setStatus(this.retries > 0 ? 'RECONNECTING' : 'CONNECTING');
|
|
165
|
+
|
|
166
|
+
const WS = getWebSocketConstructor(this.options.WebSocket);
|
|
167
|
+
if (!WS) {
|
|
168
|
+
console.warn('[cynx/realtime] WebSocket implementation not found in this environment.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const targetUrl = typeof this.url === 'function' ? this.url() : this.url;
|
|
174
|
+
this.ws = this.options.protocols
|
|
175
|
+
? new WS(targetUrl, this.options.protocols)
|
|
176
|
+
: new WS(targetUrl);
|
|
177
|
+
|
|
178
|
+
this.ws.onopen = (e) => this._onOpen(e);
|
|
179
|
+
this.ws.onmessage = (e) => this._onMessage(e);
|
|
180
|
+
this.ws.onerror = (e) => this._onError(e);
|
|
181
|
+
this.ws.onclose = (e) => this._onClose(e);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
this._handleReconnect();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
disconnect(code = 1000, reason = 'Client disconnected') {
|
|
188
|
+
this._isExplicitClose = true;
|
|
189
|
+
this._clearTimers();
|
|
190
|
+
if (this.ws) {
|
|
191
|
+
this.ws.close(code, reason);
|
|
192
|
+
this.ws = null;
|
|
193
|
+
}
|
|
194
|
+
this._setStatus('DISCONNECTED');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Send data or a typed event payload.
|
|
199
|
+
* If offline and queueOffline=true, queues the message until reconnect.
|
|
200
|
+
*/
|
|
201
|
+
send(eventOrData, payload = undefined) {
|
|
202
|
+
let message;
|
|
203
|
+
if (payload !== undefined) {
|
|
204
|
+
message = JSON.stringify({ event: eventOrData, data: payload });
|
|
205
|
+
} else if (typeof eventOrData === 'object') {
|
|
206
|
+
message = JSON.stringify(eventOrData);
|
|
207
|
+
} else {
|
|
208
|
+
message = String(eventOrData);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (this.isConnected()) {
|
|
212
|
+
this.ws.send(message);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (this.options.queueOffline) {
|
|
217
|
+
if (this._offlineQueue.length >= this.options.maxQueueSize) {
|
|
218
|
+
this._offlineQueue.shift(); // Drop oldest message
|
|
219
|
+
}
|
|
220
|
+
this._offlineQueue.push(message);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Perform a Request-Response interaction over WebSocket.
|
|
229
|
+
* Attaches a correlation `requestId` and resolves when server replies.
|
|
230
|
+
*/
|
|
231
|
+
request(action, data = {}, { timeout = 10000 } = {}) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
234
|
+
const timer = setTimeout(() => {
|
|
235
|
+
if (this._pendingRequests.has(requestId)) {
|
|
236
|
+
this._pendingRequests.delete(requestId);
|
|
237
|
+
reject(new Error(`[cynx/realtime] Request "${action}" timed out after ${timeout}ms`));
|
|
238
|
+
}
|
|
239
|
+
}, timeout);
|
|
240
|
+
|
|
241
|
+
this._pendingRequests.set(requestId, { resolve, reject, timer });
|
|
242
|
+
|
|
243
|
+
const sent = this.send({
|
|
244
|
+
action,
|
|
245
|
+
requestId,
|
|
246
|
+
data,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!sent && !this.options.queueOffline) {
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
this._pendingRequests.delete(requestId);
|
|
252
|
+
reject(new Error('[cynx/realtime] WebSocket not connected'));
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
on(event, callback) {
|
|
258
|
+
return this.events.on(event, callback);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
once(event, callback) {
|
|
262
|
+
return this.events.once(event, callback);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
off(event, callback) {
|
|
266
|
+
this.events.off(event, callback);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
isConnected() {
|
|
270
|
+
return this.ws !== null && this.ws.readyState === 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
getState() {
|
|
274
|
+
return this.status;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Private Handlers ──
|
|
278
|
+
|
|
279
|
+
_setStatus(status) {
|
|
280
|
+
if (this.status !== status) {
|
|
281
|
+
this.status = status;
|
|
282
|
+
this.events.emit('status', status);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_onOpen(event) {
|
|
287
|
+
this.retries = 0;
|
|
288
|
+
this._setStatus('CONNECTED');
|
|
289
|
+
this.events.emit('open', event);
|
|
290
|
+
this.events.emit('connect', event);
|
|
291
|
+
|
|
292
|
+
this._startHeartbeat();
|
|
293
|
+
this._flushQueue();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_onMessage(event) {
|
|
297
|
+
this._resetHeartbeatTimeout();
|
|
298
|
+
const raw = event.data;
|
|
299
|
+
const parsed = safeJSONParse(raw, null);
|
|
300
|
+
|
|
301
|
+
// Handle Request-Response correlation
|
|
302
|
+
if (parsed && typeof parsed === 'object' && parsed.requestId && this._pendingRequests.has(parsed.requestId)) {
|
|
303
|
+
const req = this._pendingRequests.get(parsed.requestId);
|
|
304
|
+
clearTimeout(req.timer);
|
|
305
|
+
this._pendingRequests.delete(parsed.requestId);
|
|
306
|
+
if (parsed.error) {
|
|
307
|
+
req.reject(parsed.error);
|
|
308
|
+
} else {
|
|
309
|
+
req.resolve(parsed.data !== undefined ? parsed.data : parsed);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle typed events: { event: 'name', data: ... } or { type: 'name', ... }
|
|
315
|
+
if (parsed && typeof parsed === 'object') {
|
|
316
|
+
const eventName = parsed.event || parsed.type;
|
|
317
|
+
if (eventName) {
|
|
318
|
+
this.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// General message event
|
|
323
|
+
this.events.emit('message', parsed !== null ? parsed : raw, raw);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_onError(event) {
|
|
327
|
+
this.events.emit('error', event);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_onClose(event) {
|
|
331
|
+
this._clearTimers();
|
|
332
|
+
this._setStatus('DISCONNECTED');
|
|
333
|
+
this.events.emit('close', event);
|
|
334
|
+
this.events.emit('disconnect', event);
|
|
335
|
+
|
|
336
|
+
if (!this._isExplicitClose && this.options.reconnect) {
|
|
337
|
+
this._handleReconnect();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_flushQueue() {
|
|
342
|
+
while (this._offlineQueue.length > 0 && this.isConnected()) {
|
|
343
|
+
const msg = this._offlineQueue.shift();
|
|
344
|
+
this.ws.send(msg);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
_handleReconnect() {
|
|
349
|
+
if (this.retries >= this.options.maxRetries) {
|
|
350
|
+
this.events.emit('max_retries_reached', this.retries);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let delay = this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries);
|
|
355
|
+
delay = Math.min(delay, this.options.maxRetryDelay);
|
|
356
|
+
|
|
357
|
+
if (this.options.jitter) {
|
|
358
|
+
delay = delay * (0.8 + Math.random() * 0.4); // ±20% jitter
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.retries += 1;
|
|
362
|
+
this._setStatus('RECONNECTING');
|
|
363
|
+
this.events.emit('reconnect_attempt', { attempt: this.retries, delay: Math.round(delay) });
|
|
364
|
+
|
|
365
|
+
clearTimeout(this._reconnectTimer);
|
|
366
|
+
this._reconnectTimer = setTimeout(() => {
|
|
367
|
+
this.connect();
|
|
368
|
+
}, delay);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
_startHeartbeat() {
|
|
372
|
+
if (!this.options.heartbeat || !this.options.heartbeat.enabled) return;
|
|
373
|
+
this._clearHeartbeat();
|
|
374
|
+
|
|
375
|
+
this._heartbeatTimer = setInterval(() => {
|
|
376
|
+
if (this.isConnected()) {
|
|
377
|
+
try {
|
|
378
|
+
this.ws.send(this.options.heartbeat.pingPayload);
|
|
379
|
+
this._heartbeatTimeoutTimer = setTimeout(() => {
|
|
380
|
+
// Server did not reply in time, force reconnect
|
|
381
|
+
if (this.ws) {
|
|
382
|
+
this.ws.close(4000, 'Heartbeat timeout');
|
|
383
|
+
}
|
|
384
|
+
}, this.options.heartbeat.timeout);
|
|
385
|
+
} catch {
|
|
386
|
+
// ignore
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}, this.options.heartbeat.interval);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
_resetHeartbeatTimeout() {
|
|
393
|
+
if (this._heartbeatTimeoutTimer) {
|
|
394
|
+
clearTimeout(this._heartbeatTimeoutTimer);
|
|
395
|
+
this._heartbeatTimeoutTimer = null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_clearHeartbeat() {
|
|
400
|
+
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
401
|
+
if (this._heartbeatTimeoutTimer) clearTimeout(this._heartbeatTimeoutTimer);
|
|
402
|
+
this._heartbeatTimer = null;
|
|
403
|
+
this._heartbeatTimeoutTimer = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
_clearTimers() {
|
|
407
|
+
this._clearHeartbeat();
|
|
408
|
+
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
409
|
+
this._reconnectTimer = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function createRealtimeSocket(url, options) {
|
|
414
|
+
return new RealtimeSocket(url, options);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── 3. SERVER-SENT EVENTS (SSE) & AI STREAMING ─────────────────────────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Universal SSE (Server-Sent Events) client with AI text stream support.
|
|
421
|
+
*/
|
|
422
|
+
export class SSEStream {
|
|
423
|
+
constructor(url, options = {}) {
|
|
424
|
+
this.url = url;
|
|
425
|
+
this.options = {
|
|
426
|
+
withCredentials: false,
|
|
427
|
+
reconnect: true,
|
|
428
|
+
maxRetries: Infinity,
|
|
429
|
+
retryDelay: 3000,
|
|
430
|
+
autoConnect: true,
|
|
431
|
+
...options,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
this.events = new EventBus();
|
|
435
|
+
this.eventSource = null;
|
|
436
|
+
this._isClosed = false;
|
|
437
|
+
this.retries = 0;
|
|
438
|
+
|
|
439
|
+
if (this.options.autoConnect) {
|
|
440
|
+
this.connect();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
connect() {
|
|
445
|
+
if (typeof EventSource === 'undefined') {
|
|
446
|
+
console.warn('[cynx/realtime] EventSource is not supported in this environment.');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (this.eventSource) this.close();
|
|
450
|
+
|
|
451
|
+
this._isClosed = false;
|
|
452
|
+
this.eventSource = new EventSource(this.url, {
|
|
453
|
+
withCredentials: this.options.withCredentials,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
this.eventSource.onopen = (e) => {
|
|
457
|
+
this.retries = 0;
|
|
458
|
+
this.events.emit('open', e);
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
this.eventSource.onmessage = (e) => {
|
|
462
|
+
const parsed = safeJSONParse(e.data, e.data);
|
|
463
|
+
this.events.emit('message', parsed, e);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
this.eventSource.onerror = (e) => {
|
|
467
|
+
this.events.emit('error', e);
|
|
468
|
+
if (this.eventSource.readyState === 2 && !this._isClosed && this.options.reconnect) {
|
|
469
|
+
this.eventSource.close();
|
|
470
|
+
this._retry();
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
addEventListener(eventName, handler) {
|
|
476
|
+
if (!this.eventSource) return;
|
|
477
|
+
this.eventSource.addEventListener(eventName, (e) => {
|
|
478
|
+
const parsed = safeJSONParse(e.data, e.data);
|
|
479
|
+
handler(parsed, e);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
on(event, callback) {
|
|
484
|
+
return this.events.on(event, callback);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
close() {
|
|
488
|
+
this._isClosed = true;
|
|
489
|
+
if (this.eventSource) {
|
|
490
|
+
this.eventSource.close();
|
|
491
|
+
this.eventSource = null;
|
|
492
|
+
}
|
|
493
|
+
this.events.emit('close');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
_retry() {
|
|
497
|
+
if (this.retries >= this.options.maxRetries) return;
|
|
498
|
+
this.retries += 1;
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
if (!this._isClosed) this.connect();
|
|
501
|
+
}, this.options.retryDelay);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function createSSE(url, options) {
|
|
506
|
+
return new SSEStream(url, options);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Helper to consume async AI/LLM readable streams (Fetch SSE / NDJSON streams).
|
|
511
|
+
*
|
|
512
|
+
* Example:
|
|
513
|
+
* await parseAIStream(response, {
|
|
514
|
+
* onChunk: (text) => console.log(text),
|
|
515
|
+
* onDone: (full) => console.log('Finished:', full),
|
|
516
|
+
* });
|
|
517
|
+
*/
|
|
518
|
+
export async function parseAIStream(response, { onChunk, onDone, onError } = {}) {
|
|
519
|
+
if (!response || !response.body) {
|
|
520
|
+
throw new Error('[cynx/realtime] Invalid response object for streaming');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const reader = response.body.getReader();
|
|
524
|
+
const decoder = new TextDecoder('utf-8');
|
|
525
|
+
let accumulated = '';
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
while (true) {
|
|
529
|
+
const { done, value } = await reader.read();
|
|
530
|
+
if (done) break;
|
|
531
|
+
|
|
532
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
533
|
+
accumulated += chunk;
|
|
534
|
+
if (onChunk) onChunk(chunk, accumulated);
|
|
535
|
+
}
|
|
536
|
+
if (onDone) onDone(accumulated);
|
|
537
|
+
return accumulated;
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (onError) onError(err);
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── 4. CROSS-TAB / WINDOW SYNCHRONIZATION ──────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Cross-Tab realtime sync channel using BroadcastChannel with LocalStorage fallback.
|
|
548
|
+
* Allows instant communication between open browser tabs/windows on the same domain.
|
|
549
|
+
*/
|
|
550
|
+
export class TabChannel {
|
|
551
|
+
constructor(name = 'cynx_channel') {
|
|
552
|
+
this.name = name;
|
|
553
|
+
this.events = new EventBus();
|
|
554
|
+
this.hasBroadcast = isBrowser && typeof BroadcastChannel !== 'undefined';
|
|
555
|
+
this._bc = null;
|
|
556
|
+
this._storageHandler = null;
|
|
557
|
+
|
|
558
|
+
if (this.hasBroadcast) {
|
|
559
|
+
this._bc = new BroadcastChannel(this.name);
|
|
560
|
+
this._bc.onmessage = (e) => {
|
|
561
|
+
const { event, data } = e.data || {};
|
|
562
|
+
if (event) {
|
|
563
|
+
this.events.emit(event, data);
|
|
564
|
+
}
|
|
565
|
+
this.events.emit('message', e.data);
|
|
566
|
+
};
|
|
567
|
+
} else if (isBrowser) {
|
|
568
|
+
// Fallback to localStorage storage event
|
|
569
|
+
this._storageKey = `cynx_tab_msg_${this.name}`;
|
|
570
|
+
this._storageHandler = (e) => {
|
|
571
|
+
if (e.key === this._storageKey && e.newValue) {
|
|
572
|
+
const parsed = safeJSONParse(e.newValue, {});
|
|
573
|
+
const { event, data } = parsed;
|
|
574
|
+
if (event) {
|
|
575
|
+
this.events.emit(event, data);
|
|
576
|
+
}
|
|
577
|
+
this.events.emit('message', parsed);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
window.addEventListener('storage', this._storageHandler);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
emit(event, data = {}) {
|
|
585
|
+
const payload = { event, data, _t: Date.now() };
|
|
586
|
+
if (this._bc) {
|
|
587
|
+
this._bc.postMessage(payload);
|
|
588
|
+
} else if (isBrowser && window.localStorage) {
|
|
589
|
+
try {
|
|
590
|
+
localStorage.setItem(this._storageKey, JSON.stringify(payload));
|
|
591
|
+
} catch {
|
|
592
|
+
// ignore quota errors
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
on(event, callback) {
|
|
598
|
+
return this.events.on(event, callback);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
off(event, callback) {
|
|
602
|
+
this.events.off(event, callback);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
close() {
|
|
606
|
+
if (this._bc) {
|
|
607
|
+
this._bc.close();
|
|
608
|
+
this._bc = null;
|
|
609
|
+
}
|
|
610
|
+
if (this._storageHandler && isBrowser) {
|
|
611
|
+
window.removeEventListener('storage', this._storageHandler);
|
|
612
|
+
this._storageHandler = null;
|
|
613
|
+
}
|
|
614
|
+
this.events.clear();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function createTabChannel(channelName) {
|
|
619
|
+
return new TabChannel(channelName);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ─── 5. SMART POLLER (BATTERY & TAB-AWARE) ──────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Smart Poller that automatically pauses when the browser tab is hidden/inactive,
|
|
626
|
+
* and handles execution delays safely without interval drift.
|
|
627
|
+
*/
|
|
628
|
+
export class SmartPoller {
|
|
629
|
+
constructor(taskFn, options = {}) {
|
|
630
|
+
this.taskFn = taskFn;
|
|
631
|
+
this.options = {
|
|
632
|
+
interval: 5000,
|
|
633
|
+
immediate: true,
|
|
634
|
+
pauseWhenHidden: true,
|
|
635
|
+
...options,
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
this.isRunning = false;
|
|
639
|
+
this._timer = null;
|
|
640
|
+
this._visibilityHandler = null;
|
|
641
|
+
|
|
642
|
+
if (isDocAvailable && this.options.pauseWhenHidden) {
|
|
643
|
+
this._visibilityHandler = () => {
|
|
644
|
+
if (document.hidden) {
|
|
645
|
+
this._stopTimer();
|
|
646
|
+
} else if (this.isRunning) {
|
|
647
|
+
this._tick();
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
document.addEventListener('visibilitychange', this._visibilityHandler);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (this.options.immediate) {
|
|
654
|
+
this.start();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
start() {
|
|
659
|
+
if (this.isRunning) return;
|
|
660
|
+
this.isRunning = true;
|
|
661
|
+
this._tick();
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
stop() {
|
|
665
|
+
this.isRunning = false;
|
|
666
|
+
this._stopTimer();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
destroy() {
|
|
670
|
+
this.stop();
|
|
671
|
+
if (this._visibilityHandler && isDocAvailable) {
|
|
672
|
+
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
|
673
|
+
this._visibilityHandler = null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async _tick() {
|
|
678
|
+
this._stopTimer();
|
|
679
|
+
if (!this.isRunning) return;
|
|
680
|
+
|
|
681
|
+
if (isDocAvailable && this.options.pauseWhenHidden && document.hidden) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
await this.taskFn();
|
|
687
|
+
} catch (err) {
|
|
688
|
+
console.error('[cynx/realtime] Error during poll execution:', err);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (this.isRunning) {
|
|
692
|
+
this._timer = setTimeout(() => this._tick(), this.options.interval);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
_stopTimer() {
|
|
697
|
+
if (this._timer) {
|
|
698
|
+
clearTimeout(this._timer);
|
|
699
|
+
this._timer = null;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
export function createPoller(taskFn, options) {
|
|
705
|
+
return new SmartPoller(taskFn, options);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ─── 6. BACKEND WEBSOCKET SERVER MANAGER ────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Server-side wrapper around a connected client.
|
|
712
|
+
*/
|
|
713
|
+
export class ConnectedClient {
|
|
714
|
+
constructor(socket, server, options = {}) {
|
|
715
|
+
this.id = options.id || (typeof uid === 'function' ? uid(12) : `client_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
|
|
716
|
+
this.socket = socket;
|
|
717
|
+
this.server = server;
|
|
718
|
+
this.req = options.req || null;
|
|
719
|
+
this.isAlive = true;
|
|
720
|
+
this.rooms = new Set();
|
|
721
|
+
this.data = {}; // user-defined state/metadata (e.g. userId, roles, session)
|
|
722
|
+
this.events = new EventBus();
|
|
723
|
+
|
|
724
|
+
this._bindEvents();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
send(eventOrData, payload = undefined) {
|
|
728
|
+
if (this.socket.readyState !== 1) return false;
|
|
729
|
+
let message;
|
|
730
|
+
if (payload !== undefined) {
|
|
731
|
+
message = JSON.stringify({ event: eventOrData, data: payload });
|
|
732
|
+
} else if (typeof eventOrData === 'object') {
|
|
733
|
+
message = JSON.stringify(eventOrData);
|
|
734
|
+
} else {
|
|
735
|
+
message = String(eventOrData);
|
|
736
|
+
}
|
|
737
|
+
this.socket.send(message);
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
reply(requestId, data = null, error = null) {
|
|
742
|
+
return this.send({ requestId, data, error });
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
join(room) {
|
|
746
|
+
this.server.join(this, room);
|
|
747
|
+
return this;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
leave(room) {
|
|
751
|
+
this.server.leave(this, room);
|
|
752
|
+
return this;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
broadcast(eventOrData, payload = undefined) {
|
|
756
|
+
this.server.broadcast(eventOrData, payload, this);
|
|
757
|
+
return this;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
broadcastTo(room, eventOrData, payload = undefined) {
|
|
761
|
+
this.server.broadcastTo(room, eventOrData, payload, this);
|
|
762
|
+
return this;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
on(event, callback) {
|
|
766
|
+
return this.events.on(event, callback);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
once(event, callback) {
|
|
770
|
+
return this.events.once(event, callback);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
off(event, callback) {
|
|
774
|
+
this.events.off(event, callback);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
close(code = 1000, reason = 'Server closed connection') {
|
|
778
|
+
if (this.socket.readyState === 1 || this.socket.readyState === 0) {
|
|
779
|
+
this.socket.close(code, reason);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
_bindEvents() {
|
|
784
|
+
if (typeof this.socket.on === 'function') {
|
|
785
|
+
this.socket.on('message', (raw) => {
|
|
786
|
+
this.isAlive = true;
|
|
787
|
+
const str = raw instanceof Buffer ? raw.toString('utf-8') : String(raw);
|
|
788
|
+
const parsed = safeJSONParse(str, null);
|
|
789
|
+
|
|
790
|
+
if (parsed && typeof parsed === 'object') {
|
|
791
|
+
// Heartbeat ping/pong response
|
|
792
|
+
if (parsed.type === 'ping') {
|
|
793
|
+
this.send({ type: 'pong' });
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Typed event handling
|
|
798
|
+
const eventName = parsed.event || parsed.type;
|
|
799
|
+
if (eventName) {
|
|
800
|
+
this.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed, this);
|
|
801
|
+
this.server.events.emit(eventName, parsed.data !== undefined ? parsed.data : parsed, this);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
this.events.emit('message', parsed !== null ? parsed : str, this);
|
|
806
|
+
this.server.events.emit('message', parsed !== null ? parsed : str, this);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
this.socket.on('pong', () => {
|
|
810
|
+
this.isAlive = true;
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
this.socket.on('close', (code, reason) => {
|
|
814
|
+
this.server._handleClientDisconnect(this, code, reason);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
this.socket.on('error', (err) => {
|
|
818
|
+
this.events.emit('error', err, this);
|
|
819
|
+
this.server.events.emit('client_error', err, this);
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Universal WebSocket Server Manager for Node.js / Bun / Express / Fastify / HTTP Server.
|
|
827
|
+
* Supports rooms, broadcasting, automatic heartbeat cleanup, and request-response handling.
|
|
828
|
+
*/
|
|
829
|
+
export class RealtimeServer {
|
|
830
|
+
constructor(options = {}) {
|
|
831
|
+
this.options = {
|
|
832
|
+
server: undefined, // HTTP/HTTPS server instance
|
|
833
|
+
port: undefined, // Standalone port
|
|
834
|
+
path: '/ws',
|
|
835
|
+
heartbeatInterval: 30000,
|
|
836
|
+
WebSocketServer: undefined, // ws.WebSocketServer constructor
|
|
837
|
+
wss: undefined, // Existing WebSocketServer instance
|
|
838
|
+
authenticate: undefined, // (req) => Promise<boolean|object>
|
|
839
|
+
...options,
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
this.clients = new Map(); // id -> ConnectedClient
|
|
843
|
+
this.rooms = new Map(); // room -> Set<ConnectedClient>
|
|
844
|
+
this.events = new EventBus();
|
|
845
|
+
this.wss = null;
|
|
846
|
+
this._heartbeatTimer = null;
|
|
847
|
+
|
|
848
|
+
if (this.options.wss) {
|
|
849
|
+
this.attachWSS(this.options.wss);
|
|
850
|
+
} else if (this.options.server || this.options.port || this.options.WebSocketServer) {
|
|
851
|
+
this.initServer();
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
initServer() {
|
|
856
|
+
let WSS = this.options.WebSocketServer;
|
|
857
|
+
if (!WSS && typeof globalThis !== 'undefined' && globalThis.WebSocketServer) {
|
|
858
|
+
WSS = globalThis.WebSocketServer;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!WSS) {
|
|
862
|
+
throw new Error(
|
|
863
|
+
'[cynx/realtime] WebSocketServer constructor not found. Pass `{ WebSocketServer: ws.WebSocketServer }` or an existing `wss` instance.'
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const wssOptions = {};
|
|
868
|
+
if (this.options.server) wssOptions.server = this.options.server;
|
|
869
|
+
if (this.options.port) wssOptions.port = this.options.port;
|
|
870
|
+
if (this.options.path) wssOptions.path = this.options.path;
|
|
871
|
+
|
|
872
|
+
this.wss = new WSS(wssOptions);
|
|
873
|
+
this.attachWSS(this.wss);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
attachWSS(wss) {
|
|
877
|
+
this.wss = wss;
|
|
878
|
+
|
|
879
|
+
this.wss.on('connection', async (rawSocket, req) => {
|
|
880
|
+
try {
|
|
881
|
+
if (typeof this.options.authenticate === 'function') {
|
|
882
|
+
const authResult = await this.options.authenticate(req);
|
|
883
|
+
if (!authResult) {
|
|
884
|
+
rawSocket.close(4001, 'Unauthorized');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
if (typeof authResult === 'object') {
|
|
888
|
+
rawSocket._authData = authResult;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const client = new ConnectedClient(rawSocket, this, { req });
|
|
893
|
+
if (rawSocket._authData) {
|
|
894
|
+
client.data = { ...rawSocket._authData };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
this.clients.set(client.id, client);
|
|
898
|
+
this.events.emit('connection', client);
|
|
899
|
+
client.events.emit('connect', client);
|
|
900
|
+
} catch (err) {
|
|
901
|
+
this.events.emit('error', err);
|
|
902
|
+
rawSocket.close(4000, 'Connection error');
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
this.wss.on('error', (err) => {
|
|
907
|
+
this.events.emit('error', err);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
this._startHeartbeat();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
broadcast(eventOrData, payload = undefined, excludeClient = null) {
|
|
914
|
+
const excludeId = excludeClient ? (excludeClient.id || excludeClient) : null;
|
|
915
|
+
for (const client of this.clients.values()) {
|
|
916
|
+
if (client.id !== excludeId) {
|
|
917
|
+
client.send(eventOrData, payload);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
broadcastTo(room, eventOrData, payload = undefined, excludeClient = null) {
|
|
923
|
+
if (!this.rooms.has(room)) return;
|
|
924
|
+
const excludeId = excludeClient ? (excludeClient.id || excludeClient) : null;
|
|
925
|
+
for (const client of this.rooms.get(room)) {
|
|
926
|
+
if (client.id !== excludeId) {
|
|
927
|
+
client.send(eventOrData, payload);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
join(clientOrId, room) {
|
|
933
|
+
const client = typeof clientOrId === 'string' ? this.clients.get(clientOrId) : clientOrId;
|
|
934
|
+
if (!client) return;
|
|
935
|
+
|
|
936
|
+
if (!this.rooms.has(room)) {
|
|
937
|
+
this.rooms.set(room, new Set());
|
|
938
|
+
}
|
|
939
|
+
this.rooms.get(room).add(client);
|
|
940
|
+
client.rooms.add(room);
|
|
941
|
+
this.events.emit('join_room', { client, room });
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
leave(clientOrId, room) {
|
|
945
|
+
const client = typeof clientOrId === 'string' ? this.clients.get(clientOrId) : clientOrId;
|
|
946
|
+
if (!client) return;
|
|
947
|
+
|
|
948
|
+
if (this.rooms.has(room)) {
|
|
949
|
+
const roomSet = this.rooms.get(room);
|
|
950
|
+
roomSet.delete(client);
|
|
951
|
+
if (roomSet.size === 0) this.rooms.delete(room);
|
|
952
|
+
}
|
|
953
|
+
client.rooms.delete(room);
|
|
954
|
+
this.events.emit('leave_room', { client, room });
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
getClient(id) {
|
|
958
|
+
return this.clients.get(id);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
getRoomClients(room) {
|
|
962
|
+
return Array.from(this.rooms.get(room) || []);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
getOnlineCount() {
|
|
966
|
+
return this.clients.size;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
on(event, callback) {
|
|
970
|
+
return this.events.on(event, callback);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
off(event, callback) {
|
|
974
|
+
this.events.off(event, callback);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
close() {
|
|
978
|
+
if (this._heartbeatTimer) clearInterval(this._heartbeatTimer);
|
|
979
|
+
for (const client of this.clients.values()) {
|
|
980
|
+
client.close(1000, 'Server shutdown');
|
|
981
|
+
}
|
|
982
|
+
this.clients.clear();
|
|
983
|
+
this.rooms.clear();
|
|
984
|
+
if (this.wss && typeof this.wss.close === 'function') {
|
|
985
|
+
this.wss.close();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
_handleClientDisconnect(client, code, reason) {
|
|
990
|
+
for (const room of client.rooms) {
|
|
991
|
+
this.leave(client, room);
|
|
992
|
+
}
|
|
993
|
+
this.clients.delete(client.id);
|
|
994
|
+
client.events.emit('close', code, reason);
|
|
995
|
+
this.events.emit('disconnection', client, code, reason);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
_startHeartbeat() {
|
|
999
|
+
if (!this.options.heartbeatInterval) return;
|
|
1000
|
+
this._heartbeatTimer = setInterval(() => {
|
|
1001
|
+
for (const client of this.clients.values()) {
|
|
1002
|
+
if (!client.isAlive) {
|
|
1003
|
+
client.close(4000, 'Heartbeat timeout');
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
client.isAlive = false;
|
|
1007
|
+
if (typeof client.socket.ping === 'function') {
|
|
1008
|
+
client.socket.ping();
|
|
1009
|
+
} else {
|
|
1010
|
+
client.send({ type: 'ping' });
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}, this.options.heartbeatInterval);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
export function createRealtimeServer(options) {
|
|
1018
|
+
return new RealtimeServer(options);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ─── 7. UNIVERSAL URL NORMALIZER ────────────────────────────────────────────────
|
|
1022
|
+
/**
|
|
1023
|
+
* Normalizes HTTP/HTTPS/WS/WSS and relative URLs into valid WebSocket endpoints.
|
|
1024
|
+
* Automatically removes accidental duplicate /ws paths or trailing slashes.
|
|
1025
|
+
*/
|
|
1026
|
+
export function normalizeWsUrl(rawUrl, defaultPath = '/ws') {
|
|
1027
|
+
if (!rawUrl || typeof rawUrl !== 'string') {
|
|
1028
|
+
if (isBrowser) {
|
|
1029
|
+
const isHttps = window.location.protocol === 'https:';
|
|
1030
|
+
const defaultScheme = isHttps ? 'wss:' : 'ws:';
|
|
1031
|
+
return `${defaultScheme}//${window.location.host}${defaultPath}`;
|
|
1032
|
+
}
|
|
1033
|
+
return '';
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
let url = rawUrl.trim();
|
|
1037
|
+
const isHttps = isBrowser ? window.location.protocol === 'https:' : false;
|
|
1038
|
+
const defaultScheme = isHttps ? 'wss:' : 'ws:';
|
|
1039
|
+
|
|
1040
|
+
// 1. Protocol-relative URL: //example.com/ws
|
|
1041
|
+
if (url.startsWith('//')) {
|
|
1042
|
+
url = `${defaultScheme}${url}`;
|
|
1043
|
+
}
|
|
1044
|
+
// 2. Relative path: /ws
|
|
1045
|
+
else if (url.startsWith('/')) {
|
|
1046
|
+
if (isBrowser) {
|
|
1047
|
+
url = `${defaultScheme}//${window.location.host}${url}`;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// 3. HTTP -> WS
|
|
1051
|
+
else if (/^http:\/\//i.test(url)) {
|
|
1052
|
+
url = url.replace(/^http:\/\//i, 'ws://');
|
|
1053
|
+
}
|
|
1054
|
+
// 4. HTTPS -> WSS
|
|
1055
|
+
else if (/^https:\/\//i.test(url)) {
|
|
1056
|
+
url = url.replace(/^https:\/\//i, 'wss://');
|
|
1057
|
+
}
|
|
1058
|
+
// 5. Raw host without scheme
|
|
1059
|
+
else if (!/^wss?:\/\//i.test(url)) {
|
|
1060
|
+
const isLocal = url.includes('localhost') || url.includes('127.0.0.1');
|
|
1061
|
+
const scheme = isLocal && !isHttps ? 'ws://' : 'wss://';
|
|
1062
|
+
url = `${scheme}${url}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Ensure path is normalized without duplicate /ws/ws
|
|
1066
|
+
try {
|
|
1067
|
+
const httpEquivalent = url.replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
|
|
1068
|
+
const parsed = new URL(httpEquivalent);
|
|
1069
|
+
let pathname = parsed.pathname || defaultPath;
|
|
1070
|
+
if (!pathname || pathname === '/') {
|
|
1071
|
+
pathname = defaultPath;
|
|
1072
|
+
}
|
|
1073
|
+
pathname = pathname.replace(/\/+/g, '/').replace(/\/ws\/ws(\/|$)/g, '/ws$1');
|
|
1074
|
+
const scheme = url.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
|
|
1075
|
+
return `${scheme}//${parsed.host}${pathname}`;
|
|
1076
|
+
} catch {
|
|
1077
|
+
return url;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// ─── 8. UNIVERSAL REALTIME FRONTEND CLIENT ──────────────────────────────────────
|
|
1082
|
+
/**
|
|
1083
|
+
* Universal Realtime Client for frontends/browsers.
|
|
1084
|
+
* Manages WebSocket connections through proxy or direct, user token resolution,
|
|
1085
|
+
* automatic heartbeat ping-pong, typed event bus, and global subscriptions.
|
|
1086
|
+
*/
|
|
1087
|
+
export class RealtimeClient {
|
|
1088
|
+
constructor(options = {}) {
|
|
1089
|
+
const defaultUrl = isBrowser ? (window.location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + window.location.host + '/ws' : '/ws';
|
|
1090
|
+
this.options = {
|
|
1091
|
+
url: defaultUrl,
|
|
1092
|
+
token: undefined, // string or function: () => token
|
|
1093
|
+
room: undefined, // optional room name or function: () => room
|
|
1094
|
+
autoConnect: true,
|
|
1095
|
+
reconnect: true,
|
|
1096
|
+
maxRetries: Infinity,
|
|
1097
|
+
initialRetryDelay: 1000,
|
|
1098
|
+
maxRetryDelay: 30000,
|
|
1099
|
+
backoffFactor: 1.5,
|
|
1100
|
+
jitter: true,
|
|
1101
|
+
heartbeatInterval: 20000,
|
|
1102
|
+
pingPayload: { type: 'PING' },
|
|
1103
|
+
...options,
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
this.events = new EventBus();
|
|
1107
|
+
this.ws = null;
|
|
1108
|
+
this.status = 'DISCONNECTED';
|
|
1109
|
+
this.retries = 0;
|
|
1110
|
+
this._pingTimer = null;
|
|
1111
|
+
this._reconnectTimer = null;
|
|
1112
|
+
this._isExplicitClose = false;
|
|
1113
|
+
this._connecting = false;
|
|
1114
|
+
|
|
1115
|
+
if (this.options.autoConnect) {
|
|
1116
|
+
this.init();
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
getToken() {
|
|
1121
|
+
if (typeof this.options.token === 'function') {
|
|
1122
|
+
return this.options.token();
|
|
1123
|
+
}
|
|
1124
|
+
if (typeof this.options.token === 'string') {
|
|
1125
|
+
return this.options.token;
|
|
1126
|
+
}
|
|
1127
|
+
if (isBrowser) {
|
|
1128
|
+
return localStorage.getItem('token') || sessionStorage.getItem('token') || '';
|
|
1129
|
+
}
|
|
1130
|
+
return '';
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
getRoom() {
|
|
1134
|
+
if (typeof this.options.room === 'function') {
|
|
1135
|
+
return this.options.room();
|
|
1136
|
+
}
|
|
1137
|
+
if (typeof this.options.room === 'string') {
|
|
1138
|
+
return this.options.room;
|
|
1139
|
+
}
|
|
1140
|
+
return '';
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
init() {
|
|
1144
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
const token = this.getToken();
|
|
1148
|
+
if (!token && !this.options.anonymous) {
|
|
1149
|
+
return; // Wait until token is available
|
|
1150
|
+
}
|
|
1151
|
+
this.connect();
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
connect() {
|
|
1155
|
+
if (this._connecting) return;
|
|
1156
|
+
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return;
|
|
1157
|
+
|
|
1158
|
+
this._connecting = true;
|
|
1159
|
+
this._isExplicitClose = false;
|
|
1160
|
+
this.cleanup();
|
|
1161
|
+
|
|
1162
|
+
const WS = getWebSocketConstructor(this.options.WebSocket);
|
|
1163
|
+
if (!WS) {
|
|
1164
|
+
this._connecting = false;
|
|
1165
|
+
console.warn('[cynx/realtime] WebSocket implementation not found in this environment.');
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
try {
|
|
1170
|
+
const rawUrl = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
|
|
1171
|
+
const normalized = normalizeWsUrl(rawUrl);
|
|
1172
|
+
const token = this.getToken();
|
|
1173
|
+
const room = this.getRoom();
|
|
1174
|
+
|
|
1175
|
+
const urlObj = new URL(normalized.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:'));
|
|
1176
|
+
if (token && !urlObj.searchParams.has('token')) {
|
|
1177
|
+
urlObj.searchParams.set('token', token);
|
|
1178
|
+
}
|
|
1179
|
+
if (room && !urlObj.searchParams.has('room')) {
|
|
1180
|
+
urlObj.searchParams.set('room', room);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const finalScheme = normalized.toLowerCase().startsWith('wss:') ? 'wss:' : 'ws:';
|
|
1184
|
+
const finalUrl = `${finalScheme}//${urlObj.host}${urlObj.pathname}${urlObj.search}`;
|
|
1185
|
+
|
|
1186
|
+
this.ws = new WS(finalUrl);
|
|
1187
|
+
this.status = this.retries > 0 ? 'RECONNECTING' : 'CONNECTING';
|
|
1188
|
+
|
|
1189
|
+
this.ws.onopen = (e) => {
|
|
1190
|
+
this._connecting = false;
|
|
1191
|
+
this.retries = 0;
|
|
1192
|
+
this.status = 'CONNECTED';
|
|
1193
|
+
this.events.emit('open', e);
|
|
1194
|
+
this.events.emit('status', this.status);
|
|
1195
|
+
this._startHeartbeat();
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
this.ws.onmessage = (event) => {
|
|
1199
|
+
try {
|
|
1200
|
+
const raw = typeof event.data === 'string' ? event.data : String(event.data);
|
|
1201
|
+
const parsed = safeJSONParse(raw, raw);
|
|
1202
|
+
const eventType = (parsed && typeof parsed === 'object') ? (parsed.type || parsed.event || 'message') : 'message';
|
|
1203
|
+
|
|
1204
|
+
if (parsed && parsed.type === 'PONG') {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
this.events.emit(eventType, parsed);
|
|
1209
|
+
this.events.emit('message', parsed);
|
|
1210
|
+
this.events.emit('*', parsed);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
console.warn('[cynx/realtime] Message parse warning:', err);
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
this.ws.onerror = (err) => {
|
|
1217
|
+
this.events.emit('error', err);
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
this.ws.onclose = (e) => {
|
|
1221
|
+
this._connecting = false;
|
|
1222
|
+
this.status = 'DISCONNECTED';
|
|
1223
|
+
this._stopHeartbeat();
|
|
1224
|
+
this.events.emit('close', e);
|
|
1225
|
+
this.events.emit('status', this.status);
|
|
1226
|
+
|
|
1227
|
+
if (!this._isExplicitClose && this.options.reconnect && this.retries < this.options.maxRetries) {
|
|
1228
|
+
this._handleReconnect();
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
this._connecting = false;
|
|
1233
|
+
this._handleReconnect();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
send(eventOrData, payload = undefined) {
|
|
1238
|
+
if (!this.ws || this.ws.readyState !== 1) return false;
|
|
1239
|
+
let message;
|
|
1240
|
+
if (payload !== undefined) {
|
|
1241
|
+
message = JSON.stringify({ type: eventOrData, payload });
|
|
1242
|
+
} else if (typeof eventOrData === 'object') {
|
|
1243
|
+
message = JSON.stringify(eventOrData);
|
|
1244
|
+
} else {
|
|
1245
|
+
message = String(eventOrData);
|
|
1246
|
+
}
|
|
1247
|
+
this.ws.send(message);
|
|
1248
|
+
return true;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
emit(event, payload = undefined) {
|
|
1252
|
+
return this.send(event, payload);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
subscribe(callback) {
|
|
1256
|
+
const unsub = this.events.on('*', callback);
|
|
1257
|
+
this.init();
|
|
1258
|
+
return unsub;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
on(event, callback) {
|
|
1262
|
+
const unsub = this.events.on(event, callback);
|
|
1263
|
+
this.init();
|
|
1264
|
+
return unsub;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
once(event, callback) {
|
|
1268
|
+
const unsub = this.events.once(event, callback);
|
|
1269
|
+
this.init();
|
|
1270
|
+
return unsub;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
off(event, callback) {
|
|
1274
|
+
this.events.off(event, callback);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
disconnect(code = 1000, reason = 'Client disconnected') {
|
|
1278
|
+
this._isExplicitClose = true;
|
|
1279
|
+
this.cleanup();
|
|
1280
|
+
if (this.ws) {
|
|
1281
|
+
try { this.ws.close(code, reason); } catch {}
|
|
1282
|
+
this.ws = null;
|
|
1283
|
+
}
|
|
1284
|
+
this.status = 'DISCONNECTED';
|
|
1285
|
+
this.events.emit('status', this.status);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
cleanup() {
|
|
1289
|
+
this._stopHeartbeat();
|
|
1290
|
+
if (this._reconnectTimer) {
|
|
1291
|
+
clearTimeout(this._reconnectTimer);
|
|
1292
|
+
this._reconnectTimer = null;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
_startHeartbeat() {
|
|
1297
|
+
this._stopHeartbeat();
|
|
1298
|
+
if (!this.options.heartbeatInterval) return;
|
|
1299
|
+
this._pingTimer = setInterval(() => {
|
|
1300
|
+
if (this.ws && this.ws.readyState === 1) {
|
|
1301
|
+
try {
|
|
1302
|
+
this.ws.send(JSON.stringify(this.options.pingPayload));
|
|
1303
|
+
} catch {}
|
|
1304
|
+
}
|
|
1305
|
+
}, this.options.heartbeatInterval);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
_stopHeartbeat() {
|
|
1309
|
+
if (this._pingTimer) {
|
|
1310
|
+
clearInterval(this._pingTimer);
|
|
1311
|
+
this._pingTimer = null;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
_handleReconnect() {
|
|
1316
|
+
if (this._reconnectTimer) return;
|
|
1317
|
+
this.retries++;
|
|
1318
|
+
const delay = Math.min(
|
|
1319
|
+
this.options.initialRetryDelay * Math.pow(this.options.backoffFactor, this.retries - 1),
|
|
1320
|
+
this.options.maxRetryDelay
|
|
1321
|
+
);
|
|
1322
|
+
const finalDelay = this.options.jitter ? delay * (0.8 + Math.random() * 0.4) : delay;
|
|
1323
|
+
|
|
1324
|
+
this._reconnectTimer = setTimeout(() => {
|
|
1325
|
+
this._reconnectTimer = null;
|
|
1326
|
+
this.connect();
|
|
1327
|
+
}, finalDelay);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
export function createRealtimeClient(options) {
|
|
1332
|
+
return new RealtimeClient(options);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// ─── 9. UNIVERSAL BACKEND WEBSOCKET PROXY / REPEATER ────────────────────────────
|
|
1336
|
+
/**
|
|
1337
|
+
* Universal WebSocket Repeater/Proxy for application backends.
|
|
1338
|
+
* Intercepts /ws upgrades, validates client sessions (protecting master WS_KEY),
|
|
1339
|
+
* and safely pipes traffic to/from upstream ws-server.
|
|
1340
|
+
*/
|
|
1341
|
+
export class RealtimeProxy {
|
|
1342
|
+
constructor(server, options = {}) {
|
|
1343
|
+
this.server = server;
|
|
1344
|
+
this.options = {
|
|
1345
|
+
path: '/ws',
|
|
1346
|
+
upstreamUrl: undefined, // process.env.WS_URL
|
|
1347
|
+
upstreamKey: undefined, // process.env.WS_KEY
|
|
1348
|
+
room: 'default', // process.env.WS_ROOM
|
|
1349
|
+
authenticate: undefined,// async (req, token) => ({ userId })
|
|
1350
|
+
WebSocket: undefined,
|
|
1351
|
+
WebSocketServer: undefined,
|
|
1352
|
+
...options,
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
this.wss = null;
|
|
1356
|
+
this.events = new EventBus();
|
|
1357
|
+
|
|
1358
|
+
if (this.server) {
|
|
1359
|
+
this.attach(this.server);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
attach(server) {
|
|
1364
|
+
this.server = server;
|
|
1365
|
+
let WSS = this.options.WebSocketServer;
|
|
1366
|
+
if (!WSS && typeof globalThis !== 'undefined' && globalThis.WebSocketServer) {
|
|
1367
|
+
WSS = globalThis.WebSocketServer;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (!WSS) {
|
|
1371
|
+
try {
|
|
1372
|
+
const wsModule = typeof require === 'function' ? require('ws') : null;
|
|
1373
|
+
if (wsModule && wsModule.WebSocketServer) {
|
|
1374
|
+
WSS = wsModule.WebSocketServer;
|
|
1375
|
+
} else if (wsModule && wsModule.Server) {
|
|
1376
|
+
WSS = wsModule.Server;
|
|
1377
|
+
}
|
|
1378
|
+
} catch {}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (!WSS) {
|
|
1382
|
+
console.warn('[cynx/realtime] WebSocketServer not available for RealtimeProxy.');
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
this.wss = new WSS({ noServer: true });
|
|
1387
|
+
|
|
1388
|
+
server.on('upgrade', async (request, socket, head) => {
|
|
1389
|
+
try {
|
|
1390
|
+
const url = new URL(request.url, `http://${request.headers.host || 'localhost'}`);
|
|
1391
|
+
if (url.pathname !== this.options.path) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const token = (
|
|
1396
|
+
url.searchParams.get('token') ||
|
|
1397
|
+
request.headers['authorization']?.replace(/^Bearer\s+/i, '') ||
|
|
1398
|
+
request.headers['x-auth-token'] ||
|
|
1399
|
+
''
|
|
1400
|
+
).trim();
|
|
1401
|
+
|
|
1402
|
+
let authData = null;
|
|
1403
|
+
if (typeof this.options.authenticate === 'function') {
|
|
1404
|
+
authData = await this.options.authenticate(request, token);
|
|
1405
|
+
if (!authData) {
|
|
1406
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
1407
|
+
socket.destroy();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const room = (url.searchParams.get('room') || this.options.room || 'default').trim().toLowerCase();
|
|
1413
|
+
|
|
1414
|
+
this.wss.handleUpgrade(request, socket, head, (clientWs) => {
|
|
1415
|
+
this.wss.emit('connection', clientWs, request, { authData, room });
|
|
1416
|
+
});
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
console.warn('[RealtimeProxy Upgrade Error]:', err.message);
|
|
1419
|
+
try {
|
|
1420
|
+
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
|
1421
|
+
socket.destroy();
|
|
1422
|
+
} catch {}
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
this.wss.on('connection', (clientWs, request, { authData, room }) => {
|
|
1427
|
+
this._handleConnection(clientWs, { authData, room });
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
_handleConnection(clientWs, { authData, room }) {
|
|
1432
|
+
const WS_URL = this.options.upstreamUrl || (typeof process !== 'undefined' ? process.env.WS_URL : '');
|
|
1433
|
+
const WS_KEY = this.options.upstreamKey || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
|
|
1434
|
+
const targetRoom = room || this.options.room || 'default';
|
|
1435
|
+
|
|
1436
|
+
if (!WS_URL || !WS_KEY) {
|
|
1437
|
+
clientWs.close(1011, 'Real-Time upstream not configured');
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Rock-solid URL normalization: strip duplicate /ws/
|
|
1442
|
+
const upstreamBase = WS_URL
|
|
1443
|
+
.replace(/^http:\/\//i, 'ws://')
|
|
1444
|
+
.replace(/^https:\/\//i, 'wss://')
|
|
1445
|
+
.replace(/\/ws\/?$/i, '')
|
|
1446
|
+
.replace(/\/$/, '');
|
|
1447
|
+
|
|
1448
|
+
const upstreamUrl = `${upstreamBase}/ws?room=${encodeURIComponent(targetRoom)}&token=${encodeURIComponent(WS_KEY)}`;
|
|
1449
|
+
|
|
1450
|
+
let WS = this.options.WebSocket;
|
|
1451
|
+
if (!WS && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
1452
|
+
WS = globalThis.WebSocket;
|
|
1453
|
+
}
|
|
1454
|
+
if (!WS) {
|
|
1455
|
+
try {
|
|
1456
|
+
const wsModule = typeof require === 'function' ? require('ws') : null;
|
|
1457
|
+
if (wsModule) WS = wsModule;
|
|
1458
|
+
} catch {}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (!WS) {
|
|
1462
|
+
clientWs.close(1011, 'WebSocket client constructor not available');
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
let upstreamWs = null;
|
|
1467
|
+
try {
|
|
1468
|
+
upstreamWs = new WS(upstreamUrl);
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
clientWs.close(1011, 'Failed to connect upstream');
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Upstream -> Client
|
|
1475
|
+
upstreamWs.onmessage = (event) => {
|
|
1476
|
+
if (clientWs.readyState === 1) {
|
|
1477
|
+
clientWs.send(event.data);
|
|
1478
|
+
}
|
|
1479
|
+
};
|
|
1480
|
+
if (typeof upstreamWs.on === 'function') {
|
|
1481
|
+
upstreamWs.on('message', (data, isBinary) => {
|
|
1482
|
+
if (clientWs.readyState === 1) {
|
|
1483
|
+
clientWs.send(data, { binary: isBinary });
|
|
1484
|
+
}
|
|
1485
|
+
});
|
|
1486
|
+
upstreamWs.on('error', (err) => {
|
|
1487
|
+
console.warn('[RealtimeProxy Upstream Error]:', err.message);
|
|
1488
|
+
if (clientWs.readyState === 1) clientWs.close(1011, 'Upstream error');
|
|
1489
|
+
});
|
|
1490
|
+
upstreamWs.on('close', (code, reason) => {
|
|
1491
|
+
if (clientWs.readyState === 1) clientWs.close(code, reason);
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Client -> Upstream
|
|
1496
|
+
if (typeof clientWs.on === 'function') {
|
|
1497
|
+
clientWs.on('message', (data, isBinary) => {
|
|
1498
|
+
if (upstreamWs && upstreamWs.readyState === 1) {
|
|
1499
|
+
upstreamWs.send(data, { binary: isBinary });
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
clientWs.on('error', () => {
|
|
1503
|
+
if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
|
|
1504
|
+
});
|
|
1505
|
+
clientWs.on('close', () => {
|
|
1506
|
+
if (upstreamWs && upstreamWs.readyState === 1) upstreamWs.close();
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
export function createRealtimeProxy(server, options) {
|
|
1513
|
+
return new RealtimeProxy(server, options);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ─── 10. UNIVERSAL REALTIME DISPATCHER (HTTP BROADCASTER) ───────────────────────
|
|
1517
|
+
/**
|
|
1518
|
+
* Universal Event Broadcaster for backend services and microservices.
|
|
1519
|
+
* Sends broadcast events to ws-server via HTTP REST (POST /api/broadcast and /broadcast).
|
|
1520
|
+
*/
|
|
1521
|
+
export class RealtimeDispatcher {
|
|
1522
|
+
constructor(options = {}) {
|
|
1523
|
+
this.options = {
|
|
1524
|
+
url: undefined, // process.env.WS_URL
|
|
1525
|
+
key: undefined, // process.env.WS_KEY
|
|
1526
|
+
room: undefined, // process.env.WS_ROOM
|
|
1527
|
+
fetch: typeof globalThis !== 'undefined' ? globalThis.fetch : undefined,
|
|
1528
|
+
...options,
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async broadcast(eventType, payload = {}, options = {}) {
|
|
1533
|
+
const rawUrl = options.url || this.options.url || (typeof process !== 'undefined' ? process.env.WS_URL : '');
|
|
1534
|
+
const key = options.key || this.options.key || (typeof process !== 'undefined' ? process.env.WS_KEY : '');
|
|
1535
|
+
const room = (options.room || this.options.room || (typeof process !== 'undefined' ? process.env.WS_ROOM : '') || '').trim().toLowerCase();
|
|
1536
|
+
|
|
1537
|
+
if (!rawUrl || !key || !room) {
|
|
1538
|
+
return { ok: false, error: 'Missing WS_URL, WS_KEY, or WS_ROOM configuration' };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const fetchFn = this.options.fetch || globalThis.fetch;
|
|
1542
|
+
if (!fetchFn) {
|
|
1543
|
+
return { ok: false, error: 'fetch function not found in environment' };
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const baseUrl = rawUrl
|
|
1547
|
+
.replace(/^ws:\/\//i, 'http://')
|
|
1548
|
+
.replace(/^wss:\/\//i, 'https://')
|
|
1549
|
+
.replace(/\/ws\/?$/i, '')
|
|
1550
|
+
.replace(/\/$/, '');
|
|
1551
|
+
|
|
1552
|
+
const endpoint = `${baseUrl}/api/broadcast?room=${encodeURIComponent(room)}`;
|
|
1553
|
+
const body = JSON.stringify({
|
|
1554
|
+
type: eventType,
|
|
1555
|
+
room,
|
|
1556
|
+
payload,
|
|
1557
|
+
timestamp: Date.now(),
|
|
1558
|
+
...options.extra,
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
const headers = {
|
|
1562
|
+
'Content-Type': 'application/json',
|
|
1563
|
+
'X-Secret-Key': key,
|
|
1564
|
+
'x-ws-token': key,
|
|
1565
|
+
'x-room': room,
|
|
1566
|
+
'Authorization': `Bearer ${key}`,
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
try {
|
|
1570
|
+
let res = await fetchFn(endpoint, {
|
|
1571
|
+
method: 'POST',
|
|
1572
|
+
headers,
|
|
1573
|
+
body,
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
// Fallback to legacy /broadcast if /api/broadcast returned 404
|
|
1577
|
+
if (res.status === 404) {
|
|
1578
|
+
const legacyEndpoint = `${baseUrl}/broadcast?room=${encodeURIComponent(room)}`;
|
|
1579
|
+
res = await fetchFn(legacyEndpoint, { method: 'POST', headers, body });
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (!res.ok) {
|
|
1583
|
+
const errText = await res.text().catch(() => '');
|
|
1584
|
+
console.warn(`[cynx/realtime] Broadcast warning (${res.status}):`, errText);
|
|
1585
|
+
return { ok: false, status: res.status, error: errText };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const data = await res.json().catch(() => ({ status: 'ok' }));
|
|
1589
|
+
return { ok: true, data };
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
console.warn('[cynx/realtime] Broadcast network error:', err.message);
|
|
1592
|
+
return { ok: false, error: err.message };
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
async broadcastSession(token, eventType, payload = {}, options = {}) {
|
|
1597
|
+
return this.broadcast('session_event', payload, {
|
|
1598
|
+
...options,
|
|
1599
|
+
extra: { token, eventType, sessionToken: token },
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
export function createRealtimeDispatcher(options) {
|
|
1605
|
+
return new RealtimeDispatcher(options);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
|
|
1609
|
+
|
|
1610
|
+
export default {
|
|
1611
|
+
// Client
|
|
1612
|
+
RealtimeSocket,
|
|
1613
|
+
createRealtimeSocket,
|
|
1614
|
+
RealtimeClient,
|
|
1615
|
+
createRealtimeClient,
|
|
1616
|
+
normalizeWsUrl,
|
|
1617
|
+
SSEStream,
|
|
1618
|
+
createSSE,
|
|
1619
|
+
parseAIStream,
|
|
1620
|
+
TabChannel,
|
|
1621
|
+
createTabChannel,
|
|
1622
|
+
SmartPoller,
|
|
1623
|
+
createPoller,
|
|
1624
|
+
EventBus,
|
|
1625
|
+
createEventBus,
|
|
1626
|
+
// Server
|
|
1627
|
+
ConnectedClient,
|
|
1628
|
+
RealtimeServer,
|
|
1629
|
+
createRealtimeServer,
|
|
1630
|
+
RealtimeProxy,
|
|
1631
|
+
createRealtimeProxy,
|
|
1632
|
+
RealtimeDispatcher,
|
|
1633
|
+
createRealtimeDispatcher,
|
|
1634
|
+
};
|