@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.
@@ -5,7 +5,7 @@ import { TabCoordinator } from './TabCoordinator';
5
5
  import { SharedSocket } from './SharedSocket';
6
6
  import { WorkerSocket } from './WorkerSocket';
7
7
  import { SubscriptionManager } from './SubscriptionManager';
8
- import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware } from './types';
8
+ import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware, FrameKind, FramePayload, ChannelAckResult } from './types';
9
9
 
10
10
  const DEFAULT_PROTOCOL: EventProtocol = {
11
11
  eventField: 'event',
@@ -28,6 +28,14 @@ const NOOP_LOGGER: Logger = {
28
28
  error() {},
29
29
  };
30
30
 
31
+ /**
32
+ * Internal separator for channel-scoped subscription keys. ASCII RECORD
33
+ * SEPARATOR (U+001E) — chosen because it cannot collide with characters
34
+ * users put in channel or event names. Wire format keeps `:` for server
35
+ * compatibility; this is storage-only.
36
+ */
37
+ const CHANNEL_KEY_SEP = '\u001e';
38
+
31
39
  /** Common interface for both SharedSocket and WorkerSocket. */
32
40
  interface SocketAdapter {
33
41
  readonly state: string;
@@ -72,6 +80,27 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
72
80
  private _isAuthenticated = false;
73
81
  private authChannels = new Map<string, Channel>();
74
82
  private authTopics = new Set<string>();
83
+ /**
84
+ * Refcount of active channel subscriptions per name. Used to route
85
+ * incoming events back to channel handlers via `${name}<RS>${event}`
86
+ * keys without colliding when names/events contain `:`, and as the
87
+ * source for cross-tab subscription replay on leader change.
88
+ */
89
+ private channelRefs = new Map<string, number>();
90
+ /** All topic subscriptions (auth and non-auth). Replayed on leader change. */
91
+ private topics = new Set<string>();
92
+ /** Listeners for every raw incoming frame (post-deserialize, post-middleware). */
93
+ private rawFrameListeners = new Set<(raw: unknown) => void>();
94
+ /**
95
+ * Local outbound buffer of follower-originated dispatches awaiting flush
96
+ * confirmation from the leader. Drained when the leader broadcasts
97
+ * `ws:dispatch-flushed` for the entry's id; replayed by the next leader
98
+ * after gathering across surviving tabs. Insertion order preserved
99
+ * (Map) so we drop oldest on overflow.
100
+ */
101
+ private pendingOutbound = new Map<string, { id: string; kind: FrameKind; payload: FramePayload; enqueuedAt: number }>();
102
+ /** Periodic refresh timer — leader only. Recreated on each leader handover. */
103
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
75
104
 
76
105
  constructor(
77
106
  private readonly url: string,
@@ -90,20 +119,66 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
90
119
 
91
120
  // When ANY tab receives a WS message via bus → emit to local subscribers
92
121
  this.cleanups.push(
93
- this.bus.subscribe<{ event: string; data: unknown }>('ws:message', (msg) => {
94
- this.subs.emit(msg.event, msg.data);
122
+ this.bus.subscribe<{ event: string; data: unknown; raw?: unknown }>('ws:message', (msg) => {
123
+ // Bare emit — fires any handler registered with the literal event name
124
+ this.subs.emit(msg.event, msg.data, msg.raw);
125
+
126
+ // Channel-scoped emit — for each registered channel whose name is a
127
+ // prefix of the incoming event (separated by ':'), also fire handlers
128
+ // stored under `${name}<RS>${rest}`. This lets `Channel.on('msg', h)`
129
+ // receive a wire event like 'chat:room:42:msg' without colon parsing.
130
+ for (const channelName of this.channelRefs.keys()) {
131
+ const prefix = channelName + ':';
132
+ if (msg.event.length > prefix.length && msg.event.startsWith(prefix)) {
133
+ const subEvent = msg.event.slice(prefix.length);
134
+ this.subs.emit(`${channelName}${CHANNEL_KEY_SEP}${subEvent}`, msg.data, msg.raw);
135
+ }
136
+ }
137
+
138
+ // Raw-frame fanout — pending Channel.ready ack matchers listen here.
139
+ if (this.rawFrameListeners.size > 0) {
140
+ for (const fn of this.rawFrameListeners) {
141
+ try { fn(msg.raw); } catch { /* matcher errors don't break dispatch */ }
142
+ }
143
+ }
95
144
  }),
96
145
  );
97
146
 
98
- // Leader listens for send requests from followers
147
+ // Leader listens for dispatch requests from followers — re-enters
148
+ // transmit() so frameBuilder + outgoing middleware run on the tab that
149
+ // actually owns the socket.
99
150
  this.cleanups.push(
100
- this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {
151
+ this.bus.subscribe<{ kind: FrameKind; payload: FramePayload; id?: string }>('ws:dispatch', (msg) => {
101
152
  if (this.coordinator.isLeader && this.socket) {
102
- this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
153
+ this.transmit(msg.kind, msg.payload);
154
+ // Tell the originator to drop the entry from its pending buffer.
155
+ // Always flush — even when transmit was a no-op (middleware drop,
156
+ // frameBuilder returned null) — there's no point retrying a
157
+ // permanently-dropped frame.
158
+ if (msg.id) this.bus.publish('ws:dispatch-flushed', { id: msg.id });
103
159
  }
104
160
  }),
105
161
  );
106
162
 
163
+ // Originator tabs drop their entry once the leader confirms it processed
164
+ // the dispatch (or, on leader change, the new leader confirms replay).
165
+ this.cleanups.push(
166
+ this.bus.subscribe<{ id: string }>('ws:dispatch-flushed', (msg) => {
167
+ this.pendingOutbound.delete(msg.id);
168
+ }),
169
+ );
170
+
171
+ // New-leader gather request — every tab announces its still-pending
172
+ // dispatches so the new leader can replay them on the fresh socket.
173
+ this.cleanups.push(
174
+ this.bus.subscribe<{ replyId: string }>('ws:gather-pending', (req) => {
175
+ if (this.pendingOutbound.size === 0) return;
176
+ this.bus.publish(`ws:pending:${req.replyId}`, {
177
+ entries: [...this.pendingOutbound.values()],
178
+ });
179
+ }),
180
+ );
181
+
107
182
  // Leader listens for reconnect requests from followers
108
183
  this.cleanups.push(
109
184
  this.bus.subscribe<void>('ws:reconnect', () => {
@@ -114,6 +189,29 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
114
189
  }),
115
190
  );
116
191
 
192
+ // Conditional resume — only reconnect if the leader's socket gave up
193
+ // (e.g. auth-failure close code). Sent by authenticate() from followers
194
+ // so they can recover with fresh creds without disrupting healthy tabs.
195
+ this.cleanups.push(
196
+ this.bus.subscribe<void>('ws:authenticate-resume', () => {
197
+ if (this.coordinator.isLeader && this.socket?.state === 'failed') {
198
+ this.log.info('[SharedWS] resume requested after auth — reconnecting failed socket');
199
+ this.socket.reconnect();
200
+ }
201
+ }),
202
+ );
203
+
204
+ // Each tab announces its channels/topics on request. Used on leader
205
+ // promotion or reconnect to rebuild the server-side subscription set.
206
+ this.cleanups.push(
207
+ this.bus.subscribe<{ replyId: string }>('ws:gather-subs', (req) => {
208
+ this.bus.publish(`ws:subs:${req.replyId}`, {
209
+ channels: [...this.channelRefs.keys()],
210
+ topics: [...this.topics],
211
+ });
212
+ }),
213
+ );
214
+
117
215
  // Sync across tabs
118
216
  this.cleanups.push(
119
217
  this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {
@@ -322,9 +420,23 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
322
420
  this._isAuthenticated = true;
323
421
  this.syncStore.set('$auth:token', token);
324
422
  this.bus.broadcast('ws:sync', { key: '$auth:token', value: token });
325
- this.send(this.proto.authLogin, { token });
326
423
  this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: true });
327
424
  this.log.info('[SharedWS] authenticated');
425
+
426
+ // If the leader's socket gave up (e.g. auth-failure close code), the new
427
+ // creds should restart the connection. resubscribeOnConnect resends
428
+ // the auth-login frame from syncStore once we're connected again.
429
+ if (this.coordinator.isLeader && this.socket && this.socket.state === 'failed') {
430
+ this.reconnect();
431
+ return;
432
+ }
433
+
434
+ if (!this.coordinator.isLeader) {
435
+ // Followers can't see leader state — hint to leader to reconnect IFF failed.
436
+ this.bus.publish('ws:authenticate-resume', undefined);
437
+ }
438
+
439
+ this.dispatch('auth-login', { data: token });
328
440
  }
329
441
 
330
442
  /**
@@ -342,7 +454,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
342
454
  this.authTopics.clear();
343
455
 
344
456
  this._isAuthenticated = false;
345
- this.send(this.proto.authLogout, {});
457
+ this.dispatch('auth-logout', {});
346
458
  this.syncStore.delete('$auth:token');
347
459
  this.bus.broadcast('ws:sync', { key: '$auth:token', value: undefined });
348
460
  this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: false });
@@ -425,18 +537,31 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
425
537
 
426
538
  // ─── Event Subscription ──────────────────────────────
427
539
 
428
- /** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */
540
+ /**
541
+ * Subscribe to server events (works in ALL tabs). Type-safe with EventMap.
542
+ *
543
+ * The handler receives `(data, raw)`:
544
+ * - `data` is extracted via `dataField` (default `'data'`)
545
+ * - `raw` is the full deserialized envelope, useful for protocols with extra
546
+ * top-level fields like `id`, `kind`, `channel`, `type`, etc.
547
+ *
548
+ * @example
549
+ * ws.on('msg', (data, raw) => {
550
+ * raw.id; // top-level metadata
551
+ * raw.kind; // discriminator
552
+ * });
553
+ */
429
554
  on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
430
555
  on(event: string, handler: EventHandler<unknown>): Unsubscribe;
431
556
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
- on(event: string, handler: (data: any) => void): Unsubscribe {
557
+ on(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {
433
558
  return this.subs.on(event, handler);
434
559
  }
435
560
 
436
561
  once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
437
562
  once(event: string, handler: EventHandler<unknown>): Unsubscribe;
438
563
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
439
- once(event: string, handler: (data: any) => void): Unsubscribe {
564
+ once(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {
440
565
  return this.subs.once(event, handler);
441
566
  }
442
567
 
@@ -451,30 +576,55 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
451
576
  return this.subs.stream(event, signal);
452
577
  }
453
578
 
454
- /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
455
- send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
456
- send(event: string, data: unknown): void;
457
- send(event: string, data: unknown): void {
458
- // Per-event serializer transforms data before building payload
579
+ /**
580
+ * Send message to server (auto-routed through leader). Type-safe with EventMap.
581
+ *
582
+ * The optional third argument `extras` adds top-level fields to the wire envelope.
583
+ * Use it for protocols that need extra envelope keys like `type`, `channel`, etc.
584
+ *
585
+ * @example
586
+ * // Default shape: { event, data }
587
+ * ws.send('chat.message', { text: 'Hello' });
588
+ * // → { event: 'chat.message', data: { text: 'Hello' } }
589
+ *
590
+ * @example
591
+ * // Pusher/Reverb-style envelope
592
+ * ws.send('group.member_ready',
593
+ * { member_id: 'abc', ready: true },
594
+ * { type: 'event', channel: 'public.group.xxx' },
595
+ * );
596
+ * // → {
597
+ * // type: 'event',
598
+ * // channel: 'public.group.xxx',
599
+ * // event: 'group.member_ready',
600
+ * // data: { member_id: 'abc', ready: true },
601
+ * // }
602
+ */
603
+ send<K extends string & keyof TEvents>(event: K, data: TEvents[K], extras?: Record<string, unknown>): void;
604
+ send(event: string, data: unknown, extras?: Record<string, unknown>): void;
605
+ send(event: string, data: unknown, extras?: Record<string, unknown>): void {
606
+ this.assertExtrasReserved(extras);
607
+
608
+ // Per-event serializer transforms data before the frame is built
459
609
  const eventSerializer = this.serializers.get(event);
460
610
  const serializedData = eventSerializer ? eventSerializer(data) : data;
461
611
 
462
- let payload: unknown = { [this.proto.eventField]: event, [this.proto.dataField]: serializedData };
612
+ this.dispatch('event', { event, data: serializedData, extras });
613
+ }
463
614
 
464
- for (const mw of this.outgoingMiddleware) {
465
- payload = mw(payload);
466
- if (payload === null) {
467
- this.log.debug('[SharedWS] outgoing dropped by middleware', event);
468
- return;
469
- }
615
+ private assertExtrasReserved(extras: Record<string, unknown> | undefined): void {
616
+ if (!extras) return;
617
+ if (this.proto.eventField in extras) {
618
+ throw new Error(
619
+ `SharedWebSocket.send: extras cannot contain reserved key "${this.proto.eventField}" (eventField). ` +
620
+ `Pass the event name as the first argument instead.`,
621
+ );
470
622
  }
471
-
472
- this.log.debug('[SharedWS] send', event, data);
473
-
474
- if (this.coordinator.isLeader && this.socket) {
475
- this.socket.send(payload);
476
- } else {
477
- this.bus.publish('ws:send', { event, data });
623
+ if (this.proto.dataField in extras) {
624
+ throw new Error(
625
+ `SharedWebSocket.send: extras cannot contain reserved key "${this.proto.dataField}" (dataField). ` +
626
+ `Pass the payload as the second argument instead.`,
627
+ );
478
628
  }
479
629
  }
480
630
 
@@ -513,36 +663,94 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
513
663
  * notifications.on('alert', (alert) => showToast(alert));
514
664
  */
515
665
  channel(name: string, options?: { auth?: boolean }): Channel {
666
+ // Set up the ack matcher BEFORE dispatching so we don't miss a fast
667
+ // server response. With no matcher configured, ready resolves
668
+ // synchronously on the next microtask after dispatch.
669
+ const matcher = this.proto.channelAckMatcher;
670
+ const ackTimeout = this.proto.channelAckTimeout ?? 5000;
671
+ let cancelReady: ((reason: Error) => void) | undefined;
672
+
673
+ const ready = matcher
674
+ ? new Promise<void>((resolve, reject) => {
675
+ let settled = false;
676
+ const settle = (fn: () => void) => {
677
+ if (settled) return;
678
+ settled = true;
679
+ clearTimeout(timer);
680
+ unsubAck();
681
+ fn();
682
+ };
683
+ const unsubAck = this.onRawFrame((frame) => {
684
+ let result: ChannelAckResult;
685
+ try {
686
+ result = matcher(frame, name);
687
+ } catch {
688
+ // matcher exceptions are treated as a hard reject
689
+ result = 'reject';
690
+ }
691
+ if (result === 'ok') settle(() => resolve());
692
+ else if (result === 'reject') settle(() => reject(new Error(`SharedWebSocket: subscribe rejected for channel "${name}"`)));
693
+ });
694
+ const timer = setTimeout(
695
+ () => settle(() => reject(new Error(`SharedWebSocket: subscribe ack timeout for channel "${name}"`))),
696
+ ackTimeout,
697
+ );
698
+ cancelReady = (err: Error) => settle(() => reject(err));
699
+ })
700
+ : Promise.resolve();
701
+
702
+ // Avoid noisy unhandled-rejection warnings if the user never awaits ready.
703
+ if (matcher) ready.catch(() => {});
704
+
516
705
  // Notify server about channel subscription
517
- this.send(this.proto.channelJoin, { channel: name });
706
+ this.dispatch('subscribe', { channel: name });
707
+
708
+ // Track this channel for incoming-event prefix routing
709
+ this.channelRefs.set(name, (this.channelRefs.get(name) ?? 0) + 1);
518
710
 
519
711
  const self = this;
520
712
  const unsubs: Unsubscribe[] = [];
521
713
  const isAuth = options?.auth ?? false;
714
+ let left = false;
715
+ const key = (event: string) => `${name}${CHANNEL_KEY_SEP}${event}`;
522
716
 
523
717
  const ch: Channel = {
524
718
  name,
719
+ ready,
525
720
  on(event: string, handler: EventHandler): Unsubscribe {
526
- const unsub = self.subs.on(`${name}:${event}`, handler);
721
+ const unsub = self.subs.on(key(event), handler);
527
722
  unsubs.push(unsub);
528
723
  return unsub;
529
724
  },
530
725
  once(event: string, handler: EventHandler): Unsubscribe {
531
- const unsub = self.subs.once(`${name}:${event}`, handler);
726
+ const unsub = self.subs.once(key(event), handler);
532
727
  unsubs.push(unsub);
533
728
  return unsub;
534
729
  },
535
730
  send(event: string, data: unknown): void {
536
- self.send(`${name}:${event}`, data);
731
+ // Channel name is passed structurally so a custom frameBuilder can
732
+ // emit it as a top-level wire field (Pusher/Reverb-style). The
733
+ // default builder joins as `${channel}:${event}` for back-compat.
734
+ // Per-event serializers are keyed on the joined name (legacy).
735
+ const joined = `${name}:${event}`;
736
+ const eventSerializer = self.serializers.get(joined) ?? self.serializers.get(event);
737
+ const serializedData = eventSerializer ? eventSerializer(data) : data;
738
+ self.dispatch('event', { event, data: serializedData, channel: name });
537
739
  },
538
740
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
539
- return self.subs.stream(`${name}:${event}`, signal);
741
+ return self.subs.stream(key(event), signal);
540
742
  },
541
743
  leave(): void {
542
- self.send(self.proto.channelLeave, { channel: name });
744
+ if (left) return;
745
+ left = true;
746
+ cancelReady?.(new Error(`SharedWebSocket: channel "${name}" left before ack`));
747
+ self.dispatch('unsubscribe', { channel: name });
543
748
  for (const unsub of unsubs) unsub();
544
749
  unsubs.length = 0;
545
750
  if (isAuth) self.authChannels.delete(name);
751
+ const next = (self.channelRefs.get(name) ?? 1) - 1;
752
+ if (next <= 0) self.channelRefs.delete(name);
753
+ else self.channelRefs.set(name, next);
546
754
  },
547
755
  };
548
756
 
@@ -565,7 +773,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
565
773
  * ws.subscribe(`user:${userId}:mentions`);
566
774
  */
567
775
  subscribe(topic: string, options?: { auth?: boolean }): void {
568
- this.send(this.proto.topicSubscribe, { topic });
776
+ this.dispatch('topic-subscribe', { topic });
777
+ this.topics.add(topic);
569
778
  if (options?.auth) {
570
779
  this.authTopics.add(topic);
571
780
  }
@@ -577,7 +786,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
577
786
  * Sends topicUnsubscribe event (default: "$topic:unsubscribe").
578
787
  */
579
788
  unsubscribe(topic: string): void {
580
- this.send(this.proto.topicUnsubscribe, { topic });
789
+ this.dispatch('topic-unsubscribe', { topic });
790
+ this.topics.delete(topic);
581
791
  this.authTopics.delete(topic);
582
792
  this.log.debug('[SharedWS] unsubscribe topic', topic);
583
793
  }
@@ -705,12 +915,163 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
705
915
  this[Symbol.dispose]();
706
916
  }
707
917
 
918
+ // ─── Frame Pipeline ─────────────────────────────────
919
+ //
920
+ // dispatch(kind, payload) is the single entry point for all outgoing
921
+ // frames (events, channel join/leave, topic sub/unsub, auth login/logout).
922
+ // - On the leader, it calls transmit() which builds the frame, runs
923
+ // outgoing middleware, and writes to the socket.
924
+ // - On followers, it forwards { kind, payload } over BroadcastChannel;
925
+ // the leader's bus subscriber re-enters transmit() so middleware
926
+ // runs in exactly one place regardless of which tab originated.
927
+ //
928
+ // The actual wire shape is decided by frameBuilder (custom) or
929
+ // defaultFrameBuilder (legacy two-key { event, data } envelope).
930
+
931
+ /**
932
+ * Build the wire frame for a given kind. Honors custom `frameBuilder`.
933
+ * Return-value contract:
934
+ * - any concrete value → use as the frame
935
+ * - `null` → drop the frame (intentional filter)
936
+ * - `undefined` → fall back to the default builder for this kind
937
+ */
938
+ private buildFrame(kind: FrameKind, payload: FramePayload): unknown {
939
+ if (this.proto.frameBuilder) {
940
+ const result = this.proto.frameBuilder(kind, payload);
941
+ if (result !== undefined) return result;
942
+ // undefined → fall through to default for this kind
943
+ }
944
+ return this.defaultFrameBuilder(kind, payload);
945
+ }
946
+
947
+ /**
948
+ * Subscribe to every raw incoming frame (post-deserialize). Used by
949
+ * `Channel.ready`'s ack matcher. Internal — not part of the public API.
950
+ */
951
+ private onRawFrame(fn: (raw: unknown) => void): Unsubscribe {
952
+ this.rawFrameListeners.add(fn);
953
+ return () => { this.rawFrameListeners.delete(fn); };
954
+ }
955
+
956
+ /** Legacy two-key builder — preserved as the default for back-compat. */
957
+ private defaultFrameBuilder(kind: FrameKind, p: FramePayload): unknown {
958
+ let eventName: string;
959
+ let dataPart: unknown;
960
+
961
+ switch (kind) {
962
+ case 'event':
963
+ // Channel-scoped events join with `:` for wire compat (Pusher convention).
964
+ eventName = p.channel ? `${p.channel}:${p.event ?? ''}` : (p.event ?? this.proto.defaultEvent);
965
+ dataPart = p.data;
966
+ break;
967
+ case 'subscribe':
968
+ eventName = this.proto.channelJoin;
969
+ dataPart = { channel: p.channel };
970
+ break;
971
+ case 'unsubscribe':
972
+ eventName = this.proto.channelLeave;
973
+ dataPart = { channel: p.channel };
974
+ break;
975
+ case 'topic-subscribe':
976
+ eventName = this.proto.topicSubscribe;
977
+ dataPart = { topic: p.topic };
978
+ break;
979
+ case 'topic-unsubscribe':
980
+ eventName = this.proto.topicUnsubscribe;
981
+ dataPart = { topic: p.topic };
982
+ break;
983
+ case 'auth-login':
984
+ eventName = this.proto.authLogin;
985
+ dataPart = { token: p.data };
986
+ break;
987
+ case 'auth-logout':
988
+ eventName = this.proto.authLogout;
989
+ dataPart = {};
990
+ break;
991
+ }
992
+
993
+ return {
994
+ ...(p.extras ?? {}),
995
+ [this.proto.eventField]: eventName,
996
+ [this.proto.dataField]: dataPart,
997
+ };
998
+ }
999
+
1000
+ /** Route a structured frame: leader transmits, followers forward via bus. */
1001
+ private dispatch(kind: FrameKind, payload: FramePayload): void {
1002
+ if (this.coordinator.isLeader && this.socket) {
1003
+ this.transmit(kind, payload);
1004
+ return;
1005
+ }
1006
+ // Follower path — buffer locally so the next leader can replay if the
1007
+ // current leader dies before the dispatch reaches the socket.
1008
+ const id = generateId();
1009
+ this.enqueuePending(id, kind, payload);
1010
+ this.bus.publish('ws:dispatch', { id, kind, payload });
1011
+ }
1012
+
1013
+ private enqueuePending(id: string, kind: FrameKind, payload: FramePayload): void {
1014
+ const max = this.options.outboundBufferSize ?? 100;
1015
+ if (max <= 0) return;
1016
+ if (this.pendingOutbound.size >= max) {
1017
+ // Drop oldest — Map iteration order = insertion order.
1018
+ const oldestKey = this.pendingOutbound.keys().next().value;
1019
+ if (oldestKey !== undefined) this.pendingOutbound.delete(oldestKey);
1020
+ }
1021
+ this.pendingOutbound.set(id, { id, kind, payload, enqueuedAt: Date.now() });
1022
+ }
1023
+
1024
+ /** Build, run middleware, and write to the socket. Leader-only. */
1025
+ private transmit(kind: FrameKind, payload: FramePayload): void {
1026
+ if (!this.socket) return;
1027
+ let frame: unknown = this.buildFrame(kind, payload);
1028
+ if (frame === null) {
1029
+ this.log.debug('[SharedWS] ✗ frameBuilder dropped frame', kind, this.frameLabel(kind, payload));
1030
+ return;
1031
+ }
1032
+ for (const mw of this.outgoingMiddleware) {
1033
+ frame = mw(frame);
1034
+ if (frame === null) {
1035
+ this.log.debug('[SharedWS] ✗ outgoing dropped by middleware', kind, this.frameLabel(kind, payload));
1036
+ return;
1037
+ }
1038
+ }
1039
+ // Auth frames carry a token — never log payload or wire frame.
1040
+ if (kind === 'auth-login') {
1041
+ this.log.debug('[SharedWS] → send', kind, '(token redacted)');
1042
+ } else {
1043
+ this.log.debug('[SharedWS] → send', kind, this.frameLabel(kind, payload), { payload, frame });
1044
+ }
1045
+ this.socket.send(frame);
1046
+ }
1047
+
1048
+ /**
1049
+ * Human-readable headline for log lines — picks the most relevant field
1050
+ * out of the structured payload so log scanners aren't reading objects:
1051
+ * - event → event name
1052
+ * - subscribe → channel
1053
+ * - topic-* → topic
1054
+ * - auth-* → '(redacted)' / ''
1055
+ */
1056
+ private frameLabel(kind: FrameKind, p: FramePayload): string {
1057
+ switch (kind) {
1058
+ case 'event': return p.event ?? '?';
1059
+ case 'subscribe':
1060
+ case 'unsubscribe': return p.channel ?? '?';
1061
+ case 'topic-subscribe':
1062
+ case 'topic-unsubscribe': return p.topic ?? '?';
1063
+ case 'auth-login': return '(redacted)';
1064
+ case 'auth-logout': return '';
1065
+ }
1066
+ }
1067
+
708
1068
  private createSocket(): SocketAdapter {
709
1069
  const socketOptions = {
710
1070
  protocols: this.options.protocols,
711
1071
  reconnect: this.options.reconnect,
712
1072
  reconnectMaxDelay: this.options.reconnectMaxDelay,
713
1073
  reconnectMaxRetries: this.options.reconnectMaxRetries,
1074
+ authFailureCloseCodes: this.options.authFailureCloseCodes,
714
1075
  heartbeatInterval: this.options.heartbeatInterval,
715
1076
  sendBuffer: this.options.sendBuffer,
716
1077
  pingPayload: this.proto.ping,
@@ -741,13 +1102,14 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
741
1102
  private handleBecomeLeader(): void {
742
1103
  this.log.info('[SharedWS] 👑 became leader');
743
1104
  this.socket = this.createSocket();
1105
+ this.startRefreshTimer();
744
1106
 
745
1107
  this.socket.onMessage((raw: unknown) => {
746
1108
  let data: unknown = raw;
747
1109
  for (const mw of this.incomingMiddleware) {
748
1110
  data = mw(data);
749
1111
  if (data === null) {
750
- this.log.debug('[SharedWS] ✗ incoming dropped by middleware');
1112
+ this.log.debug('[SharedWS] ✗ incoming dropped by middleware', { raw });
751
1113
  return;
752
1114
  }
753
1115
  }
@@ -762,8 +1124,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
762
1124
  payload = eventDeserializer(payload);
763
1125
  }
764
1126
 
765
- this.log.debug('[SharedWS] ← recv', event, payload);
766
- this.bus.broadcast('ws:message', { event, data: payload });
1127
+ this.log.debug('[SharedWS] ← recv', event, { data: payload, raw: data });
1128
+ this.bus.broadcast('ws:message', { event, data: payload, raw: data });
767
1129
  });
768
1130
 
769
1131
  this.socket.onStateChange((state: string) => {
@@ -771,7 +1133,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
771
1133
  switch (state) {
772
1134
  case 'connected':
773
1135
  this.bus.broadcast('ws:lifecycle', { type: 'connect' });
774
- this.reAuthenticateOnReconnect();
1136
+ void this.onConnected();
775
1137
  break;
776
1138
  case 'closed':
777
1139
  this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
@@ -796,7 +1158,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
796
1158
  resolve(res?.[this.proto.dataField] ?? response);
797
1159
  }
798
1160
  });
799
- this.socket!.send({ event: req.event, data: req.data });
1161
+ this.transmit('event', { event: req.event, data: req.data });
800
1162
  });
801
1163
  }),
802
1164
  );
@@ -804,45 +1166,193 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
804
1166
  void this.socket.connect();
805
1167
  }
806
1168
 
807
- private reAuthenticateOnReconnect(): void {
808
- if (!this._isAuthenticated || !this.socket) return;
1169
+ /**
1170
+ * Re-establish all server-side state on the freshly connected leader socket:
1171
+ * 1. auth-login (so server accepts subsequent joins on auth channels)
1172
+ * 2. channel-join for the union of channels held by ALL surviving tabs
1173
+ * 3. topic-subscribe for the union of topics held by ALL surviving tabs
1174
+ *
1175
+ * The union covers leader handover: when a follower with handlers is
1176
+ * promoted, no tab's subscriptions get silently dropped. Frames are sent
1177
+ * in FIFO order over the single WebSocket, so auth precedes the joins
1178
+ * that depend on it.
1179
+ */
1180
+ /**
1181
+ * Orchestrate post-connect recovery: replay subscriptions first (so the
1182
+ * server is ready to route events for any channels we still care about),
1183
+ * then drain follower-pending dispatches that didn't reach the previous
1184
+ * leader's socket.
1185
+ */
1186
+ private async onConnected(): Promise<void> {
1187
+ await this.resubscribeOnConnect();
1188
+ await this.replayPendingDispatches();
1189
+ }
1190
+
1191
+ private async resubscribeOnConnect(): Promise<void> {
1192
+ if (!this.socket) return;
1193
+ const socket = this.socket;
809
1194
 
810
- const token = this.syncStore.get('$auth:token') as string | undefined;
811
- if (token) {
812
- this.socket.send({
813
- [this.proto.eventField]: this.proto.authLogin,
814
- [this.proto.dataField]: { token },
815
- });
816
- this.log.debug('[SharedWS] re-authenticated after reconnect');
1195
+ // 1. Re-authenticate first so subsequent auth-channel joins succeed.
1196
+ if (this._isAuthenticated) {
1197
+ const token = this.syncStore.get('$auth:token') as string | undefined;
1198
+ if (token) {
1199
+ this.transmit('auth-login', { data: token });
1200
+ this.log.debug('[SharedWS] re-authenticated after reconnect');
1201
+ }
817
1202
  }
818
1203
 
819
- // Re-join auth channels
820
- for (const name of this.authChannels.keys()) {
821
- this.socket.send({
822
- [this.proto.eventField]: this.proto.channelJoin,
823
- [this.proto.dataField]: { channel: name },
824
- });
1204
+ // 2/3. Gather subscriptions from all surviving tabs (including self).
1205
+ const { channels, topics } = await this.gatherSubscriptions();
1206
+ if (this.socket !== socket) return; // socket replaced while we were waiting
1207
+
1208
+ for (const name of channels) {
1209
+ this.transmit('subscribe', { channel: name });
1210
+ }
1211
+ for (const topic of topics) {
1212
+ this.transmit('topic-subscribe', { topic });
825
1213
  }
826
1214
 
827
- // Re-subscribe auth topics
828
- for (const topic of this.authTopics) {
829
- this.socket.send({
830
- [this.proto.eventField]: this.proto.topicSubscribe,
831
- [this.proto.dataField]: { topic },
1215
+ if (channels.length || topics.length) {
1216
+ this.log.info('[SharedWS] replayed subscriptions', {
1217
+ channels: channels.length,
1218
+ topics: topics.length,
832
1219
  });
833
1220
  }
834
1221
  }
835
1222
 
1223
+ /**
1224
+ * Replay buffered follower dispatches over the freshly connected socket.
1225
+ * Gathers from all tabs (including this one), de-dups by id, transmits,
1226
+ * then signals each originator to drop its local entry. Drops own-tab
1227
+ * entries after transmission since `bus.publish` doesn't echo to self.
1228
+ */
1229
+ private async replayPendingDispatches(): Promise<void> {
1230
+ if (!this.socket) return;
1231
+ const socket = this.socket;
1232
+ const entries = await this.gatherPendingDispatches();
1233
+ if (this.socket !== socket) return; // socket replaced while waiting
1234
+ if (entries.length === 0) return;
1235
+
1236
+ let sent = 0;
1237
+ for (const e of entries) {
1238
+ this.transmit(e.kind, e.payload);
1239
+ // Remove from own pending (publish doesn't echo to self) and tell
1240
+ // any other tab that originated the same id to drop it as well.
1241
+ this.pendingOutbound.delete(e.id);
1242
+ this.bus.publish('ws:dispatch-flushed', { id: e.id });
1243
+ sent++;
1244
+ }
1245
+ this.log.info('[SharedWS] replayed pending dispatches', { count: sent });
1246
+ }
1247
+
1248
+ /**
1249
+ * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
1250
+ * — broadcasts a one-shot request, collects for a short window, dedups
1251
+ * by id (so multiple tabs holding the same id don't double-replay).
1252
+ */
1253
+ private gatherPendingDispatches(timeoutMs = 100): Promise<Array<{ id: string; kind: FrameKind; payload: FramePayload }>> {
1254
+ const seen = new Map<string, { id: string; kind: FrameKind; payload: FramePayload }>();
1255
+ for (const e of this.pendingOutbound.values()) {
1256
+ seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1257
+ }
1258
+ const replyId = generateId();
1259
+
1260
+ return new Promise((resolve) => {
1261
+ const unsub = this.bus.subscribe<{ entries: Array<{ id: string; kind: FrameKind; payload: FramePayload; enqueuedAt: number }> }>(
1262
+ `ws:pending:${replyId}`,
1263
+ (msg) => {
1264
+ for (const e of msg.entries) {
1265
+ if (!seen.has(e.id)) seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1266
+ }
1267
+ },
1268
+ );
1269
+ this.bus.publish('ws:gather-pending', { replyId });
1270
+ setTimeout(() => {
1271
+ unsub();
1272
+ resolve([...seen.values()]);
1273
+ }, timeoutMs);
1274
+ });
1275
+ }
1276
+
1277
+ /**
1278
+ * Best-effort cross-tab gather. Broadcasts a request and collects responses
1279
+ * for a short window. Times out gracefully — late responses are dropped.
1280
+ * The leader's own subs are seeded into the result to avoid relying on
1281
+ * BroadcastChannel echo to self.
1282
+ */
1283
+ private gatherSubscriptions(timeoutMs = 150): Promise<{ channels: string[]; topics: string[] }> {
1284
+ const channels = new Set<string>(this.channelRefs.keys());
1285
+ const topics = new Set<string>(this.topics);
1286
+ const replyId = generateId();
1287
+
1288
+ return new Promise((resolve) => {
1289
+ const unsub = this.bus.subscribe<{ channels: string[]; topics: string[] }>(
1290
+ `ws:subs:${replyId}`,
1291
+ (msg) => {
1292
+ for (const c of msg.channels) channels.add(c);
1293
+ for (const t of msg.topics) topics.add(t);
1294
+ },
1295
+ );
1296
+
1297
+ this.bus.publish('ws:gather-subs', { replyId });
1298
+
1299
+ setTimeout(() => {
1300
+ unsub();
1301
+ resolve({ channels: [...channels], topics: [...topics] });
1302
+ }, timeoutMs);
1303
+ });
1304
+ }
1305
+
836
1306
  private handleLoseLeadership(): void {
1307
+ this.stopRefreshTimer();
837
1308
  if (this.socket) {
838
1309
  this.socket[Symbol.dispose]();
839
1310
  this.socket = null;
840
1311
  }
841
1312
  }
842
1313
 
1314
+ /**
1315
+ * Start a leader-only periodic refresh of the auth token. The callback
1316
+ * is `options.refresh` (preferred) or `options.auth` (fallback). When
1317
+ * the timer fires and the connection is currently authenticated, the
1318
+ * returned token is fed back through `authenticate()` so subscribers
1319
+ * stay synced and the leader's socket re-issues auth-login.
1320
+ *
1321
+ * Idempotent — calling start while already running is a no-op.
1322
+ */
1323
+ private startRefreshTimer(): void {
1324
+ if (this.refreshTimer) return;
1325
+ const interval = this.options.refreshTokenInterval;
1326
+ const refresh = this.options.refresh ?? this.options.auth;
1327
+ if (!interval || interval <= 0 || !refresh) return;
1328
+ if (!this.coordinator.isLeader) return;
1329
+
1330
+ this.refreshTimer = setInterval(async () => {
1331
+ if (!this.coordinator.isLeader || !this._isAuthenticated) return;
1332
+ try {
1333
+ const token = await refresh();
1334
+ if (!token) {
1335
+ this.log.warn('[SharedWS] refresh() returned empty token — skipping');
1336
+ return;
1337
+ }
1338
+ this.authenticate(token);
1339
+ } catch (err) {
1340
+ this.log.warn('[SharedWS] refresh() failed', err);
1341
+ }
1342
+ }, interval);
1343
+ }
1344
+
1345
+ private stopRefreshTimer(): void {
1346
+ if (this.refreshTimer) {
1347
+ clearInterval(this.refreshTimer);
1348
+ this.refreshTimer = null;
1349
+ }
1350
+ }
1351
+
843
1352
  [Symbol.dispose](): void {
844
1353
  if (this.disposed) return;
845
1354
  this.disposed = true;
1355
+ this.stopRefreshTimer();
846
1356
 
847
1357
  this.coordinator[Symbol.dispose]();
848
1358
 
@@ -858,5 +1368,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
858
1368
  this.syncStore.clear();
859
1369
  this.authChannels.clear();
860
1370
  this.authTopics.clear();
1371
+ this.channelRefs.clear();
1372
+ this.topics.clear();
1373
+ this.rawFrameListeners.clear();
1374
+ this.pendingOutbound.clear();
861
1375
  }
862
1376
  }