cynx-lib 1.3.35 → 1.3.36
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 +1 -1
- package/utils/index.js +18 -11
- package/utils/utils/duration.js +139 -0
- package/utils/utils/oidc.js +214 -209
- package/utils/utils/realtime.js +1040 -1040
- package/utils/utils/session.js +58 -0
- package/utils/utils/storage.js +393 -393
- package/utils/utils/tz.js +115 -0
- package/utils/utils/ui.js +111 -0
package/utils/utils/realtime.js
CHANGED
|
@@ -1,1040 +1,1040 @@
|
|
|
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
|
-
// ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
|
|
1022
|
-
|
|
1023
|
-
export default {
|
|
1024
|
-
// Client
|
|
1025
|
-
RealtimeSocket,
|
|
1026
|
-
createRealtimeSocket,
|
|
1027
|
-
SSEStream,
|
|
1028
|
-
createSSE,
|
|
1029
|
-
parseAIStream,
|
|
1030
|
-
TabChannel,
|
|
1031
|
-
createTabChannel,
|
|
1032
|
-
SmartPoller,
|
|
1033
|
-
createPoller,
|
|
1034
|
-
EventBus,
|
|
1035
|
-
createEventBus,
|
|
1036
|
-
// Server
|
|
1037
|
-
ConnectedClient,
|
|
1038
|
-
RealtimeServer,
|
|
1039
|
-
createRealtimeServer,
|
|
1040
|
-
};
|
|
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
|
+
// ─── MAIN EXPORT ────────────────────────────────────────────────────────────────
|
|
1022
|
+
|
|
1023
|
+
export default {
|
|
1024
|
+
// Client
|
|
1025
|
+
RealtimeSocket,
|
|
1026
|
+
createRealtimeSocket,
|
|
1027
|
+
SSEStream,
|
|
1028
|
+
createSSE,
|
|
1029
|
+
parseAIStream,
|
|
1030
|
+
TabChannel,
|
|
1031
|
+
createTabChannel,
|
|
1032
|
+
SmartPoller,
|
|
1033
|
+
createPoller,
|
|
1034
|
+
EventBus,
|
|
1035
|
+
createEventBus,
|
|
1036
|
+
// Server
|
|
1037
|
+
ConnectedClient,
|
|
1038
|
+
RealtimeServer,
|
|
1039
|
+
createRealtimeServer,
|
|
1040
|
+
};
|