@gwakko/shared-websocket 0.13.0 → 0.14.5

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.
@@ -21,7 +21,9 @@ var MessageBus = class {
21
21
  pendingRequests = /* @__PURE__ */ new Map();
22
22
  subscribe(topic, fn) {
23
23
  const wrapper = (msg) => {
24
- if (msg.source !== this.tabId) fn(msg.data);
24
+ if (msg.type !== "publish" || msg.source !== this.tabId) {
25
+ fn(msg.data);
26
+ }
25
27
  };
26
28
  this.addListener(topic, wrapper);
27
29
  return () => this.removeListener(topic, wrapper);
@@ -245,6 +247,7 @@ var SharedSocket = class {
245
247
  reconnect: options.reconnect ?? true,
246
248
  reconnectMaxDelay: options.reconnectMaxDelay ?? 3e4,
247
249
  reconnectMaxRetries: options.reconnectMaxRetries ?? Infinity,
250
+ authFailureCloseCodes: new Set(options.authFailureCloseCodes ?? [1008]),
248
251
  heartbeatInterval: options.heartbeatInterval ?? 3e4,
249
252
  sendBuffer: options.sendBuffer ?? 100,
250
253
  auth: options.auth,
@@ -275,7 +278,13 @@ var SharedSocket = class {
275
278
  async connect() {
276
279
  if (this.disposed) return;
277
280
  this.setState("connecting");
278
- const connectUrl = await this.buildUrl();
281
+ let connectUrl;
282
+ try {
283
+ connectUrl = await this.buildUrl();
284
+ } catch {
285
+ this.setState("failed");
286
+ return;
287
+ }
279
288
  this.ws = new WebSocket(connectUrl, this.opts.protocols);
280
289
  this.ws.onopen = () => {
281
290
  this.reconnectAttempts = 0;
@@ -292,8 +301,12 @@ var SharedSocket = class {
292
301
  }
293
302
  for (const fn of this.onMessageFns) fn(data);
294
303
  };
295
- this.ws.onclose = () => {
304
+ this.ws.onclose = (ev) => {
296
305
  this.stopHeartbeat();
306
+ if (this.opts.authFailureCloseCodes.has(ev.code)) {
307
+ this.setState("failed");
308
+ return;
309
+ }
297
310
  if (!this.disposed && this.opts.reconnect) {
298
311
  this.scheduleReconnect();
299
312
  } else {
@@ -403,6 +416,9 @@ var SharedSocket = class {
403
416
  let token;
404
417
  if (this.opts.auth) {
405
418
  token = await this.opts.auth();
419
+ if (!token) {
420
+ throw new Error("SharedSocket: auth() returned no token");
421
+ }
406
422
  } else if (this.opts.authToken) {
407
423
  token = this.opts.authToken;
408
424
  }
@@ -439,10 +455,23 @@ var WorkerSocket = class {
439
455
  get state() {
440
456
  return this._state;
441
457
  }
458
+ setState(s) {
459
+ this._state = s;
460
+ for (const fn of this.onStateChangeFns) fn(s);
461
+ }
442
462
  async connect() {
443
463
  let authToken;
444
464
  if (this.options.auth) {
445
- authToken = await this.options.auth();
465
+ try {
466
+ authToken = await this.options.auth();
467
+ } catch {
468
+ this.setState("failed");
469
+ return;
470
+ }
471
+ if (!authToken) {
472
+ this.setState("failed");
473
+ return;
474
+ }
446
475
  } else if (this.options.authToken) {
447
476
  authToken = this.options.authToken;
448
477
  }
@@ -475,6 +504,7 @@ var WorkerSocket = class {
475
504
  reconnect: this.options.reconnect ?? true,
476
505
  reconnectMaxDelay: this.options.reconnectMaxDelay ?? 3e4,
477
506
  reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
507
+ authFailureCloseCodes: this.options.authFailureCloseCodes ?? [1008],
478
508
  heartbeatInterval: this.options.heartbeatInterval ?? 3e4,
479
509
  bufferSize: this.options.sendBuffer ?? 100,
480
510
  pingPayload: this.options.pingPayload
@@ -516,6 +546,7 @@ var WorkerSocket = class {
516
546
  let heartbeatTimer = null, reconnectTimer = null;
517
547
  let url = '', protocols = [], shouldReconnect = true;
518
548
  let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
549
+ let authFailCodes = new Set([1008]);
519
550
  let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
520
551
 
521
552
  function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
@@ -525,7 +556,12 @@ var WorkerSocket = class {
525
556
  ws = new WebSocket(url, protocols);
526
557
  ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
527
558
  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'); };
559
+ ws.onclose = (e) => {
560
+ stopHB();
561
+ self.postMessage({ type: 'close', code: e.code, reason: e.reason });
562
+ if (authFailCodes.has(e.code)) { setState('failed'); return; }
563
+ if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed');
564
+ };
529
565
  ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
530
566
  }
531
567
  function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
@@ -549,7 +585,7 @@ var WorkerSocket = class {
549
585
  }
550
586
  self.onmessage = (e) => {
551
587
  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(); }
588
+ 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
589
  if (c.type === 'send') send(c.data);
554
590
  if (c.type === 'reconnect') manualReconnect();
555
591
  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 +615,9 @@ var SubscriptionManager = class {
579
615
  return () => set.delete(handler);
580
616
  }
581
617
  once(event, handler) {
582
- const wrapper = (data) => {
618
+ const wrapper = (data, raw) => {
583
619
  unsub();
584
- handler(data);
620
+ handler(data, raw);
585
621
  };
586
622
  const unsub = this.on(event, wrapper);
587
623
  return unsub;
@@ -593,11 +629,11 @@ var SubscriptionManager = class {
593
629
  this.handlers.delete(event);
594
630
  }
595
631
  }
596
- emit(event, data) {
632
+ emit(event, data, raw) {
597
633
  this.lastMessages.set(event, data);
598
634
  const set = this.handlers.get(event);
599
635
  if (set) {
600
- for (const fn of set) fn(data);
636
+ for (const fn of set) fn(data, raw);
601
637
  }
602
638
  }
603
639
  getLastMessage(event) {
@@ -665,6 +701,7 @@ var NOOP_LOGGER = {
665
701
  error() {
666
702
  }
667
703
  };
704
+ var CHANNEL_KEY_SEP = "";
668
705
  var SharedWebSocket = class {
669
706
  constructor(url, options = {}) {
670
707
  this.url = url;
@@ -681,16 +718,45 @@ var SharedWebSocket = class {
681
718
  });
682
719
  this.cleanups.push(
683
720
  this.bus.subscribe("ws:message", (msg) => {
684
- this.subs.emit(msg.event, msg.data);
721
+ this.subs.emit(msg.event, msg.data, msg.raw);
722
+ for (const channelName of this.channelRefs.keys()) {
723
+ const prefix = channelName + ":";
724
+ if (msg.event.length > prefix.length && msg.event.startsWith(prefix)) {
725
+ const subEvent = msg.event.slice(prefix.length);
726
+ this.subs.emit(`${channelName}${CHANNEL_KEY_SEP}${subEvent}`, msg.data, msg.raw);
727
+ }
728
+ }
729
+ if (this.rawFrameListeners.size > 0) {
730
+ for (const fn of this.rawFrameListeners) {
731
+ try {
732
+ fn(msg.raw);
733
+ } catch {
734
+ }
735
+ }
736
+ }
685
737
  })
686
738
  );
687
739
  this.cleanups.push(
688
- this.bus.subscribe("ws:send", (msg) => {
740
+ this.bus.subscribe("ws:dispatch", (msg) => {
689
741
  if (this.coordinator.isLeader && this.socket) {
690
- this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
742
+ this.transmit(msg.kind, msg.payload);
743
+ if (msg.id) this.bus.publish("ws:dispatch-flushed", { id: msg.id });
691
744
  }
692
745
  })
693
746
  );
747
+ this.cleanups.push(
748
+ this.bus.subscribe("ws:dispatch-flushed", (msg) => {
749
+ this.pendingOutbound.delete(msg.id);
750
+ })
751
+ );
752
+ this.cleanups.push(
753
+ this.bus.subscribe("ws:gather-pending", (req) => {
754
+ if (this.pendingOutbound.size === 0) return;
755
+ this.bus.publish(`ws:pending:${req.replyId}`, {
756
+ entries: [...this.pendingOutbound.values()]
757
+ });
758
+ })
759
+ );
694
760
  this.cleanups.push(
695
761
  this.bus.subscribe("ws:reconnect", () => {
696
762
  if (this.coordinator.isLeader && this.socket) {
@@ -699,6 +765,22 @@ var SharedWebSocket = class {
699
765
  }
700
766
  })
701
767
  );
768
+ this.cleanups.push(
769
+ this.bus.subscribe("ws:authenticate-resume", () => {
770
+ if (this.coordinator.isLeader && this.socket?.state === "failed") {
771
+ this.log.info("[SharedWS] resume requested after auth \u2014 reconnecting failed socket");
772
+ this.socket.reconnect();
773
+ }
774
+ })
775
+ );
776
+ this.cleanups.push(
777
+ this.bus.subscribe("ws:gather-subs", (req) => {
778
+ this.bus.publish(`ws:subs:${req.replyId}`, {
779
+ channels: [...this.channelRefs.keys()],
780
+ topics: [...this.topics]
781
+ });
782
+ })
783
+ );
702
784
  this.cleanups.push(
703
785
  this.bus.subscribe("ws:sync", (msg) => {
704
786
  this.syncStore.set(msg.key, msg.value);
@@ -794,6 +876,27 @@ var SharedWebSocket = class {
794
876
  _isAuthenticated = false;
795
877
  authChannels = /* @__PURE__ */ new Map();
796
878
  authTopics = /* @__PURE__ */ new Set();
879
+ /**
880
+ * Refcount of active channel subscriptions per name. Used to route
881
+ * incoming events back to channel handlers via `${name}<RS>${event}`
882
+ * keys without colliding when names/events contain `:`, and as the
883
+ * source for cross-tab subscription replay on leader change.
884
+ */
885
+ channelRefs = /* @__PURE__ */ new Map();
886
+ /** All topic subscriptions (auth and non-auth). Replayed on leader change. */
887
+ topics = /* @__PURE__ */ new Set();
888
+ /** Listeners for every raw incoming frame (post-deserialize, post-middleware). */
889
+ rawFrameListeners = /* @__PURE__ */ new Set();
890
+ /**
891
+ * Local outbound buffer of follower-originated dispatches awaiting flush
892
+ * confirmation from the leader. Drained when the leader broadcasts
893
+ * `ws:dispatch-flushed` for the entry's id; replayed by the next leader
894
+ * after gathering across surviving tabs. Insertion order preserved
895
+ * (Map) so we drop oldest on overflow.
896
+ */
897
+ pendingOutbound = /* @__PURE__ */ new Map();
898
+ /** Periodic refresh timer — leader only. Recreated on each leader handover. */
899
+ refreshTimer = null;
797
900
  get connected() {
798
901
  return this.socket?.state === "connected" || !this.coordinator.isLeader;
799
902
  }
@@ -897,9 +1000,16 @@ var SharedWebSocket = class {
897
1000
  this._isAuthenticated = true;
898
1001
  this.syncStore.set("$auth:token", token);
899
1002
  this.bus.broadcast("ws:sync", { key: "$auth:token", value: token });
900
- this.send(this.proto.authLogin, { token });
901
1003
  this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: true });
902
1004
  this.log.info("[SharedWS] authenticated");
1005
+ if (this.coordinator.isLeader && this.socket && this.socket.state === "failed") {
1006
+ this.reconnect();
1007
+ return;
1008
+ }
1009
+ if (!this.coordinator.isLeader) {
1010
+ this.bus.publish("ws:authenticate-resume", void 0);
1011
+ }
1012
+ this.dispatch("auth-login", { data: token });
903
1013
  }
904
1014
  /**
905
1015
  * Deauthenticate — notifies server, auto-leaves all auth-required channels
@@ -914,7 +1024,7 @@ var SharedWebSocket = class {
914
1024
  for (const topic of this.authTopics) this.unsubscribe(topic);
915
1025
  this.authTopics.clear();
916
1026
  this._isAuthenticated = false;
917
- this.send(this.proto.authLogout, {});
1027
+ this.dispatch("auth-logout", {});
918
1028
  this.syncStore.delete("$auth:token");
919
1029
  this.bus.broadcast("ws:sync", { key: "$auth:token", value: void 0 });
920
1030
  this.bus.broadcast("ws:lifecycle", { type: "auth", authenticated: false });
@@ -1002,22 +1112,23 @@ var SharedWebSocket = class {
1002
1112
  stream(event, signal) {
1003
1113
  return this.subs.stream(event, signal);
1004
1114
  }
1005
- send(event, data) {
1115
+ send(event, data, extras) {
1116
+ this.assertExtrasReserved(extras);
1006
1117
  const eventSerializer = this.serializers.get(event);
1007
1118
  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
- }
1119
+ this.dispatch("event", { event, data: serializedData, extras });
1120
+ }
1121
+ assertExtrasReserved(extras) {
1122
+ if (!extras) return;
1123
+ if (this.proto.eventField in extras) {
1124
+ throw new Error(
1125
+ `SharedWebSocket.send: extras cannot contain reserved key "${this.proto.eventField}" (eventField). Pass the event name as the first argument instead.`
1126
+ );
1015
1127
  }
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 });
1128
+ if (this.proto.dataField in extras) {
1129
+ throw new Error(
1130
+ `SharedWebSocket.send: extras cannot contain reserved key "${this.proto.dataField}" (dataField). Pass the payload as the second argument instead.`
1131
+ );
1021
1132
  }
1022
1133
  }
1023
1134
  /** Request/response through server via leader. */
@@ -1051,33 +1162,76 @@ var SharedWebSocket = class {
1051
1162
  * notifications.on('alert', (alert) => showToast(alert));
1052
1163
  */
1053
1164
  channel(name, options) {
1054
- this.send(this.proto.channelJoin, { channel: name });
1165
+ const matcher = this.proto.channelAckMatcher;
1166
+ const ackTimeout = this.proto.channelAckTimeout ?? 5e3;
1167
+ let cancelReady;
1168
+ const ready = matcher ? new Promise((resolve, reject) => {
1169
+ let settled = false;
1170
+ const settle = (fn) => {
1171
+ if (settled) return;
1172
+ settled = true;
1173
+ clearTimeout(timer);
1174
+ unsubAck();
1175
+ fn();
1176
+ };
1177
+ const unsubAck = this.onRawFrame((frame) => {
1178
+ let result;
1179
+ try {
1180
+ result = matcher(frame, name);
1181
+ } catch {
1182
+ result = "reject";
1183
+ }
1184
+ if (result === "ok") settle(() => resolve());
1185
+ else if (result === "reject") settle(() => reject(new Error(`SharedWebSocket: subscribe rejected for channel "${name}"`)));
1186
+ });
1187
+ const timer = setTimeout(
1188
+ () => settle(() => reject(new Error(`SharedWebSocket: subscribe ack timeout for channel "${name}"`))),
1189
+ ackTimeout
1190
+ );
1191
+ cancelReady = (err) => settle(() => reject(err));
1192
+ }) : Promise.resolve();
1193
+ if (matcher) ready.catch(() => {
1194
+ });
1195
+ this.dispatch("subscribe", { channel: name });
1196
+ this.channelRefs.set(name, (this.channelRefs.get(name) ?? 0) + 1);
1055
1197
  const self = this;
1056
1198
  const unsubs = [];
1057
1199
  const isAuth = options?.auth ?? false;
1200
+ let left = false;
1201
+ const key = (event) => `${name}${CHANNEL_KEY_SEP}${event}`;
1058
1202
  const ch = {
1059
1203
  name,
1204
+ ready,
1060
1205
  on(event, handler) {
1061
- const unsub = self.subs.on(`${name}:${event}`, handler);
1206
+ const unsub = self.subs.on(key(event), handler);
1062
1207
  unsubs.push(unsub);
1063
1208
  return unsub;
1064
1209
  },
1065
1210
  once(event, handler) {
1066
- const unsub = self.subs.once(`${name}:${event}`, handler);
1211
+ const unsub = self.subs.once(key(event), handler);
1067
1212
  unsubs.push(unsub);
1068
1213
  return unsub;
1069
1214
  },
1070
1215
  send(event, data) {
1071
- self.send(`${name}:${event}`, data);
1216
+ const joined = `${name}:${event}`;
1217
+ const eventSerializer = self.serializers.get(joined) ?? self.serializers.get(event);
1218
+ const serializedData = eventSerializer ? eventSerializer(data) : data;
1219
+ self.dispatch("event", { event, data: serializedData, channel: name });
1072
1220
  },
1073
1221
  stream(event, signal) {
1074
- return self.subs.stream(`${name}:${event}`, signal);
1222
+ return self.subs.stream(key(event), signal);
1075
1223
  },
1076
1224
  leave() {
1077
- self.send(self.proto.channelLeave, { channel: name });
1225
+ if (left) return;
1226
+ left = true;
1227
+ cancelReady?.(new Error(`SharedWebSocket: channel "${name}" left before ack`));
1228
+ self.dispatch("unsubscribe", { channel: name });
1078
1229
  for (const unsub of unsubs) unsub();
1079
1230
  unsubs.length = 0;
1080
1231
  if (isAuth) self.authChannels.delete(name);
1232
+ const next = (self.channelRefs.get(name) ?? 1) - 1;
1233
+ if (next <= 0) self.channelRefs.delete(name);
1234
+ else self.channelRefs.set(name, next);
1081
1235
  }
1082
1236
  };
1083
1237
  if (isAuth) {
@@ -1096,7 +1250,8 @@ var SharedWebSocket = class {
1096
1250
  * ws.subscribe(`user:${userId}:mentions`);
1097
1251
  */
1098
1252
  subscribe(topic, options) {
1099
- this.send(this.proto.topicSubscribe, { topic });
1253
+ this.dispatch("topic-subscribe", { topic });
1254
+ this.topics.add(topic);
1100
1255
  if (options?.auth) {
1101
1256
  this.authTopics.add(topic);
1102
1257
  }
@@ -1107,7 +1262,8 @@ var SharedWebSocket = class {
1107
1262
  * Sends topicUnsubscribe event (default: "$topic:unsubscribe").
1108
1263
  */
1109
1264
  unsubscribe(topic) {
1110
- this.send(this.proto.topicUnsubscribe, { topic });
1265
+ this.dispatch("topic-unsubscribe", { topic });
1266
+ this.topics.delete(topic);
1111
1267
  this.authTopics.delete(topic);
1112
1268
  this.log.debug("[SharedWS] unsubscribe topic", topic);
1113
1269
  }
@@ -1188,12 +1344,154 @@ var SharedWebSocket = class {
1188
1344
  disconnect() {
1189
1345
  this[Symbol.dispose]();
1190
1346
  }
1347
+ // ─── Frame Pipeline ─────────────────────────────────
1348
+ //
1349
+ // dispatch(kind, payload) is the single entry point for all outgoing
1350
+ // frames (events, channel join/leave, topic sub/unsub, auth login/logout).
1351
+ // - On the leader, it calls transmit() which builds the frame, runs
1352
+ // outgoing middleware, and writes to the socket.
1353
+ // - On followers, it forwards { kind, payload } over BroadcastChannel;
1354
+ // the leader's bus subscriber re-enters transmit() so middleware
1355
+ // runs in exactly one place regardless of which tab originated.
1356
+ //
1357
+ // The actual wire shape is decided by frameBuilder (custom) or
1358
+ // defaultFrameBuilder (legacy two-key { event, data } envelope).
1359
+ /**
1360
+ * Build the wire frame for a given kind. Honors custom `frameBuilder`.
1361
+ * Return-value contract:
1362
+ * - any concrete value → use as the frame
1363
+ * - `null` → drop the frame (intentional filter)
1364
+ * - `undefined` → fall back to the default builder for this kind
1365
+ */
1366
+ buildFrame(kind, payload) {
1367
+ if (this.proto.frameBuilder) {
1368
+ const result = this.proto.frameBuilder(kind, payload);
1369
+ if (result !== void 0) return result;
1370
+ }
1371
+ return this.defaultFrameBuilder(kind, payload);
1372
+ }
1373
+ /**
1374
+ * Subscribe to every raw incoming frame (post-deserialize). Used by
1375
+ * `Channel.ready`'s ack matcher. Internal — not part of the public API.
1376
+ */
1377
+ onRawFrame(fn) {
1378
+ this.rawFrameListeners.add(fn);
1379
+ return () => {
1380
+ this.rawFrameListeners.delete(fn);
1381
+ };
1382
+ }
1383
+ /** Legacy two-key builder — preserved as the default for back-compat. */
1384
+ defaultFrameBuilder(kind, p) {
1385
+ let eventName;
1386
+ let dataPart;
1387
+ switch (kind) {
1388
+ case "event":
1389
+ eventName = p.channel ? `${p.channel}:${p.event ?? ""}` : p.event ?? this.proto.defaultEvent;
1390
+ dataPart = p.data;
1391
+ break;
1392
+ case "subscribe":
1393
+ eventName = this.proto.channelJoin;
1394
+ dataPart = { channel: p.channel };
1395
+ break;
1396
+ case "unsubscribe":
1397
+ eventName = this.proto.channelLeave;
1398
+ dataPart = { channel: p.channel };
1399
+ break;
1400
+ case "topic-subscribe":
1401
+ eventName = this.proto.topicSubscribe;
1402
+ dataPart = { topic: p.topic };
1403
+ break;
1404
+ case "topic-unsubscribe":
1405
+ eventName = this.proto.topicUnsubscribe;
1406
+ dataPart = { topic: p.topic };
1407
+ break;
1408
+ case "auth-login":
1409
+ eventName = this.proto.authLogin;
1410
+ dataPart = { token: p.data };
1411
+ break;
1412
+ case "auth-logout":
1413
+ eventName = this.proto.authLogout;
1414
+ dataPart = {};
1415
+ break;
1416
+ }
1417
+ return {
1418
+ ...p.extras ?? {},
1419
+ [this.proto.eventField]: eventName,
1420
+ [this.proto.dataField]: dataPart
1421
+ };
1422
+ }
1423
+ /** Route a structured frame: leader transmits, followers forward via bus. */
1424
+ dispatch(kind, payload) {
1425
+ if (this.coordinator.isLeader && this.socket) {
1426
+ this.transmit(kind, payload);
1427
+ return;
1428
+ }
1429
+ const id = generateId();
1430
+ this.enqueuePending(id, kind, payload);
1431
+ this.bus.publish("ws:dispatch", { id, kind, payload });
1432
+ }
1433
+ enqueuePending(id, kind, payload) {
1434
+ const max = this.options.outboundBufferSize ?? 100;
1435
+ if (max <= 0) return;
1436
+ if (this.pendingOutbound.size >= max) {
1437
+ const oldestKey = this.pendingOutbound.keys().next().value;
1438
+ if (oldestKey !== void 0) this.pendingOutbound.delete(oldestKey);
1439
+ }
1440
+ this.pendingOutbound.set(id, { id, kind, payload, enqueuedAt: Date.now() });
1441
+ }
1442
+ /** Build, run middleware, and write to the socket. Leader-only. */
1443
+ transmit(kind, payload) {
1444
+ if (!this.socket) return;
1445
+ let frame = this.buildFrame(kind, payload);
1446
+ if (frame === null) {
1447
+ this.log.debug("[SharedWS] \u2717 frameBuilder dropped frame", kind, this.frameLabel(kind, payload));
1448
+ return;
1449
+ }
1450
+ for (const mw of this.outgoingMiddleware) {
1451
+ frame = mw(frame);
1452
+ if (frame === null) {
1453
+ this.log.debug("[SharedWS] \u2717 outgoing dropped by middleware", kind, this.frameLabel(kind, payload));
1454
+ return;
1455
+ }
1456
+ }
1457
+ if (kind === "auth-login") {
1458
+ this.log.debug("[SharedWS] \u2192 send", kind, "(token redacted)");
1459
+ } else {
1460
+ this.log.debug("[SharedWS] \u2192 send", kind, this.frameLabel(kind, payload), { payload, frame });
1461
+ }
1462
+ this.socket.send(frame);
1463
+ }
1464
+ /**
1465
+ * Human-readable headline for log lines — picks the most relevant field
1466
+ * out of the structured payload so log scanners aren't reading objects:
1467
+ * - event → event name
1468
+ * - subscribe → channel
1469
+ * - topic-* → topic
1470
+ * - auth-* → '(redacted)' / ''
1471
+ */
1472
+ frameLabel(kind, p) {
1473
+ switch (kind) {
1474
+ case "event":
1475
+ return p.event ?? "?";
1476
+ case "subscribe":
1477
+ case "unsubscribe":
1478
+ return p.channel ?? "?";
1479
+ case "topic-subscribe":
1480
+ case "topic-unsubscribe":
1481
+ return p.topic ?? "?";
1482
+ case "auth-login":
1483
+ return "(redacted)";
1484
+ case "auth-logout":
1485
+ return "";
1486
+ }
1487
+ }
1191
1488
  createSocket() {
1192
1489
  const socketOptions = {
1193
1490
  protocols: this.options.protocols,
1194
1491
  reconnect: this.options.reconnect,
1195
1492
  reconnectMaxDelay: this.options.reconnectMaxDelay,
1196
1493
  reconnectMaxRetries: this.options.reconnectMaxRetries,
1494
+ authFailureCloseCodes: this.options.authFailureCloseCodes,
1197
1495
  heartbeatInterval: this.options.heartbeatInterval,
1198
1496
  sendBuffer: this.options.sendBuffer,
1199
1497
  pingPayload: this.proto.ping
@@ -1219,12 +1517,13 @@ var SharedWebSocket = class {
1219
1517
  handleBecomeLeader() {
1220
1518
  this.log.info("[SharedWS] \u{1F451} became leader");
1221
1519
  this.socket = this.createSocket();
1520
+ this.startRefreshTimer();
1222
1521
  this.socket.onMessage((raw) => {
1223
1522
  let data = raw;
1224
1523
  for (const mw of this.incomingMiddleware) {
1225
1524
  data = mw(data);
1226
1525
  if (data === null) {
1227
- this.log.debug("[SharedWS] \u2717 incoming dropped by middleware");
1526
+ this.log.debug("[SharedWS] \u2717 incoming dropped by middleware", { raw });
1228
1527
  return;
1229
1528
  }
1230
1529
  }
@@ -1235,15 +1534,15 @@ var SharedWebSocket = class {
1235
1534
  if (eventDeserializer) {
1236
1535
  payload = eventDeserializer(payload);
1237
1536
  }
1238
- this.log.debug("[SharedWS] \u2190 recv", event, payload);
1239
- this.bus.broadcast("ws:message", { event, data: payload });
1537
+ this.log.debug("[SharedWS] \u2190 recv", event, { data: payload, raw: data });
1538
+ this.bus.broadcast("ws:message", { event, data: payload, raw: data });
1240
1539
  });
1241
1540
  this.socket.onStateChange((state) => {
1242
1541
  this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : state === "failed" ? "\u2717 reconnect failed" : `state: ${state}`);
1243
1542
  switch (state) {
1244
1543
  case "connected":
1245
1544
  this.bus.broadcast("ws:lifecycle", { type: "connect" });
1246
- this.reAuthenticateOnReconnect();
1545
+ void this.onConnected();
1247
1546
  break;
1248
1547
  case "closed":
1249
1548
  this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
@@ -1267,44 +1566,177 @@ var SharedWebSocket = class {
1267
1566
  resolve(res?.[this.proto.dataField] ?? response);
1268
1567
  }
1269
1568
  });
1270
- this.socket.send({ event: req.event, data: req.data });
1569
+ this.transmit("event", { event: req.event, data: req.data });
1271
1570
  });
1272
1571
  })
1273
1572
  );
1274
1573
  void this.socket.connect();
1275
1574
  }
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");
1575
+ /**
1576
+ * Re-establish all server-side state on the freshly connected leader socket:
1577
+ * 1. auth-login (so server accepts subsequent joins on auth channels)
1578
+ * 2. channel-join for the union of channels held by ALL surviving tabs
1579
+ * 3. topic-subscribe for the union of topics held by ALL surviving tabs
1580
+ *
1581
+ * The union covers leader handover: when a follower with handlers is
1582
+ * promoted, no tab's subscriptions get silently dropped. Frames are sent
1583
+ * in FIFO order over the single WebSocket, so auth precedes the joins
1584
+ * that depend on it.
1585
+ */
1586
+ /**
1587
+ * Orchestrate post-connect recovery: replay subscriptions first (so the
1588
+ * server is ready to route events for any channels we still care about),
1589
+ * then drain follower-pending dispatches that didn't reach the previous
1590
+ * leader's socket.
1591
+ */
1592
+ async onConnected() {
1593
+ await this.resubscribeOnConnect();
1594
+ await this.replayPendingDispatches();
1595
+ }
1596
+ async resubscribeOnConnect() {
1597
+ if (!this.socket) return;
1598
+ const socket = this.socket;
1599
+ if (this._isAuthenticated) {
1600
+ const token = this.syncStore.get("$auth:token");
1601
+ if (token) {
1602
+ this.transmit("auth-login", { data: token });
1603
+ this.log.debug("[SharedWS] re-authenticated after reconnect");
1604
+ }
1285
1605
  }
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
- });
1606
+ const { channels, topics } = await this.gatherSubscriptions();
1607
+ if (this.socket !== socket) return;
1608
+ for (const name of channels) {
1609
+ this.transmit("subscribe", { channel: name });
1291
1610
  }
1292
- for (const topic of this.authTopics) {
1293
- this.socket.send({
1294
- [this.proto.eventField]: this.proto.topicSubscribe,
1295
- [this.proto.dataField]: { topic }
1611
+ for (const topic of topics) {
1612
+ this.transmit("topic-subscribe", { topic });
1613
+ }
1614
+ if (channels.length || topics.length) {
1615
+ this.log.info("[SharedWS] replayed subscriptions", {
1616
+ channels: channels.length,
1617
+ topics: topics.length
1296
1618
  });
1297
1619
  }
1298
1620
  }
1621
+ /**
1622
+ * Replay buffered follower dispatches over the freshly connected socket.
1623
+ * Gathers from all tabs (including this one), de-dups by id, transmits,
1624
+ * then signals each originator to drop its local entry. Drops own-tab
1625
+ * entries after transmission since `bus.publish` doesn't echo to self.
1626
+ */
1627
+ async replayPendingDispatches() {
1628
+ if (!this.socket) return;
1629
+ const socket = this.socket;
1630
+ const entries = await this.gatherPendingDispatches();
1631
+ if (this.socket !== socket) return;
1632
+ if (entries.length === 0) return;
1633
+ let sent = 0;
1634
+ for (const e of entries) {
1635
+ this.transmit(e.kind, e.payload);
1636
+ this.pendingOutbound.delete(e.id);
1637
+ this.bus.publish("ws:dispatch-flushed", { id: e.id });
1638
+ sent++;
1639
+ }
1640
+ this.log.info("[SharedWS] replayed pending dispatches", { count: sent });
1641
+ }
1642
+ /**
1643
+ * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
1644
+ * — broadcasts a one-shot request, collects for a short window, dedups
1645
+ * by id (so multiple tabs holding the same id don't double-replay).
1646
+ */
1647
+ gatherPendingDispatches(timeoutMs = 100) {
1648
+ const seen = /* @__PURE__ */ new Map();
1649
+ for (const e of this.pendingOutbound.values()) {
1650
+ seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1651
+ }
1652
+ const replyId = generateId();
1653
+ return new Promise((resolve) => {
1654
+ const unsub = this.bus.subscribe(
1655
+ `ws:pending:${replyId}`,
1656
+ (msg) => {
1657
+ for (const e of msg.entries) {
1658
+ if (!seen.has(e.id)) seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1659
+ }
1660
+ }
1661
+ );
1662
+ this.bus.publish("ws:gather-pending", { replyId });
1663
+ setTimeout(() => {
1664
+ unsub();
1665
+ resolve([...seen.values()]);
1666
+ }, timeoutMs);
1667
+ });
1668
+ }
1669
+ /**
1670
+ * Best-effort cross-tab gather. Broadcasts a request and collects responses
1671
+ * for a short window. Times out gracefully — late responses are dropped.
1672
+ * The leader's own subs are seeded into the result to avoid relying on
1673
+ * BroadcastChannel echo to self.
1674
+ */
1675
+ gatherSubscriptions(timeoutMs = 150) {
1676
+ const channels = new Set(this.channelRefs.keys());
1677
+ const topics = new Set(this.topics);
1678
+ const replyId = generateId();
1679
+ return new Promise((resolve) => {
1680
+ const unsub = this.bus.subscribe(
1681
+ `ws:subs:${replyId}`,
1682
+ (msg) => {
1683
+ for (const c of msg.channels) channels.add(c);
1684
+ for (const t of msg.topics) topics.add(t);
1685
+ }
1686
+ );
1687
+ this.bus.publish("ws:gather-subs", { replyId });
1688
+ setTimeout(() => {
1689
+ unsub();
1690
+ resolve({ channels: [...channels], topics: [...topics] });
1691
+ }, timeoutMs);
1692
+ });
1693
+ }
1299
1694
  handleLoseLeadership() {
1695
+ this.stopRefreshTimer();
1300
1696
  if (this.socket) {
1301
1697
  this.socket[Symbol.dispose]();
1302
1698
  this.socket = null;
1303
1699
  }
1304
1700
  }
1701
+ /**
1702
+ * Start a leader-only periodic refresh of the auth token. The callback
1703
+ * is `options.refresh` (preferred) or `options.auth` (fallback). When
1704
+ * the timer fires and the connection is currently authenticated, the
1705
+ * returned token is fed back through `authenticate()` so subscribers
1706
+ * stay synced and the leader's socket re-issues auth-login.
1707
+ *
1708
+ * Idempotent — calling start while already running is a no-op.
1709
+ */
1710
+ startRefreshTimer() {
1711
+ if (this.refreshTimer) return;
1712
+ const interval = this.options.refreshTokenInterval;
1713
+ const refresh = this.options.refresh ?? this.options.auth;
1714
+ if (!interval || interval <= 0 || !refresh) return;
1715
+ if (!this.coordinator.isLeader) return;
1716
+ this.refreshTimer = setInterval(async () => {
1717
+ if (!this.coordinator.isLeader || !this._isAuthenticated) return;
1718
+ try {
1719
+ const token = await refresh();
1720
+ if (!token) {
1721
+ this.log.warn("[SharedWS] refresh() returned empty token \u2014 skipping");
1722
+ return;
1723
+ }
1724
+ this.authenticate(token);
1725
+ } catch (err) {
1726
+ this.log.warn("[SharedWS] refresh() failed", err);
1727
+ }
1728
+ }, interval);
1729
+ }
1730
+ stopRefreshTimer() {
1731
+ if (this.refreshTimer) {
1732
+ clearInterval(this.refreshTimer);
1733
+ this.refreshTimer = null;
1734
+ }
1735
+ }
1305
1736
  [Symbol.dispose]() {
1306
1737
  if (this.disposed) return;
1307
1738
  this.disposed = true;
1739
+ this.stopRefreshTimer();
1308
1740
  this.coordinator[Symbol.dispose]();
1309
1741
  if (this.socket) {
1310
1742
  this.socket[Symbol.dispose]();
@@ -1317,6 +1749,10 @@ var SharedWebSocket = class {
1317
1749
  this.syncStore.clear();
1318
1750
  this.authChannels.clear();
1319
1751
  this.authTopics.clear();
1752
+ this.channelRefs.clear();
1753
+ this.topics.clear();
1754
+ this.rawFrameListeners.clear();
1755
+ this.pendingOutbound.clear();
1320
1756
  }
1321
1757
  };
1322
1758
 
@@ -1328,4 +1764,4 @@ export {
1328
1764
  SubscriptionManager,
1329
1765
  SharedWebSocket
1330
1766
  };
1331
- //# sourceMappingURL=chunk-IK4HLA3K.js.map
1767
+ //# sourceMappingURL=chunk-N63ZMMWV.js.map