@gwakko/shared-websocket 0.13.0 → 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,13 +294,17 @@ 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
309
  this.scheduleReconnect();
299
310
  } else {
@@ -403,6 +414,9 @@ var SharedSocket = (_class3 = class {
403
414
  let token;
404
415
  if (this.opts.auth) {
405
416
  token = await this.opts.auth();
417
+ if (!token) {
418
+ throw new Error("SharedSocket: auth() returned no token");
419
+ }
406
420
  } else if (this.opts.authToken) {
407
421
  token = this.opts.authToken;
408
422
  }
@@ -439,10 +453,23 @@ var WorkerSocket = (_class4 = class {
439
453
  get state() {
440
454
  return this._state;
441
455
  }
456
+ setState(s) {
457
+ this._state = s;
458
+ for (const fn of this.onStateChangeFns) fn(s);
459
+ }
442
460
  async connect() {
443
461
  let authToken;
444
462
  if (this.options.auth) {
445
- 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
+ }
446
473
  } else if (this.options.authToken) {
447
474
  authToken = this.options.authToken;
448
475
  }
@@ -475,6 +502,7 @@ var WorkerSocket = (_class4 = class {
475
502
  reconnect: _nullishCoalesce(this.options.reconnect, () => ( true)),
476
503
  reconnectMaxDelay: _nullishCoalesce(this.options.reconnectMaxDelay, () => ( 3e4)),
477
504
  reconnectMaxRetries: _nullishCoalesce(this.options.reconnectMaxRetries, () => ( Infinity)),
505
+ authFailureCloseCodes: _nullishCoalesce(this.options.authFailureCloseCodes, () => ( [1008])),
478
506
  heartbeatInterval: _nullishCoalesce(this.options.heartbeatInterval, () => ( 3e4)),
479
507
  bufferSize: _nullishCoalesce(this.options.sendBuffer, () => ( 100)),
480
508
  pingPayload: this.options.pingPayload
@@ -516,6 +544,7 @@ var WorkerSocket = (_class4 = class {
516
544
  let heartbeatTimer = null, reconnectTimer = null;
517
545
  let url = '', protocols = [], shouldReconnect = true;
518
546
  let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
547
+ let authFailCodes = new Set([1008]);
519
548
  let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
520
549
 
521
550
  function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
@@ -525,7 +554,12 @@ var WorkerSocket = (_class4 = class {
525
554
  ws = new WebSocket(url, protocols);
526
555
  ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
527
556
  ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
528
- 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
+ };
529
563
  ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
530
564
  }
531
565
  function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
@@ -549,7 +583,7 @@ var WorkerSocket = (_class4 = class {
549
583
  }
550
584
  self.onmessage = (e) => {
551
585
  const c = e.data;
552
- 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(); }
553
587
  if (c.type === 'send') send(c.data);
554
588
  if (c.type === 'reconnect') manualReconnect();
555
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'); }
@@ -579,9 +613,9 @@ var SubscriptionManager = (_class5 = class {constructor() { _class5.prototype.__
579
613
  return () => set.delete(handler);
580
614
  }
581
615
  once(event, handler) {
582
- const wrapper = (data) => {
616
+ const wrapper = (data, raw) => {
583
617
  unsub();
584
- handler(data);
618
+ handler(data, raw);
585
619
  };
586
620
  const unsub = this.on(event, wrapper);
587
621
  return unsub;
@@ -593,11 +627,11 @@ var SubscriptionManager = (_class5 = class {constructor() { _class5.prototype.__
593
627
  this.handlers.delete(event);
594
628
  }
595
629
  }
596
- emit(event, data) {
630
+ emit(event, data, raw) {
597
631
  this.lastMessages.set(event, data);
598
632
  const set = this.handlers.get(event);
599
633
  if (set) {
600
- for (const fn of set) fn(data);
634
+ for (const fn of set) fn(data, raw);
601
635
  }
602
636
  }
603
637
  getLastMessage(event) {
@@ -665,8 +699,9 @@ var NOOP_LOGGER = {
665
699
  error() {
666
700
  }
667
701
  };
702
+ var CHANNEL_KEY_SEP = "";
668
703
  var SharedWebSocket = (_class6 = class {
669
- 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);
670
705
  this.url = url;
671
706
  this.options = options;
672
707
  this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
@@ -681,16 +716,45 @@ var SharedWebSocket = (_class6 = class {
681
716
  });
682
717
  this.cleanups.push(
683
718
  this.bus.subscribe("ws:message", (msg) => {
684
- 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
+ }
685
735
  })
686
736
  );
687
737
  this.cleanups.push(
688
- this.bus.subscribe("ws:send", (msg) => {
738
+ this.bus.subscribe("ws:dispatch", (msg) => {
689
739
  if (this.coordinator.isLeader && this.socket) {
690
- this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
740
+ this.transmit(msg.kind, msg.payload);
741
+ if (msg.id) this.bus.publish("ws:dispatch-flushed", { id: msg.id });
691
742
  }
692
743
  })
693
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
+ });
756
+ })
757
+ );
694
758
  this.cleanups.push(
695
759
  this.bus.subscribe("ws:reconnect", () => {
696
760
  if (this.coordinator.isLeader && this.socket) {
@@ -699,6 +763,22 @@ var SharedWebSocket = (_class6 = class {
699
763
  }
700
764
  })
701
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
+ );
702
782
  this.cleanups.push(
703
783
  this.bus.subscribe("ws:sync", (msg) => {
704
784
  this.syncStore.set(msg.key, msg.value);
@@ -794,8 +874,29 @@ var SharedWebSocket = (_class6 = class {
794
874
  __init35() {this._isAuthenticated = false}
795
875
  __init36() {this.authChannels = /* @__PURE__ */ new Map()}
796
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}
797
898
  get connected() {
798
- return _optionalChain([this, 'access', _33 => _33.socket, 'optionalAccess', _34 => _34.state]) === "connected" || !this.coordinator.isLeader;
899
+ return _optionalChain([this, 'access', _35 => _35.socket, 'optionalAccess', _36 => _36.state]) === "connected" || !this.coordinator.isLeader;
799
900
  }
800
901
  get tabRole() {
801
902
  return this.coordinator.isLeader ? "leader" : "follower";
@@ -897,9 +998,16 @@ var SharedWebSocket = (_class6 = class {
897
998
  this._isAuthenticated = true;
898
999
  this.syncStore.set("$auth:token", token);
899
1000
  this.bus.broadcast("ws:sync", { key: "$auth:token", value: token });
900
- this.send(this.proto.authLogin, { token });
901
1001
  this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: true });
902
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 });
903
1011
  }
904
1012
  /**
905
1013
  * Deauthenticate — notifies server, auto-leaves all auth-required channels
@@ -914,7 +1022,7 @@ var SharedWebSocket = (_class6 = class {
914
1022
  for (const topic of this.authTopics) this.unsubscribe(topic);
915
1023
  this.authTopics.clear();
916
1024
  this._isAuthenticated = false;
917
- this.send(this.proto.authLogout, {});
1025
+ this.dispatch("auth-logout", {});
918
1026
  this.syncStore.delete("$auth:token");
919
1027
  this.bus.broadcast("ws:sync", { key: "$auth:token", value: void 0 });
920
1028
  this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: false });
@@ -1002,22 +1110,23 @@ var SharedWebSocket = (_class6 = class {
1002
1110
  stream(event, signal) {
1003
1111
  return this.subs.stream(event, signal);
1004
1112
  }
1005
- send(event, data) {
1113
+ send(event, data, extras) {
1114
+ this.assertExtrasReserved(extras);
1006
1115
  const eventSerializer = this.serializers.get(event);
1007
1116
  const serializedData = eventSerializer ? eventSerializer(data) : data;
1008
- let payload = { [this.proto.eventField]: event, [this.proto.dataField]: serializedData };
1009
- for (const mw of this.outgoingMiddleware) {
1010
- payload = mw(payload);
1011
- if (payload === null) {
1012
- this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", event);
1013
- return;
1014
- }
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
+ );
1015
1125
  }
1016
- this.log.debug("[SharedWS] \u2192 send", event, data);
1017
- if (this.coordinator.isLeader && this.socket) {
1018
- this.socket.send(payload);
1019
- } else {
1020
- 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
+ );
1021
1130
  }
1022
1131
  }
1023
1132
  /** Request/response through server via leader. */
@@ -1051,33 +1160,76 @@ var SharedWebSocket = (_class6 = class {
1051
1160
  * notifications.on('alert', (alert) => showToast(alert));
1052
1161
  */
1053
1162
  channel(name, options) {
1054
- 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);
1055
1195
  const self = this;
1056
1196
  const unsubs = [];
1057
- const isAuth = _nullishCoalesce(_optionalChain([options, 'optionalAccess', _35 => _35.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}`;
1058
1200
  const ch = {
1059
1201
  name,
1202
+ ready,
1060
1203
  on(event, handler) {
1061
- const unsub = self.subs.on(`${name}:${event}`, handler);
1204
+ const unsub = self.subs.on(key(event), handler);
1062
1205
  unsubs.push(unsub);
1063
1206
  return unsub;
1064
1207
  },
1065
1208
  once(event, handler) {
1066
- const unsub = self.subs.once(`${name}:${event}`, handler);
1209
+ const unsub = self.subs.once(key(event), handler);
1067
1210
  unsubs.push(unsub);
1068
1211
  return unsub;
1069
1212
  },
1070
1213
  send(event, data) {
1071
- 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 });
1072
1218
  },
1073
1219
  stream(event, signal) {
1074
- return self.subs.stream(`${name}:${event}`, signal);
1220
+ return self.subs.stream(key(event), signal);
1075
1221
  },
1076
1222
  leave() {
1077
- 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 });
1078
1227
  for (const unsub of unsubs) unsub();
1079
1228
  unsubs.length = 0;
1080
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);
1081
1233
  }
1082
1234
  };
1083
1235
  if (isAuth) {
@@ -1096,8 +1248,9 @@ var SharedWebSocket = (_class6 = class {
1096
1248
  * ws.subscribe(`user:${userId}:mentions`);
1097
1249
  */
1098
1250
  subscribe(topic, options) {
1099
- this.send(this.proto.topicSubscribe, { topic });
1100
- if (_optionalChain([options, 'optionalAccess', _36 => _36.auth])) {
1251
+ this.dispatch("topic-subscribe", { topic });
1252
+ this.topics.add(topic);
1253
+ if (_optionalChain([options, 'optionalAccess', _39 => _39.auth])) {
1101
1254
  this.authTopics.add(topic);
1102
1255
  }
1103
1256
  this.log.debug("[SharedWS] subscribe topic", topic);
@@ -1107,7 +1260,8 @@ var SharedWebSocket = (_class6 = class {
1107
1260
  * Sends topicUnsubscribe event (default: "$topic:unsubscribe").
1108
1261
  */
1109
1262
  unsubscribe(topic) {
1110
- this.send(this.proto.topicUnsubscribe, { topic });
1263
+ this.dispatch("topic-unsubscribe", { topic });
1264
+ this.topics.delete(topic);
1111
1265
  this.authTopics.delete(topic);
1112
1266
  this.log.debug("[SharedWS] unsubscribe topic", topic);
1113
1267
  }
@@ -1188,12 +1342,126 @@ var SharedWebSocket = (_class6 = class {
1188
1342
  disconnect() {
1189
1343
  this[Symbol.dispose]();
1190
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
+ }
1191
1458
  createSocket() {
1192
1459
  const socketOptions = {
1193
1460
  protocols: this.options.protocols,
1194
1461
  reconnect: this.options.reconnect,
1195
1462
  reconnectMaxDelay: this.options.reconnectMaxDelay,
1196
1463
  reconnectMaxRetries: this.options.reconnectMaxRetries,
1464
+ authFailureCloseCodes: this.options.authFailureCloseCodes,
1197
1465
  heartbeatInterval: this.options.heartbeatInterval,
1198
1466
  sendBuffer: this.options.sendBuffer,
1199
1467
  pingPayload: this.proto.ping
@@ -1219,6 +1487,7 @@ var SharedWebSocket = (_class6 = class {
1219
1487
  handleBecomeLeader() {
1220
1488
  this.log.info("[SharedWS] \u{1F451} became leader");
1221
1489
  this.socket = this.createSocket();
1490
+ this.startRefreshTimer();
1222
1491
  this.socket.onMessage((raw) => {
1223
1492
  let data = raw;
1224
1493
  for (const mw of this.incomingMiddleware) {
@@ -1229,21 +1498,21 @@ var SharedWebSocket = (_class6 = class {
1229
1498
  }
1230
1499
  }
1231
1500
  const msg = data;
1232
- const event = _nullishCoalesce(_optionalChain([msg, 'optionalAccess', _37 => _37[this.proto.eventField]]), () => ( this.proto.defaultEvent));
1233
- let payload = _nullishCoalesce(_optionalChain([msg, 'optionalAccess', _38 => _38[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));
1234
1503
  const eventDeserializer = this.deserializers.get(event);
1235
1504
  if (eventDeserializer) {
1236
1505
  payload = eventDeserializer(payload);
1237
1506
  }
1238
1507
  this.log.debug("[SharedWS] \u2190 recv", event, payload);
1239
- this.bus.broadcast("ws:message", { event, data: payload });
1508
+ this.bus.broadcast("ws:message", { event, data: payload, raw: data });
1240
1509
  });
1241
1510
  this.socket.onStateChange((state) => {
1242
1511
  this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : state === "failed" ? "\u2717 reconnect failed" : `state: ${state}`);
1243
1512
  switch (state) {
1244
1513
  case "connected":
1245
1514
  this.bus.broadcast("ws:lifecycle", { type: "connect" });
1246
- this.reAuthenticateOnReconnect();
1515
+ void this.onConnected();
1247
1516
  break;
1248
1517
  case "closed":
1249
1518
  this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
@@ -1262,49 +1531,182 @@ var SharedWebSocket = (_class6 = class {
1262
1531
  return new Promise((resolve) => {
1263
1532
  const unsub = this.socket.onMessage((response) => {
1264
1533
  const res = response;
1265
- if (_optionalChain([res, 'optionalAccess', _39 => _39[this.proto.eventField]]) === req.event || _optionalChain([res, 'optionalAccess', _40 => _40.requestId])) {
1534
+ if (_optionalChain([res, 'optionalAccess', _42 => _42[this.proto.eventField]]) === req.event || _optionalChain([res, 'optionalAccess', _43 => _43.requestId])) {
1266
1535
  unsub();
1267
- resolve(_nullishCoalesce(_optionalChain([res, 'optionalAccess', _41 => _41[this.proto.dataField]]), () => ( response)));
1536
+ resolve(_nullishCoalesce(_optionalChain([res, 'optionalAccess', _44 => _44[this.proto.dataField]]), () => ( response)));
1268
1537
  }
1269
1538
  });
1270
- this.socket.send({ event: req.event, data: req.data });
1539
+ this.transmit("event", { event: req.event, data: req.data });
1271
1540
  });
1272
1541
  })
1273
1542
  );
1274
1543
  void this.socket.connect();
1275
1544
  }
1276
- reAuthenticateOnReconnect() {
1277
- if (!this._isAuthenticated || !this.socket) return;
1278
- const token = this.syncStore.get("$auth:token");
1279
- if (token) {
1280
- this.socket.send({
1281
- [this.proto.eventField]: this.proto.authLogin,
1282
- [this.proto.dataField]: { token }
1283
- });
1284
- 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
+ }
1285
1575
  }
1286
- for (const name of this.authChannels.keys()) {
1287
- this.socket.send({
1288
- [this.proto.eventField]: this.proto.channelJoin,
1289
- [this.proto.dataField]: { channel: name }
1290
- });
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 });
1291
1580
  }
1292
- for (const topic of this.authTopics) {
1293
- this.socket.send({
1294
- [this.proto.eventField]: this.proto.topicSubscribe,
1295
- [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
1296
1588
  });
1297
1589
  }
1298
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
+ }
1299
1664
  handleLoseLeadership() {
1665
+ this.stopRefreshTimer();
1300
1666
  if (this.socket) {
1301
1667
  this.socket[Symbol.dispose]();
1302
1668
  this.socket = null;
1303
1669
  }
1304
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
+ }
1305
1706
  [Symbol.dispose]() {
1306
1707
  if (this.disposed) return;
1307
1708
  this.disposed = true;
1709
+ this.stopRefreshTimer();
1308
1710
  this.coordinator[Symbol.dispose]();
1309
1711
  if (this.socket) {
1310
1712
  this.socket[Symbol.dispose]();
@@ -1317,6 +1719,10 @@ var SharedWebSocket = (_class6 = class {
1317
1719
  this.syncStore.clear();
1318
1720
  this.authChannels.clear();
1319
1721
  this.authTopics.clear();
1722
+ this.channelRefs.clear();
1723
+ this.topics.clear();
1724
+ this.rawFrameListeners.clear();
1725
+ this.pendingOutbound.clear();
1320
1726
  }
1321
1727
  }, _class6);
1322
1728
 
@@ -1328,4 +1734,4 @@ var SharedWebSocket = (_class6 = class {
1328
1734
 
1329
1735
 
1330
1736
  exports.MessageBus = MessageBus; exports.TabCoordinator = TabCoordinator; exports.SharedSocket = SharedSocket; exports.WorkerSocket = WorkerSocket; exports.SubscriptionManager = SubscriptionManager; exports.SharedWebSocket = SharedWebSocket;
1331
- //# sourceMappingURL=chunk-RKVYLJTQ.cjs.map
1737
+ //# sourceMappingURL=chunk-YZLE4TZB.cjs.map