@gwakko/shared-websocket 0.12.3 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/SharedSocket.d.ts +10 -1
- package/dist/SharedWebSocket.d.ts +154 -5
- package/dist/SubscriptionManager.d.ts +1 -1
- package/dist/WorkerSocket.d.ts +4 -0
- package/dist/adapters/react.d.ts +26 -3
- package/dist/adapters/vue.d.ts +26 -3
- package/dist/{chunk-SQZHBLWT.js → chunk-OQMJRH6C.js} +545 -61
- package/dist/chunk-OQMJRH6C.js.map +1 -0
- package/dist/{chunk-OVKB2KLE.cjs → chunk-YZLE4TZB.cjs} +561 -77
- package/dist/chunk-YZLE4TZB.cjs.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +36 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +28 -7
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +155 -2
- package/dist/vue.cjs +31 -9
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +29 -7
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +8 -2
- package/package.json +1 -1
- package/src/SharedSocket.ts +55 -6
- package/src/SharedWebSocket.ts +601 -62
- package/src/SubscriptionManager.ts +4 -4
- package/src/WorkerSocket.ts +41 -4
- package/src/adapters/react.ts +56 -9
- package/src/adapters/vue.ts +56 -9
- package/src/index.ts +3 -0
- package/src/types.ts +165 -2
- package/src/worker/socket.worker.ts +44 -2
- package/dist/chunk-OVKB2KLE.cjs.map +0 -1
- package/dist/chunk-SQZHBLWT.js.map +0 -1
|
@@ -245,6 +245,7 @@ var SharedSocket = (_class3 = class {
|
|
|
245
245
|
reconnect: _nullishCoalesce(options.reconnect, () => ( true)),
|
|
246
246
|
reconnectMaxDelay: _nullishCoalesce(options.reconnectMaxDelay, () => ( 3e4)),
|
|
247
247
|
reconnectMaxRetries: _nullishCoalesce(options.reconnectMaxRetries, () => ( Infinity)),
|
|
248
|
+
authFailureCloseCodes: new Set(_nullishCoalesce(options.authFailureCloseCodes, () => ( [1008]))),
|
|
248
249
|
heartbeatInterval: _nullishCoalesce(options.heartbeatInterval, () => ( 3e4)),
|
|
249
250
|
sendBuffer: _nullishCoalesce(options.sendBuffer, () => ( 100)),
|
|
250
251
|
auth: options.auth,
|
|
@@ -275,7 +276,13 @@ var SharedSocket = (_class3 = class {
|
|
|
275
276
|
async connect() {
|
|
276
277
|
if (this.disposed) return;
|
|
277
278
|
this.setState("connecting");
|
|
278
|
-
|
|
279
|
+
let connectUrl;
|
|
280
|
+
try {
|
|
281
|
+
connectUrl = await this.buildUrl();
|
|
282
|
+
} catch (e2) {
|
|
283
|
+
this.setState("failed");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
279
286
|
this.ws = new WebSocket(connectUrl, this.opts.protocols);
|
|
280
287
|
this.ws.onopen = () => {
|
|
281
288
|
this.reconnectAttempts = 0;
|
|
@@ -287,15 +294,19 @@ var SharedSocket = (_class3 = class {
|
|
|
287
294
|
let data;
|
|
288
295
|
try {
|
|
289
296
|
data = this.opts.deserialize(ev.data);
|
|
290
|
-
} catch (
|
|
297
|
+
} catch (e3) {
|
|
291
298
|
data = ev.data;
|
|
292
299
|
}
|
|
293
300
|
for (const fn of this.onMessageFns) fn(data);
|
|
294
301
|
};
|
|
295
|
-
this.ws.onclose = () => {
|
|
302
|
+
this.ws.onclose = (ev) => {
|
|
296
303
|
this.stopHeartbeat();
|
|
304
|
+
if (this.opts.authFailureCloseCodes.has(ev.code)) {
|
|
305
|
+
this.setState("failed");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
297
308
|
if (!this.disposed && this.opts.reconnect) {
|
|
298
|
-
this.
|
|
309
|
+
this.scheduleReconnect();
|
|
299
310
|
} else {
|
|
300
311
|
this.setState("closed");
|
|
301
312
|
}
|
|
@@ -335,10 +346,31 @@ var SharedSocket = (_class3 = class {
|
|
|
335
346
|
this.onStateChangeFns.add(fn);
|
|
336
347
|
return () => this.onStateChangeFns.delete(fn);
|
|
337
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Manually trigger a reconnect. Resets the retry counter and clears any
|
|
351
|
+
* scheduled backoff so the next attempt happens immediately. Use after
|
|
352
|
+
* `state === 'failed'` to let the user retry, or any time to force a
|
|
353
|
+
* fresh connection.
|
|
354
|
+
*/
|
|
338
355
|
reconnect() {
|
|
356
|
+
if (this.disposed) return;
|
|
357
|
+
this.clearReconnect();
|
|
358
|
+
this.reconnectAttempts = 0;
|
|
359
|
+
if (this.ws) {
|
|
360
|
+
this.ws.onclose = null;
|
|
361
|
+
this.ws.onmessage = null;
|
|
362
|
+
this.ws.onerror = null;
|
|
363
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
364
|
+
this.ws.close(1e3, "manual reconnect");
|
|
365
|
+
}
|
|
366
|
+
this.ws = null;
|
|
367
|
+
}
|
|
368
|
+
void this.connect();
|
|
369
|
+
}
|
|
370
|
+
scheduleReconnect() {
|
|
339
371
|
this.reconnectAttempts++;
|
|
340
372
|
if (this.reconnectAttempts > this.opts.reconnectMaxRetries) {
|
|
341
|
-
this.setState("
|
|
373
|
+
this.setState("failed");
|
|
342
374
|
return;
|
|
343
375
|
}
|
|
344
376
|
this.setState("reconnecting");
|
|
@@ -382,6 +414,9 @@ var SharedSocket = (_class3 = class {
|
|
|
382
414
|
let token;
|
|
383
415
|
if (this.opts.auth) {
|
|
384
416
|
token = await this.opts.auth();
|
|
417
|
+
if (!token) {
|
|
418
|
+
throw new Error("SharedSocket: auth() returned no token");
|
|
419
|
+
}
|
|
385
420
|
} else if (this.opts.authToken) {
|
|
386
421
|
token = this.opts.authToken;
|
|
387
422
|
}
|
|
@@ -418,10 +453,23 @@ var WorkerSocket = (_class4 = class {
|
|
|
418
453
|
get state() {
|
|
419
454
|
return this._state;
|
|
420
455
|
}
|
|
456
|
+
setState(s) {
|
|
457
|
+
this._state = s;
|
|
458
|
+
for (const fn of this.onStateChangeFns) fn(s);
|
|
459
|
+
}
|
|
421
460
|
async connect() {
|
|
422
461
|
let authToken;
|
|
423
462
|
if (this.options.auth) {
|
|
424
|
-
|
|
463
|
+
try {
|
|
464
|
+
authToken = await this.options.auth();
|
|
465
|
+
} catch (e4) {
|
|
466
|
+
this.setState("failed");
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (!authToken) {
|
|
470
|
+
this.setState("failed");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
425
473
|
} else if (this.options.authToken) {
|
|
426
474
|
authToken = this.options.authToken;
|
|
427
475
|
}
|
|
@@ -454,6 +502,7 @@ var WorkerSocket = (_class4 = class {
|
|
|
454
502
|
reconnect: _nullishCoalesce(this.options.reconnect, () => ( true)),
|
|
455
503
|
reconnectMaxDelay: _nullishCoalesce(this.options.reconnectMaxDelay, () => ( 3e4)),
|
|
456
504
|
reconnectMaxRetries: _nullishCoalesce(this.options.reconnectMaxRetries, () => ( Infinity)),
|
|
505
|
+
authFailureCloseCodes: _nullishCoalesce(this.options.authFailureCloseCodes, () => ( [1008])),
|
|
457
506
|
heartbeatInterval: _nullishCoalesce(this.options.heartbeatInterval, () => ( 3e4)),
|
|
458
507
|
bufferSize: _nullishCoalesce(this.options.sendBuffer, () => ( 100)),
|
|
459
508
|
pingPayload: this.options.pingPayload
|
|
@@ -469,10 +518,14 @@ var WorkerSocket = (_class4 = class {
|
|
|
469
518
|
send(data) {
|
|
470
519
|
_optionalChain([this, 'access', _10 => _10.worker, 'optionalAccess', _11 => _11.postMessage, 'call', _12 => _12({ type: "send", data })]);
|
|
471
520
|
}
|
|
521
|
+
/** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
|
|
522
|
+
reconnect() {
|
|
523
|
+
_optionalChain([this, 'access', _13 => _13.worker, 'optionalAccess', _14 => _14.postMessage, 'call', _15 => _15({ type: "reconnect" })]);
|
|
524
|
+
}
|
|
472
525
|
disconnect() {
|
|
473
|
-
_optionalChain([this, 'access',
|
|
526
|
+
_optionalChain([this, 'access', _16 => _16.worker, 'optionalAccess', _17 => _17.postMessage, 'call', _18 => _18({ type: "disconnect" })]);
|
|
474
527
|
setTimeout(() => {
|
|
475
|
-
_optionalChain([this, 'access',
|
|
528
|
+
_optionalChain([this, 'access', _19 => _19.worker, 'optionalAccess', _20 => _20.terminate, 'call', _21 => _21()]);
|
|
476
529
|
this.worker = null;
|
|
477
530
|
}, 100);
|
|
478
531
|
this._state = "closed";
|
|
@@ -491,6 +544,7 @@ var WorkerSocket = (_class4 = class {
|
|
|
491
544
|
let heartbeatTimer = null, reconnectTimer = null;
|
|
492
545
|
let url = '', protocols = [], shouldReconnect = true;
|
|
493
546
|
let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
|
|
547
|
+
let authFailCodes = new Set([1008]);
|
|
494
548
|
let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
|
|
495
549
|
|
|
496
550
|
function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
|
|
@@ -500,7 +554,12 @@ var WorkerSocket = (_class4 = class {
|
|
|
500
554
|
ws = new WebSocket(url, protocols);
|
|
501
555
|
ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
|
|
502
556
|
ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
|
|
503
|
-
ws.onclose = (e) => {
|
|
557
|
+
ws.onclose = (e) => {
|
|
558
|
+
stopHB();
|
|
559
|
+
self.postMessage({ type: 'close', code: e.code, reason: e.reason });
|
|
560
|
+
if (authFailCodes.has(e.code)) { setState('failed'); return; }
|
|
561
|
+
if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed');
|
|
562
|
+
};
|
|
504
563
|
ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
|
|
505
564
|
}
|
|
506
565
|
function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
|
|
@@ -509,16 +568,24 @@ var WorkerSocket = (_class4 = class {
|
|
|
509
568
|
function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
|
|
510
569
|
function reconnect() {
|
|
511
570
|
attempts++;
|
|
512
|
-
if (attempts > maxRetries) { setState('
|
|
571
|
+
if (attempts > maxRetries) { setState('failed'); return; }
|
|
513
572
|
setState('reconnecting');
|
|
514
573
|
const j = delay * 0.25 * (Math.random() * 2 - 1);
|
|
515
574
|
reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
|
|
516
575
|
delay = Math.min(delay * 2, maxDelay);
|
|
517
576
|
}
|
|
577
|
+
function manualReconnect() {
|
|
578
|
+
if (disposed) return;
|
|
579
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
580
|
+
attempts = 0; delay = 1000;
|
|
581
|
+
if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
|
|
582
|
+
connect();
|
|
583
|
+
}
|
|
518
584
|
self.onmessage = (e) => {
|
|
519
585
|
const c = e.data;
|
|
520
|
-
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
|
|
586
|
+
if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; if (c.authFailureCloseCodes) authFailCodes = new Set(c.authFailureCloseCodes); hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
|
|
521
587
|
if (c.type === 'send') send(c.data);
|
|
588
|
+
if (c.type === 'reconnect') manualReconnect();
|
|
522
589
|
if (c.type === 'disconnect') { disposed = true; stopHB(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) { ws.onclose = null; if (ws.readyState < 2) ws.close(1000); ws = null; } buffer = []; setState('closed'); }
|
|
523
590
|
};
|
|
524
591
|
`;
|
|
@@ -546,25 +613,25 @@ var SubscriptionManager = (_class5 = class {constructor() { _class5.prototype.__
|
|
|
546
613
|
return () => set.delete(handler);
|
|
547
614
|
}
|
|
548
615
|
once(event, handler) {
|
|
549
|
-
const wrapper = (data) => {
|
|
616
|
+
const wrapper = (data, raw) => {
|
|
550
617
|
unsub();
|
|
551
|
-
handler(data);
|
|
618
|
+
handler(data, raw);
|
|
552
619
|
};
|
|
553
620
|
const unsub = this.on(event, wrapper);
|
|
554
621
|
return unsub;
|
|
555
622
|
}
|
|
556
623
|
off(event, handler) {
|
|
557
624
|
if (handler) {
|
|
558
|
-
_optionalChain([this, 'access',
|
|
625
|
+
_optionalChain([this, 'access', _22 => _22.handlers, 'access', _23 => _23.get, 'call', _24 => _24(event), 'optionalAccess', _25 => _25.delete, 'call', _26 => _26(handler)]);
|
|
559
626
|
} else {
|
|
560
627
|
this.handlers.delete(event);
|
|
561
628
|
}
|
|
562
629
|
}
|
|
563
|
-
emit(event, data) {
|
|
630
|
+
emit(event, data, raw) {
|
|
564
631
|
this.lastMessages.set(event, data);
|
|
565
632
|
const set = this.handlers.get(event);
|
|
566
633
|
if (set) {
|
|
567
|
-
for (const fn of set) fn(data);
|
|
634
|
+
for (const fn of set) fn(data, raw);
|
|
568
635
|
}
|
|
569
636
|
}
|
|
570
637
|
getLastMessage(event) {
|
|
@@ -576,13 +643,13 @@ var SubscriptionManager = (_class5 = class {constructor() { _class5.prototype.__
|
|
|
576
643
|
let done = false;
|
|
577
644
|
const unsub = this.on(event, (data) => {
|
|
578
645
|
queue.push(data);
|
|
579
|
-
_optionalChain([resolve, 'optionalCall',
|
|
646
|
+
_optionalChain([resolve, 'optionalCall', _27 => _27()]);
|
|
580
647
|
});
|
|
581
648
|
const onAbort = () => {
|
|
582
649
|
done = true;
|
|
583
|
-
_optionalChain([resolve, 'optionalCall',
|
|
650
|
+
_optionalChain([resolve, 'optionalCall', _28 => _28()]);
|
|
584
651
|
};
|
|
585
|
-
_optionalChain([signal, 'optionalAccess',
|
|
652
|
+
_optionalChain([signal, 'optionalAccess', _29 => _29.addEventListener, 'call', _30 => _30("abort", onAbort)]);
|
|
586
653
|
try {
|
|
587
654
|
while (!done) {
|
|
588
655
|
if (queue.length > 0) {
|
|
@@ -596,7 +663,7 @@ var SubscriptionManager = (_class5 = class {constructor() { _class5.prototype.__
|
|
|
596
663
|
}
|
|
597
664
|
} finally {
|
|
598
665
|
unsub();
|
|
599
|
-
_optionalChain([signal, 'optionalAccess',
|
|
666
|
+
_optionalChain([signal, 'optionalAccess', _31 => _31.removeEventListener, 'call', _32 => _32("abort", onAbort)]);
|
|
600
667
|
}
|
|
601
668
|
}
|
|
602
669
|
offAll() {
|
|
@@ -632,8 +699,9 @@ var NOOP_LOGGER = {
|
|
|
632
699
|
error() {
|
|
633
700
|
}
|
|
634
701
|
};
|
|
702
|
+
var CHANNEL_KEY_SEP = "";
|
|
635
703
|
var SharedWebSocket = (_class6 = class {
|
|
636
|
-
constructor(url, options = {}) {;_class6.prototype.__init26.call(this);_class6.prototype.__init27.call(this);_class6.prototype.__init28.call(this);_class6.prototype.__init29.call(this);_class6.prototype.__init30.call(this);_class6.prototype.__init31.call(this);_class6.prototype.__init32.call(this);_class6.prototype.__init33.call(this);_class6.prototype.__init34.call(this);_class6.prototype.__init35.call(this);_class6.prototype.__init36.call(this);_class6.prototype.__init37.call(this);
|
|
704
|
+
constructor(url, options = {}) {;_class6.prototype.__init26.call(this);_class6.prototype.__init27.call(this);_class6.prototype.__init28.call(this);_class6.prototype.__init29.call(this);_class6.prototype.__init30.call(this);_class6.prototype.__init31.call(this);_class6.prototype.__init32.call(this);_class6.prototype.__init33.call(this);_class6.prototype.__init34.call(this);_class6.prototype.__init35.call(this);_class6.prototype.__init36.call(this);_class6.prototype.__init37.call(this);_class6.prototype.__init38.call(this);_class6.prototype.__init39.call(this);_class6.prototype.__init40.call(this);_class6.prototype.__init41.call(this);_class6.prototype.__init42.call(this);
|
|
637
705
|
this.url = url;
|
|
638
706
|
this.options = options;
|
|
639
707
|
this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
|
|
@@ -648,16 +716,69 @@ var SharedWebSocket = (_class6 = class {
|
|
|
648
716
|
});
|
|
649
717
|
this.cleanups.push(
|
|
650
718
|
this.bus.subscribe("ws:message", (msg) => {
|
|
651
|
-
this.subs.emit(msg.event, msg.data);
|
|
719
|
+
this.subs.emit(msg.event, msg.data, msg.raw);
|
|
720
|
+
for (const channelName of this.channelRefs.keys()) {
|
|
721
|
+
const prefix = channelName + ":";
|
|
722
|
+
if (msg.event.length > prefix.length && msg.event.startsWith(prefix)) {
|
|
723
|
+
const subEvent = msg.event.slice(prefix.length);
|
|
724
|
+
this.subs.emit(`${channelName}${CHANNEL_KEY_SEP}${subEvent}`, msg.data, msg.raw);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (this.rawFrameListeners.size > 0) {
|
|
728
|
+
for (const fn of this.rawFrameListeners) {
|
|
729
|
+
try {
|
|
730
|
+
fn(msg.raw);
|
|
731
|
+
} catch (e5) {
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
this.cleanups.push(
|
|
738
|
+
this.bus.subscribe("ws:dispatch", (msg) => {
|
|
739
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
740
|
+
this.transmit(msg.kind, msg.payload);
|
|
741
|
+
if (msg.id) this.bus.publish("ws:dispatch-flushed", { id: msg.id });
|
|
742
|
+
}
|
|
743
|
+
})
|
|
744
|
+
);
|
|
745
|
+
this.cleanups.push(
|
|
746
|
+
this.bus.subscribe("ws:dispatch-flushed", (msg) => {
|
|
747
|
+
this.pendingOutbound.delete(msg.id);
|
|
748
|
+
})
|
|
749
|
+
);
|
|
750
|
+
this.cleanups.push(
|
|
751
|
+
this.bus.subscribe("ws:gather-pending", (req) => {
|
|
752
|
+
if (this.pendingOutbound.size === 0) return;
|
|
753
|
+
this.bus.publish(`ws:pending:${req.replyId}`, {
|
|
754
|
+
entries: [...this.pendingOutbound.values()]
|
|
755
|
+
});
|
|
652
756
|
})
|
|
653
757
|
);
|
|
654
758
|
this.cleanups.push(
|
|
655
|
-
this.bus.subscribe("ws:
|
|
759
|
+
this.bus.subscribe("ws:reconnect", () => {
|
|
656
760
|
if (this.coordinator.isLeader && this.socket) {
|
|
657
|
-
this.
|
|
761
|
+
this.log.info("[SharedWS] manual reconnect requested by follower");
|
|
762
|
+
this.socket.reconnect();
|
|
658
763
|
}
|
|
659
764
|
})
|
|
660
765
|
);
|
|
766
|
+
this.cleanups.push(
|
|
767
|
+
this.bus.subscribe("ws:authenticate-resume", () => {
|
|
768
|
+
if (this.coordinator.isLeader && _optionalChain([this, 'access', _33 => _33.socket, 'optionalAccess', _34 => _34.state]) === "failed") {
|
|
769
|
+
this.log.info("[SharedWS] resume requested after auth \u2014 reconnecting failed socket");
|
|
770
|
+
this.socket.reconnect();
|
|
771
|
+
}
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
this.cleanups.push(
|
|
775
|
+
this.bus.subscribe("ws:gather-subs", (req) => {
|
|
776
|
+
this.bus.publish(`ws:subs:${req.replyId}`, {
|
|
777
|
+
channels: [...this.channelRefs.keys()],
|
|
778
|
+
topics: [...this.topics]
|
|
779
|
+
});
|
|
780
|
+
})
|
|
781
|
+
);
|
|
661
782
|
this.cleanups.push(
|
|
662
783
|
this.bus.subscribe("ws:sync", (msg) => {
|
|
663
784
|
this.syncStore.set(msg.key, msg.value);
|
|
@@ -684,6 +805,9 @@ var SharedWebSocket = (_class6 = class {
|
|
|
684
805
|
case "reconnecting":
|
|
685
806
|
this.subs.emit("$lifecycle:reconnecting", void 0);
|
|
686
807
|
break;
|
|
808
|
+
case "reconnectFailed":
|
|
809
|
+
this.subs.emit("$lifecycle:reconnectFailed", void 0);
|
|
810
|
+
break;
|
|
687
811
|
case "leader":
|
|
688
812
|
this.subs.emit("$lifecycle:leader", msg.isLeader);
|
|
689
813
|
break;
|
|
@@ -750,8 +874,29 @@ var SharedWebSocket = (_class6 = class {
|
|
|
750
874
|
__init35() {this._isAuthenticated = false}
|
|
751
875
|
__init36() {this.authChannels = /* @__PURE__ */ new Map()}
|
|
752
876
|
__init37() {this.authTopics = /* @__PURE__ */ new Set()}
|
|
877
|
+
/**
|
|
878
|
+
* Refcount of active channel subscriptions per name. Used to route
|
|
879
|
+
* incoming events back to channel handlers via `${name}<RS>${event}`
|
|
880
|
+
* keys without colliding when names/events contain `:`, and as the
|
|
881
|
+
* source for cross-tab subscription replay on leader change.
|
|
882
|
+
*/
|
|
883
|
+
__init38() {this.channelRefs = /* @__PURE__ */ new Map()}
|
|
884
|
+
/** All topic subscriptions (auth and non-auth). Replayed on leader change. */
|
|
885
|
+
__init39() {this.topics = /* @__PURE__ */ new Set()}
|
|
886
|
+
/** Listeners for every raw incoming frame (post-deserialize, post-middleware). */
|
|
887
|
+
__init40() {this.rawFrameListeners = /* @__PURE__ */ new Set()}
|
|
888
|
+
/**
|
|
889
|
+
* Local outbound buffer of follower-originated dispatches awaiting flush
|
|
890
|
+
* confirmation from the leader. Drained when the leader broadcasts
|
|
891
|
+
* `ws:dispatch-flushed` for the entry's id; replayed by the next leader
|
|
892
|
+
* after gathering across surviving tabs. Insertion order preserved
|
|
893
|
+
* (Map) so we drop oldest on overflow.
|
|
894
|
+
*/
|
|
895
|
+
__init41() {this.pendingOutbound = /* @__PURE__ */ new Map()}
|
|
896
|
+
/** Periodic refresh timer — leader only. Recreated on each leader handover. */
|
|
897
|
+
__init42() {this.refreshTimer = null}
|
|
753
898
|
get connected() {
|
|
754
|
-
return _optionalChain([this, 'access',
|
|
899
|
+
return _optionalChain([this, 'access', _35 => _35.socket, 'optionalAccess', _36 => _36.state]) === "connected" || !this.coordinator.isLeader;
|
|
755
900
|
}
|
|
756
901
|
get tabRole() {
|
|
757
902
|
return this.coordinator.isLeader ? "leader" : "follower";
|
|
@@ -781,6 +926,36 @@ var SharedWebSocket = (_class6 = class {
|
|
|
781
926
|
onReconnecting(fn) {
|
|
782
927
|
return this.subs.on("$lifecycle:reconnecting", fn);
|
|
783
928
|
}
|
|
929
|
+
/**
|
|
930
|
+
* Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
|
|
931
|
+
* Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
|
|
932
|
+
* so the user can call `ws.reconnect()` to try again.
|
|
933
|
+
*
|
|
934
|
+
* @example
|
|
935
|
+
* ws.onReconnectFailed(() => {
|
|
936
|
+
* showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
|
|
937
|
+
* });
|
|
938
|
+
*/
|
|
939
|
+
onReconnectFailed(fn) {
|
|
940
|
+
return this.subs.on("$lifecycle:reconnectFailed", fn);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Manually trigger a reconnect. Resets the retry counter and attempts a
|
|
944
|
+
* fresh connection. Safe to call from any tab — the leader actually owns
|
|
945
|
+
* the socket, followers route the request via BroadcastChannel.
|
|
946
|
+
*
|
|
947
|
+
* Use after `onReconnectFailed` fires to let the user retry.
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* snackbar.action('Reconnect', () => ws.reconnect());
|
|
951
|
+
*/
|
|
952
|
+
reconnect() {
|
|
953
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
954
|
+
this.socket.reconnect();
|
|
955
|
+
} else {
|
|
956
|
+
this.bus.publish("ws:reconnect", void 0);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
784
959
|
/** Called when this tab becomes leader or loses leadership. */
|
|
785
960
|
onLeaderChange(fn) {
|
|
786
961
|
return this.subs.on("$lifecycle:leader", fn);
|
|
@@ -823,9 +998,16 @@ var SharedWebSocket = (_class6 = class {
|
|
|
823
998
|
this._isAuthenticated = true;
|
|
824
999
|
this.syncStore.set("$auth:token", token);
|
|
825
1000
|
this.bus.broadcast("ws:sync", { key: "$auth:token", value: token });
|
|
826
|
-
this.send(this.proto.authLogin, { token });
|
|
827
1001
|
this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: true });
|
|
828
1002
|
this.log.info("[SharedWS] authenticated");
|
|
1003
|
+
if (this.coordinator.isLeader && this.socket && this.socket.state === "failed") {
|
|
1004
|
+
this.reconnect();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (!this.coordinator.isLeader) {
|
|
1008
|
+
this.bus.publish("ws:authenticate-resume", void 0);
|
|
1009
|
+
}
|
|
1010
|
+
this.dispatch("auth-login", { data: token });
|
|
829
1011
|
}
|
|
830
1012
|
/**
|
|
831
1013
|
* Deauthenticate — notifies server, auto-leaves all auth-required channels
|
|
@@ -840,7 +1022,7 @@ var SharedWebSocket = (_class6 = class {
|
|
|
840
1022
|
for (const topic of this.authTopics) this.unsubscribe(topic);
|
|
841
1023
|
this.authTopics.clear();
|
|
842
1024
|
this._isAuthenticated = false;
|
|
843
|
-
this.
|
|
1025
|
+
this.dispatch("auth-logout", {});
|
|
844
1026
|
this.syncStore.delete("$auth:token");
|
|
845
1027
|
this.bus.broadcast("ws:sync", { key: "$auth:token", value: void 0 });
|
|
846
1028
|
this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: false });
|
|
@@ -928,22 +1110,23 @@ var SharedWebSocket = (_class6 = class {
|
|
|
928
1110
|
stream(event, signal) {
|
|
929
1111
|
return this.subs.stream(event, signal);
|
|
930
1112
|
}
|
|
931
|
-
send(event, data) {
|
|
1113
|
+
send(event, data, extras) {
|
|
1114
|
+
this.assertExtrasReserved(extras);
|
|
932
1115
|
const eventSerializer = this.serializers.get(event);
|
|
933
1116
|
const serializedData = eventSerializer ? eventSerializer(data) : data;
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1117
|
+
this.dispatch("event", { event, data: serializedData, extras });
|
|
1118
|
+
}
|
|
1119
|
+
assertExtrasReserved(extras) {
|
|
1120
|
+
if (!extras) return;
|
|
1121
|
+
if (this.proto.eventField in extras) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`SharedWebSocket.send: extras cannot contain reserved key "${this.proto.eventField}" (eventField). Pass the event name as the first argument instead.`
|
|
1124
|
+
);
|
|
941
1125
|
}
|
|
942
|
-
this.
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
this.bus.publish("ws:send", { event, data });
|
|
1126
|
+
if (this.proto.dataField in extras) {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
`SharedWebSocket.send: extras cannot contain reserved key "${this.proto.dataField}" (dataField). Pass the payload as the second argument instead.`
|
|
1129
|
+
);
|
|
947
1130
|
}
|
|
948
1131
|
}
|
|
949
1132
|
/** Request/response through server via leader. */
|
|
@@ -977,33 +1160,76 @@ var SharedWebSocket = (_class6 = class {
|
|
|
977
1160
|
* notifications.on('alert', (alert) => showToast(alert));
|
|
978
1161
|
*/
|
|
979
1162
|
channel(name, options) {
|
|
980
|
-
this.
|
|
1163
|
+
const matcher = this.proto.channelAckMatcher;
|
|
1164
|
+
const ackTimeout = _nullishCoalesce(this.proto.channelAckTimeout, () => ( 5e3));
|
|
1165
|
+
let cancelReady;
|
|
1166
|
+
const ready = matcher ? new Promise((resolve, reject) => {
|
|
1167
|
+
let settled = false;
|
|
1168
|
+
const settle = (fn) => {
|
|
1169
|
+
if (settled) return;
|
|
1170
|
+
settled = true;
|
|
1171
|
+
clearTimeout(timer);
|
|
1172
|
+
unsubAck();
|
|
1173
|
+
fn();
|
|
1174
|
+
};
|
|
1175
|
+
const unsubAck = this.onRawFrame((frame) => {
|
|
1176
|
+
let result;
|
|
1177
|
+
try {
|
|
1178
|
+
result = matcher(frame, name);
|
|
1179
|
+
} catch (e6) {
|
|
1180
|
+
result = "reject";
|
|
1181
|
+
}
|
|
1182
|
+
if (result === "ok") settle(() => resolve());
|
|
1183
|
+
else if (result === "reject") settle(() => reject(new Error(`SharedWebSocket: subscribe rejected for channel "${name}"`)));
|
|
1184
|
+
});
|
|
1185
|
+
const timer = setTimeout(
|
|
1186
|
+
() => settle(() => reject(new Error(`SharedWebSocket: subscribe ack timeout for channel "${name}"`))),
|
|
1187
|
+
ackTimeout
|
|
1188
|
+
);
|
|
1189
|
+
cancelReady = (err) => settle(() => reject(err));
|
|
1190
|
+
}) : Promise.resolve();
|
|
1191
|
+
if (matcher) ready.catch(() => {
|
|
1192
|
+
});
|
|
1193
|
+
this.dispatch("subscribe", { channel: name });
|
|
1194
|
+
this.channelRefs.set(name, (_nullishCoalesce(this.channelRefs.get(name), () => ( 0))) + 1);
|
|
981
1195
|
const self = this;
|
|
982
1196
|
const unsubs = [];
|
|
983
|
-
const isAuth = _nullishCoalesce(_optionalChain([options, 'optionalAccess',
|
|
1197
|
+
const isAuth = _nullishCoalesce(_optionalChain([options, 'optionalAccess', _37 => _37.auth]), () => ( false));
|
|
1198
|
+
let left = false;
|
|
1199
|
+
const key = (event) => `${name}${CHANNEL_KEY_SEP}${event}`;
|
|
984
1200
|
const ch = {
|
|
985
1201
|
name,
|
|
1202
|
+
ready,
|
|
986
1203
|
on(event, handler) {
|
|
987
|
-
const unsub = self.subs.on(
|
|
1204
|
+
const unsub = self.subs.on(key(event), handler);
|
|
988
1205
|
unsubs.push(unsub);
|
|
989
1206
|
return unsub;
|
|
990
1207
|
},
|
|
991
1208
|
once(event, handler) {
|
|
992
|
-
const unsub = self.subs.once(
|
|
1209
|
+
const unsub = self.subs.once(key(event), handler);
|
|
993
1210
|
unsubs.push(unsub);
|
|
994
1211
|
return unsub;
|
|
995
1212
|
},
|
|
996
1213
|
send(event, data) {
|
|
997
|
-
|
|
1214
|
+
const joined = `${name}:${event}`;
|
|
1215
|
+
const eventSerializer = _nullishCoalesce(self.serializers.get(joined), () => ( self.serializers.get(event)));
|
|
1216
|
+
const serializedData = eventSerializer ? eventSerializer(data) : data;
|
|
1217
|
+
self.dispatch("event", { event, data: serializedData, channel: name });
|
|
998
1218
|
},
|
|
999
1219
|
stream(event, signal) {
|
|
1000
|
-
return self.subs.stream(
|
|
1220
|
+
return self.subs.stream(key(event), signal);
|
|
1001
1221
|
},
|
|
1002
1222
|
leave() {
|
|
1003
|
-
|
|
1223
|
+
if (left) return;
|
|
1224
|
+
left = true;
|
|
1225
|
+
_optionalChain([cancelReady, 'optionalCall', _38 => _38(new Error(`SharedWebSocket: channel "${name}" left before ack`))]);
|
|
1226
|
+
self.dispatch("unsubscribe", { channel: name });
|
|
1004
1227
|
for (const unsub of unsubs) unsub();
|
|
1005
1228
|
unsubs.length = 0;
|
|
1006
1229
|
if (isAuth) self.authChannels.delete(name);
|
|
1230
|
+
const next = (_nullishCoalesce(self.channelRefs.get(name), () => ( 1))) - 1;
|
|
1231
|
+
if (next <= 0) self.channelRefs.delete(name);
|
|
1232
|
+
else self.channelRefs.set(name, next);
|
|
1007
1233
|
}
|
|
1008
1234
|
};
|
|
1009
1235
|
if (isAuth) {
|
|
@@ -1022,8 +1248,9 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1022
1248
|
* ws.subscribe(`user:${userId}:mentions`);
|
|
1023
1249
|
*/
|
|
1024
1250
|
subscribe(topic, options) {
|
|
1025
|
-
this.
|
|
1026
|
-
|
|
1251
|
+
this.dispatch("topic-subscribe", { topic });
|
|
1252
|
+
this.topics.add(topic);
|
|
1253
|
+
if (_optionalChain([options, 'optionalAccess', _39 => _39.auth])) {
|
|
1027
1254
|
this.authTopics.add(topic);
|
|
1028
1255
|
}
|
|
1029
1256
|
this.log.debug("[SharedWS] subscribe topic", topic);
|
|
@@ -1033,7 +1260,8 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1033
1260
|
* Sends topicUnsubscribe event (default: "$topic:unsubscribe").
|
|
1034
1261
|
*/
|
|
1035
1262
|
unsubscribe(topic) {
|
|
1036
|
-
this.
|
|
1263
|
+
this.dispatch("topic-unsubscribe", { topic });
|
|
1264
|
+
this.topics.delete(topic);
|
|
1037
1265
|
this.authTopics.delete(topic);
|
|
1038
1266
|
this.log.debug("[SharedWS] unsubscribe topic", topic);
|
|
1039
1267
|
}
|
|
@@ -1114,12 +1342,126 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1114
1342
|
disconnect() {
|
|
1115
1343
|
this[Symbol.dispose]();
|
|
1116
1344
|
}
|
|
1345
|
+
// ─── Frame Pipeline ─────────────────────────────────
|
|
1346
|
+
//
|
|
1347
|
+
// dispatch(kind, payload) is the single entry point for all outgoing
|
|
1348
|
+
// frames (events, channel join/leave, topic sub/unsub, auth login/logout).
|
|
1349
|
+
// - On the leader, it calls transmit() which builds the frame, runs
|
|
1350
|
+
// outgoing middleware, and writes to the socket.
|
|
1351
|
+
// - On followers, it forwards { kind, payload } over BroadcastChannel;
|
|
1352
|
+
// the leader's bus subscriber re-enters transmit() so middleware
|
|
1353
|
+
// runs in exactly one place regardless of which tab originated.
|
|
1354
|
+
//
|
|
1355
|
+
// The actual wire shape is decided by frameBuilder (custom) or
|
|
1356
|
+
// defaultFrameBuilder (legacy two-key { event, data } envelope).
|
|
1357
|
+
/**
|
|
1358
|
+
* Build the wire frame for a given kind. Honors custom `frameBuilder`.
|
|
1359
|
+
* Return-value contract:
|
|
1360
|
+
* - any concrete value → use as the frame
|
|
1361
|
+
* - `null` → drop the frame (intentional filter)
|
|
1362
|
+
* - `undefined` → fall back to the default builder for this kind
|
|
1363
|
+
*/
|
|
1364
|
+
buildFrame(kind, payload) {
|
|
1365
|
+
if (this.proto.frameBuilder) {
|
|
1366
|
+
const result = this.proto.frameBuilder(kind, payload);
|
|
1367
|
+
if (result !== void 0) return result;
|
|
1368
|
+
}
|
|
1369
|
+
return this.defaultFrameBuilder(kind, payload);
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Subscribe to every raw incoming frame (post-deserialize). Used by
|
|
1373
|
+
* `Channel.ready`'s ack matcher. Internal — not part of the public API.
|
|
1374
|
+
*/
|
|
1375
|
+
onRawFrame(fn) {
|
|
1376
|
+
this.rawFrameListeners.add(fn);
|
|
1377
|
+
return () => {
|
|
1378
|
+
this.rawFrameListeners.delete(fn);
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
/** Legacy two-key builder — preserved as the default for back-compat. */
|
|
1382
|
+
defaultFrameBuilder(kind, p) {
|
|
1383
|
+
let eventName;
|
|
1384
|
+
let dataPart;
|
|
1385
|
+
switch (kind) {
|
|
1386
|
+
case "event":
|
|
1387
|
+
eventName = p.channel ? `${p.channel}:${_nullishCoalesce(p.event, () => ( ""))}` : _nullishCoalesce(p.event, () => ( this.proto.defaultEvent));
|
|
1388
|
+
dataPart = p.data;
|
|
1389
|
+
break;
|
|
1390
|
+
case "subscribe":
|
|
1391
|
+
eventName = this.proto.channelJoin;
|
|
1392
|
+
dataPart = { channel: p.channel };
|
|
1393
|
+
break;
|
|
1394
|
+
case "unsubscribe":
|
|
1395
|
+
eventName = this.proto.channelLeave;
|
|
1396
|
+
dataPart = { channel: p.channel };
|
|
1397
|
+
break;
|
|
1398
|
+
case "topic-subscribe":
|
|
1399
|
+
eventName = this.proto.topicSubscribe;
|
|
1400
|
+
dataPart = { topic: p.topic };
|
|
1401
|
+
break;
|
|
1402
|
+
case "topic-unsubscribe":
|
|
1403
|
+
eventName = this.proto.topicUnsubscribe;
|
|
1404
|
+
dataPart = { topic: p.topic };
|
|
1405
|
+
break;
|
|
1406
|
+
case "auth-login":
|
|
1407
|
+
eventName = this.proto.authLogin;
|
|
1408
|
+
dataPart = { token: p.data };
|
|
1409
|
+
break;
|
|
1410
|
+
case "auth-logout":
|
|
1411
|
+
eventName = this.proto.authLogout;
|
|
1412
|
+
dataPart = {};
|
|
1413
|
+
break;
|
|
1414
|
+
}
|
|
1415
|
+
return {
|
|
1416
|
+
..._nullishCoalesce(p.extras, () => ( {})),
|
|
1417
|
+
[this.proto.eventField]: eventName,
|
|
1418
|
+
[this.proto.dataField]: dataPart
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
/** Route a structured frame: leader transmits, followers forward via bus. */
|
|
1422
|
+
dispatch(kind, payload) {
|
|
1423
|
+
if (this.coordinator.isLeader && this.socket) {
|
|
1424
|
+
this.transmit(kind, payload);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const id = generateId();
|
|
1428
|
+
this.enqueuePending(id, kind, payload);
|
|
1429
|
+
this.bus.publish("ws:dispatch", { id, kind, payload });
|
|
1430
|
+
}
|
|
1431
|
+
enqueuePending(id, kind, payload) {
|
|
1432
|
+
const max = _nullishCoalesce(this.options.outboundBufferSize, () => ( 100));
|
|
1433
|
+
if (max <= 0) return;
|
|
1434
|
+
if (this.pendingOutbound.size >= max) {
|
|
1435
|
+
const oldestKey = this.pendingOutbound.keys().next().value;
|
|
1436
|
+
if (oldestKey !== void 0) this.pendingOutbound.delete(oldestKey);
|
|
1437
|
+
}
|
|
1438
|
+
this.pendingOutbound.set(id, { id, kind, payload, enqueuedAt: Date.now() });
|
|
1439
|
+
}
|
|
1440
|
+
/** Build, run middleware, and write to the socket. Leader-only. */
|
|
1441
|
+
transmit(kind, payload) {
|
|
1442
|
+
if (!this.socket) return;
|
|
1443
|
+
let frame = this.buildFrame(kind, payload);
|
|
1444
|
+
if (frame === null) {
|
|
1445
|
+
this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
for (const mw of this.outgoingMiddleware) {
|
|
1449
|
+
frame = mw(frame);
|
|
1450
|
+
if (frame === null) {
|
|
1451
|
+
this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
this.log.debug("[SharedWS] \u2192 send", kind, payload);
|
|
1456
|
+
this.socket.send(frame);
|
|
1457
|
+
}
|
|
1117
1458
|
createSocket() {
|
|
1118
1459
|
const socketOptions = {
|
|
1119
1460
|
protocols: this.options.protocols,
|
|
1120
1461
|
reconnect: this.options.reconnect,
|
|
1121
1462
|
reconnectMaxDelay: this.options.reconnectMaxDelay,
|
|
1122
1463
|
reconnectMaxRetries: this.options.reconnectMaxRetries,
|
|
1464
|
+
authFailureCloseCodes: this.options.authFailureCloseCodes,
|
|
1123
1465
|
heartbeatInterval: this.options.heartbeatInterval,
|
|
1124
1466
|
sendBuffer: this.options.sendBuffer,
|
|
1125
1467
|
pingPayload: this.proto.ping
|
|
@@ -1145,6 +1487,7 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1145
1487
|
handleBecomeLeader() {
|
|
1146
1488
|
this.log.info("[SharedWS] \u{1F451} became leader");
|
|
1147
1489
|
this.socket = this.createSocket();
|
|
1490
|
+
this.startRefreshTimer();
|
|
1148
1491
|
this.socket.onMessage((raw) => {
|
|
1149
1492
|
let data = raw;
|
|
1150
1493
|
for (const mw of this.incomingMiddleware) {
|
|
@@ -1155,21 +1498,21 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1155
1498
|
}
|
|
1156
1499
|
}
|
|
1157
1500
|
const msg = data;
|
|
1158
|
-
const event = _nullishCoalesce(_optionalChain([msg, 'optionalAccess',
|
|
1159
|
-
let payload = _nullishCoalesce(_optionalChain([msg, 'optionalAccess',
|
|
1501
|
+
const event = _nullishCoalesce(_optionalChain([msg, 'optionalAccess', _40 => _40[this.proto.eventField]]), () => ( this.proto.defaultEvent));
|
|
1502
|
+
let payload = _nullishCoalesce(_optionalChain([msg, 'optionalAccess', _41 => _41[this.proto.dataField]]), () => ( data));
|
|
1160
1503
|
const eventDeserializer = this.deserializers.get(event);
|
|
1161
1504
|
if (eventDeserializer) {
|
|
1162
1505
|
payload = eventDeserializer(payload);
|
|
1163
1506
|
}
|
|
1164
1507
|
this.log.debug("[SharedWS] \u2190 recv", event, payload);
|
|
1165
|
-
this.bus.broadcast("ws:message", { event, data: payload });
|
|
1508
|
+
this.bus.broadcast("ws:message", { event, data: payload, raw: data });
|
|
1166
1509
|
});
|
|
1167
1510
|
this.socket.onStateChange((state) => {
|
|
1168
|
-
this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : `state: ${state}`);
|
|
1511
|
+
this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : state === "failed" ? "\u2717 reconnect failed" : `state: ${state}`);
|
|
1169
1512
|
switch (state) {
|
|
1170
1513
|
case "connected":
|
|
1171
1514
|
this.bus.broadcast("ws:lifecycle", { type: "connect" });
|
|
1172
|
-
this.
|
|
1515
|
+
void this.onConnected();
|
|
1173
1516
|
break;
|
|
1174
1517
|
case "closed":
|
|
1175
1518
|
this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
|
|
@@ -1177,6 +1520,10 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1177
1520
|
case "reconnecting":
|
|
1178
1521
|
this.bus.broadcast("ws:lifecycle", { type: "reconnecting" });
|
|
1179
1522
|
break;
|
|
1523
|
+
case "failed":
|
|
1524
|
+
this.bus.broadcast("ws:lifecycle", { type: "reconnectFailed" });
|
|
1525
|
+
this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
|
|
1526
|
+
break;
|
|
1180
1527
|
}
|
|
1181
1528
|
});
|
|
1182
1529
|
this.cleanups.push(
|
|
@@ -1184,49 +1531,182 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1184
1531
|
return new Promise((resolve) => {
|
|
1185
1532
|
const unsub = this.socket.onMessage((response) => {
|
|
1186
1533
|
const res = response;
|
|
1187
|
-
if (_optionalChain([res, 'optionalAccess',
|
|
1534
|
+
if (_optionalChain([res, 'optionalAccess', _42 => _42[this.proto.eventField]]) === req.event || _optionalChain([res, 'optionalAccess', _43 => _43.requestId])) {
|
|
1188
1535
|
unsub();
|
|
1189
|
-
resolve(_nullishCoalesce(_optionalChain([res, 'optionalAccess',
|
|
1536
|
+
resolve(_nullishCoalesce(_optionalChain([res, 'optionalAccess', _44 => _44[this.proto.dataField]]), () => ( response)));
|
|
1190
1537
|
}
|
|
1191
1538
|
});
|
|
1192
|
-
this.
|
|
1539
|
+
this.transmit("event", { event: req.event, data: req.data });
|
|
1193
1540
|
});
|
|
1194
1541
|
})
|
|
1195
1542
|
);
|
|
1196
1543
|
void this.socket.connect();
|
|
1197
1544
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1545
|
+
/**
|
|
1546
|
+
* Re-establish all server-side state on the freshly connected leader socket:
|
|
1547
|
+
* 1. auth-login (so server accepts subsequent joins on auth channels)
|
|
1548
|
+
* 2. channel-join for the union of channels held by ALL surviving tabs
|
|
1549
|
+
* 3. topic-subscribe for the union of topics held by ALL surviving tabs
|
|
1550
|
+
*
|
|
1551
|
+
* The union covers leader handover: when a follower with handlers is
|
|
1552
|
+
* promoted, no tab's subscriptions get silently dropped. Frames are sent
|
|
1553
|
+
* in FIFO order over the single WebSocket, so auth precedes the joins
|
|
1554
|
+
* that depend on it.
|
|
1555
|
+
*/
|
|
1556
|
+
/**
|
|
1557
|
+
* Orchestrate post-connect recovery: replay subscriptions first (so the
|
|
1558
|
+
* server is ready to route events for any channels we still care about),
|
|
1559
|
+
* then drain follower-pending dispatches that didn't reach the previous
|
|
1560
|
+
* leader's socket.
|
|
1561
|
+
*/
|
|
1562
|
+
async onConnected() {
|
|
1563
|
+
await this.resubscribeOnConnect();
|
|
1564
|
+
await this.replayPendingDispatches();
|
|
1565
|
+
}
|
|
1566
|
+
async resubscribeOnConnect() {
|
|
1567
|
+
if (!this.socket) return;
|
|
1568
|
+
const socket = this.socket;
|
|
1569
|
+
if (this._isAuthenticated) {
|
|
1570
|
+
const token = this.syncStore.get("$auth:token");
|
|
1571
|
+
if (token) {
|
|
1572
|
+
this.transmit("auth-login", { data: token });
|
|
1573
|
+
this.log.debug("[SharedWS] re-authenticated after reconnect");
|
|
1574
|
+
}
|
|
1207
1575
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
});
|
|
1576
|
+
const { channels, topics } = await this.gatherSubscriptions();
|
|
1577
|
+
if (this.socket !== socket) return;
|
|
1578
|
+
for (const name of channels) {
|
|
1579
|
+
this.transmit("subscribe", { channel: name });
|
|
1213
1580
|
}
|
|
1214
|
-
for (const topic of
|
|
1215
|
-
this.
|
|
1216
|
-
|
|
1217
|
-
|
|
1581
|
+
for (const topic of topics) {
|
|
1582
|
+
this.transmit("topic-subscribe", { topic });
|
|
1583
|
+
}
|
|
1584
|
+
if (channels.length || topics.length) {
|
|
1585
|
+
this.log.info("[SharedWS] replayed subscriptions", {
|
|
1586
|
+
channels: channels.length,
|
|
1587
|
+
topics: topics.length
|
|
1218
1588
|
});
|
|
1219
1589
|
}
|
|
1220
1590
|
}
|
|
1591
|
+
/**
|
|
1592
|
+
* Replay buffered follower dispatches over the freshly connected socket.
|
|
1593
|
+
* Gathers from all tabs (including this one), de-dups by id, transmits,
|
|
1594
|
+
* then signals each originator to drop its local entry. Drops own-tab
|
|
1595
|
+
* entries after transmission since `bus.publish` doesn't echo to self.
|
|
1596
|
+
*/
|
|
1597
|
+
async replayPendingDispatches() {
|
|
1598
|
+
if (!this.socket) return;
|
|
1599
|
+
const socket = this.socket;
|
|
1600
|
+
const entries = await this.gatherPendingDispatches();
|
|
1601
|
+
if (this.socket !== socket) return;
|
|
1602
|
+
if (entries.length === 0) return;
|
|
1603
|
+
let sent = 0;
|
|
1604
|
+
for (const e of entries) {
|
|
1605
|
+
this.transmit(e.kind, e.payload);
|
|
1606
|
+
this.pendingOutbound.delete(e.id);
|
|
1607
|
+
this.bus.publish("ws:dispatch-flushed", { id: e.id });
|
|
1608
|
+
sent++;
|
|
1609
|
+
}
|
|
1610
|
+
this.log.info("[SharedWS] replayed pending dispatches", { count: sent });
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
|
|
1614
|
+
* — broadcasts a one-shot request, collects for a short window, dedups
|
|
1615
|
+
* by id (so multiple tabs holding the same id don't double-replay).
|
|
1616
|
+
*/
|
|
1617
|
+
gatherPendingDispatches(timeoutMs = 100) {
|
|
1618
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1619
|
+
for (const e of this.pendingOutbound.values()) {
|
|
1620
|
+
seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
|
|
1621
|
+
}
|
|
1622
|
+
const replyId = generateId();
|
|
1623
|
+
return new Promise((resolve) => {
|
|
1624
|
+
const unsub = this.bus.subscribe(
|
|
1625
|
+
`ws:pending:${replyId}`,
|
|
1626
|
+
(msg) => {
|
|
1627
|
+
for (const e of msg.entries) {
|
|
1628
|
+
if (!seen.has(e.id)) seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
);
|
|
1632
|
+
this.bus.publish("ws:gather-pending", { replyId });
|
|
1633
|
+
setTimeout(() => {
|
|
1634
|
+
unsub();
|
|
1635
|
+
resolve([...seen.values()]);
|
|
1636
|
+
}, timeoutMs);
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Best-effort cross-tab gather. Broadcasts a request and collects responses
|
|
1641
|
+
* for a short window. Times out gracefully — late responses are dropped.
|
|
1642
|
+
* The leader's own subs are seeded into the result to avoid relying on
|
|
1643
|
+
* BroadcastChannel echo to self.
|
|
1644
|
+
*/
|
|
1645
|
+
gatherSubscriptions(timeoutMs = 150) {
|
|
1646
|
+
const channels = new Set(this.channelRefs.keys());
|
|
1647
|
+
const topics = new Set(this.topics);
|
|
1648
|
+
const replyId = generateId();
|
|
1649
|
+
return new Promise((resolve) => {
|
|
1650
|
+
const unsub = this.bus.subscribe(
|
|
1651
|
+
`ws:subs:${replyId}`,
|
|
1652
|
+
(msg) => {
|
|
1653
|
+
for (const c of msg.channels) channels.add(c);
|
|
1654
|
+
for (const t of msg.topics) topics.add(t);
|
|
1655
|
+
}
|
|
1656
|
+
);
|
|
1657
|
+
this.bus.publish("ws:gather-subs", { replyId });
|
|
1658
|
+
setTimeout(() => {
|
|
1659
|
+
unsub();
|
|
1660
|
+
resolve({ channels: [...channels], topics: [...topics] });
|
|
1661
|
+
}, timeoutMs);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1221
1664
|
handleLoseLeadership() {
|
|
1665
|
+
this.stopRefreshTimer();
|
|
1222
1666
|
if (this.socket) {
|
|
1223
1667
|
this.socket[Symbol.dispose]();
|
|
1224
1668
|
this.socket = null;
|
|
1225
1669
|
}
|
|
1226
1670
|
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Start a leader-only periodic refresh of the auth token. The callback
|
|
1673
|
+
* is `options.refresh` (preferred) or `options.auth` (fallback). When
|
|
1674
|
+
* the timer fires and the connection is currently authenticated, the
|
|
1675
|
+
* returned token is fed back through `authenticate()` so subscribers
|
|
1676
|
+
* stay synced and the leader's socket re-issues auth-login.
|
|
1677
|
+
*
|
|
1678
|
+
* Idempotent — calling start while already running is a no-op.
|
|
1679
|
+
*/
|
|
1680
|
+
startRefreshTimer() {
|
|
1681
|
+
if (this.refreshTimer) return;
|
|
1682
|
+
const interval = this.options.refreshTokenInterval;
|
|
1683
|
+
const refresh = _nullishCoalesce(this.options.refresh, () => ( this.options.auth));
|
|
1684
|
+
if (!interval || interval <= 0 || !refresh) return;
|
|
1685
|
+
if (!this.coordinator.isLeader) return;
|
|
1686
|
+
this.refreshTimer = setInterval(async () => {
|
|
1687
|
+
if (!this.coordinator.isLeader || !this._isAuthenticated) return;
|
|
1688
|
+
try {
|
|
1689
|
+
const token = await refresh();
|
|
1690
|
+
if (!token) {
|
|
1691
|
+
this.log.warn("[SharedWS] refresh() returned empty token \u2014 skipping");
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
this.authenticate(token);
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
this.log.warn("[SharedWS] refresh() failed", err);
|
|
1697
|
+
}
|
|
1698
|
+
}, interval);
|
|
1699
|
+
}
|
|
1700
|
+
stopRefreshTimer() {
|
|
1701
|
+
if (this.refreshTimer) {
|
|
1702
|
+
clearInterval(this.refreshTimer);
|
|
1703
|
+
this.refreshTimer = null;
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1227
1706
|
[Symbol.dispose]() {
|
|
1228
1707
|
if (this.disposed) return;
|
|
1229
1708
|
this.disposed = true;
|
|
1709
|
+
this.stopRefreshTimer();
|
|
1230
1710
|
this.coordinator[Symbol.dispose]();
|
|
1231
1711
|
if (this.socket) {
|
|
1232
1712
|
this.socket[Symbol.dispose]();
|
|
@@ -1239,6 +1719,10 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1239
1719
|
this.syncStore.clear();
|
|
1240
1720
|
this.authChannels.clear();
|
|
1241
1721
|
this.authTopics.clear();
|
|
1722
|
+
this.channelRefs.clear();
|
|
1723
|
+
this.topics.clear();
|
|
1724
|
+
this.rawFrameListeners.clear();
|
|
1725
|
+
this.pendingOutbound.clear();
|
|
1242
1726
|
}
|
|
1243
1727
|
}, _class6);
|
|
1244
1728
|
|
|
@@ -1250,4 +1734,4 @@ var SharedWebSocket = (_class6 = class {
|
|
|
1250
1734
|
|
|
1251
1735
|
|
|
1252
1736
|
exports.MessageBus = MessageBus; exports.TabCoordinator = TabCoordinator; exports.SharedSocket = SharedSocket; exports.WorkerSocket = WorkerSocket; exports.SubscriptionManager = SubscriptionManager; exports.SharedWebSocket = SharedWebSocket;
|
|
1253
|
-
//# sourceMappingURL=chunk-
|
|
1737
|
+
//# sourceMappingURL=chunk-YZLE4TZB.cjs.map
|