@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.
@@ -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
- const connectUrl = await this.buildUrl();
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 (e) {
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.reconnect();
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("closed");
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
- authToken = await this.options.auth();
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', _13 => _13.worker, 'optionalAccess', _14 => _14.postMessage, 'call', _15 => _15({ type: "disconnect" })]);
526
+ _optionalChain([this, 'access', _16 => _16.worker, 'optionalAccess', _17 => _17.postMessage, 'call', _18 => _18({ type: "disconnect" })]);
474
527
  setTimeout(() => {
475
- _optionalChain([this, 'access', _16 => _16.worker, 'optionalAccess', _17 => _17.terminate, 'call', _18 => _18()]);
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) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
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('closed'); return; }
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', _19 => _19.handlers, 'access', _20 => _20.get, 'call', _21 => _21(event), 'optionalAccess', _22 => _22.delete, 'call', _23 => _23(handler)]);
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', _24 => _24()]);
646
+ _optionalChain([resolve, 'optionalCall', _27 => _27()]);
580
647
  });
581
648
  const onAbort = () => {
582
649
  done = true;
583
- _optionalChain([resolve, 'optionalCall', _25 => _25()]);
650
+ _optionalChain([resolve, 'optionalCall', _28 => _28()]);
584
651
  };
585
- _optionalChain([signal, 'optionalAccess', _26 => _26.addEventListener, 'call', _27 => _27("abort", onAbort)]);
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', _28 => _28.removeEventListener, 'call', _29 => _29("abort", onAbort)]);
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:send", (msg) => {
759
+ this.bus.subscribe("ws:reconnect", () => {
656
760
  if (this.coordinator.isLeader && this.socket) {
657
- this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
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', _30 => _30.socket, 'optionalAccess', _31 => _31.state]) === "connected" || !this.coordinator.isLeader;
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.send(this.proto.authLogout, {});
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
- let payload = { [this.proto.eventField]: event, [this.proto.dataField]: serializedData };
935
- for (const mw of this.outgoingMiddleware) {
936
- payload = mw(payload);
937
- if (payload === null) {
938
- this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", event);
939
- return;
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.log.debug("[SharedWS] \u2192 send", event, data);
943
- if (this.coordinator.isLeader && this.socket) {
944
- this.socket.send(payload);
945
- } else {
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.send(this.proto.channelJoin, { channel: name });
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', _32 => _32.auth]), () => ( false));
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(`${name}:${event}`, handler);
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(`${name}:${event}`, handler);
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
- self.send(`${name}:${event}`, data);
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(`${name}:${event}`, signal);
1220
+ return self.subs.stream(key(event), signal);
1001
1221
  },
1002
1222
  leave() {
1003
- self.send(self.proto.channelLeave, { channel: name });
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.send(this.proto.topicSubscribe, { topic });
1026
- if (_optionalChain([options, 'optionalAccess', _33 => _33.auth])) {
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.send(this.proto.topicUnsubscribe, { topic });
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', _34 => _34[this.proto.eventField]]), () => ( this.proto.defaultEvent));
1159
- let payload = _nullishCoalesce(_optionalChain([msg, 'optionalAccess', _35 => _35[this.proto.dataField]]), () => ( data));
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.reAuthenticateOnReconnect();
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', _36 => _36[this.proto.eventField]]) === req.event || _optionalChain([res, 'optionalAccess', _37 => _37.requestId])) {
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', _38 => _38[this.proto.dataField]]), () => ( response)));
1536
+ resolve(_nullishCoalesce(_optionalChain([res, 'optionalAccess', _44 => _44[this.proto.dataField]]), () => ( response)));
1190
1537
  }
1191
1538
  });
1192
- this.socket.send({ event: req.event, data: req.data });
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
- reAuthenticateOnReconnect() {
1199
- if (!this._isAuthenticated || !this.socket) return;
1200
- const token = this.syncStore.get("$auth:token");
1201
- if (token) {
1202
- this.socket.send({
1203
- [this.proto.eventField]: this.proto.authLogin,
1204
- [this.proto.dataField]: { token }
1205
- });
1206
- this.log.debug("[SharedWS] re-authenticated after reconnect");
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
- for (const name of this.authChannels.keys()) {
1209
- this.socket.send({
1210
- [this.proto.eventField]: this.proto.channelJoin,
1211
- [this.proto.dataField]: { channel: name }
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 this.authTopics) {
1215
- this.socket.send({
1216
- [this.proto.eventField]: this.proto.topicSubscribe,
1217
- [this.proto.dataField]: { topic }
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-OVKB2KLE.cjs.map
1737
+ //# sourceMappingURL=chunk-YZLE4TZB.cjs.map