@gwakko/shared-websocket 0.12.3 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,11 +28,20 @@ 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;
34
42
  connect(): void | Promise<void>;
35
43
  send(data: unknown): void;
44
+ reconnect(): void;
36
45
  disconnect(): void;
37
46
  onMessage(fn: EventHandler): Unsubscribe;
38
47
  onStateChange(fn: (state: string) => void): Unsubscribe;
@@ -71,6 +80,27 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
71
80
  private _isAuthenticated = false;
72
81
  private authChannels = new Map<string, Channel>();
73
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;
74
104
 
75
105
  constructor(
76
106
  private readonly url: string,
@@ -89,20 +119,99 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
89
119
 
90
120
  // When ANY tab receives a WS message via bus → emit to local subscribers
91
121
  this.cleanups.push(
92
- this.bus.subscribe<{ event: string; data: unknown }>('ws:message', (msg) => {
93
- 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
+ }
144
+ }),
145
+ );
146
+
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.
150
+ this.cleanups.push(
151
+ this.bus.subscribe<{ kind: FrameKind; payload: FramePayload; id?: string }>('ws:dispatch', (msg) => {
152
+ if (this.coordinator.isLeader && this.socket) {
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 });
159
+ }
160
+ }),
161
+ );
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
+ });
94
179
  }),
95
180
  );
96
181
 
97
- // Leader listens for send requests from followers
182
+ // Leader listens for reconnect requests from followers
98
183
  this.cleanups.push(
99
- this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {
184
+ this.bus.subscribe<void>('ws:reconnect', () => {
100
185
  if (this.coordinator.isLeader && this.socket) {
101
- this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
186
+ this.log.info('[SharedWS] manual reconnect requested by follower');
187
+ this.socket.reconnect();
188
+ }
189
+ }),
190
+ );
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();
102
200
  }
103
201
  }),
104
202
  );
105
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
+
106
215
  // Sync across tabs
107
216
  this.cleanups.push(
108
217
  this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {
@@ -134,6 +243,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
134
243
  case 'reconnecting':
135
244
  this.subs.emit('$lifecycle:reconnecting', undefined);
136
245
  break;
246
+ case 'reconnectFailed':
247
+ this.subs.emit('$lifecycle:reconnectFailed', undefined);
248
+ break;
137
249
  case 'leader':
138
250
  this.subs.emit('$lifecycle:leader', msg.isLeader);
139
251
  break;
@@ -228,6 +340,38 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
228
340
  return this.subs.on('$lifecycle:reconnecting', fn);
229
341
  }
230
342
 
343
+ /**
344
+ * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
345
+ * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
346
+ * so the user can call `ws.reconnect()` to try again.
347
+ *
348
+ * @example
349
+ * ws.onReconnectFailed(() => {
350
+ * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
351
+ * });
352
+ */
353
+ onReconnectFailed(fn: () => void): Unsubscribe {
354
+ return this.subs.on('$lifecycle:reconnectFailed', fn);
355
+ }
356
+
357
+ /**
358
+ * Manually trigger a reconnect. Resets the retry counter and attempts a
359
+ * fresh connection. Safe to call from any tab — the leader actually owns
360
+ * the socket, followers route the request via BroadcastChannel.
361
+ *
362
+ * Use after `onReconnectFailed` fires to let the user retry.
363
+ *
364
+ * @example
365
+ * snackbar.action('Reconnect', () => ws.reconnect());
366
+ */
367
+ reconnect(): void {
368
+ if (this.coordinator.isLeader && this.socket) {
369
+ this.socket.reconnect();
370
+ } else {
371
+ this.bus.publish('ws:reconnect', undefined);
372
+ }
373
+ }
374
+
231
375
  /** Called when this tab becomes leader or loses leadership. */
232
376
  onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {
233
377
  return this.subs.on('$lifecycle:leader', fn as EventHandler);
@@ -276,9 +420,23 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
276
420
  this._isAuthenticated = true;
277
421
  this.syncStore.set('$auth:token', token);
278
422
  this.bus.broadcast('ws:sync', { key: '$auth:token', value: token });
279
- this.send(this.proto.authLogin, { token });
280
423
  this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: true });
281
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 });
282
440
  }
283
441
 
284
442
  /**
@@ -296,7 +454,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
296
454
  this.authTopics.clear();
297
455
 
298
456
  this._isAuthenticated = false;
299
- this.send(this.proto.authLogout, {});
457
+ this.dispatch('auth-logout', {});
300
458
  this.syncStore.delete('$auth:token');
301
459
  this.bus.broadcast('ws:sync', { key: '$auth:token', value: undefined });
302
460
  this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: false });
@@ -379,18 +537,31 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
379
537
 
380
538
  // ─── Event Subscription ──────────────────────────────
381
539
 
382
- /** 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
+ */
383
554
  on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
384
555
  on(event: string, handler: EventHandler<unknown>): Unsubscribe;
385
556
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
386
- on(event: string, handler: (data: any) => void): Unsubscribe {
557
+ on(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {
387
558
  return this.subs.on(event, handler);
388
559
  }
389
560
 
390
561
  once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
391
562
  once(event: string, handler: EventHandler<unknown>): Unsubscribe;
392
563
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
- once(event: string, handler: (data: any) => void): Unsubscribe {
564
+ once(event: string, handler: (data: any, raw?: unknown) => void): Unsubscribe {
394
565
  return this.subs.once(event, handler);
395
566
  }
396
567
 
@@ -405,30 +576,55 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
405
576
  return this.subs.stream(event, signal);
406
577
  }
407
578
 
408
- /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
409
- send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
410
- send(event: string, data: unknown): void;
411
- send(event: string, data: unknown): void {
412
- // 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
413
609
  const eventSerializer = this.serializers.get(event);
414
610
  const serializedData = eventSerializer ? eventSerializer(data) : data;
415
611
 
416
- let payload: unknown = { [this.proto.eventField]: event, [this.proto.dataField]: serializedData };
612
+ this.dispatch('event', { event, data: serializedData, extras });
613
+ }
417
614
 
418
- for (const mw of this.outgoingMiddleware) {
419
- payload = mw(payload);
420
- if (payload === null) {
421
- this.log.debug('[SharedWS] outgoing dropped by middleware', event);
422
- return;
423
- }
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
+ );
424
622
  }
425
-
426
- this.log.debug('[SharedWS] send', event, data);
427
-
428
- if (this.coordinator.isLeader && this.socket) {
429
- this.socket.send(payload);
430
- } else {
431
- 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
+ );
432
628
  }
433
629
  }
434
630
 
@@ -467,36 +663,94 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
467
663
  * notifications.on('alert', (alert) => showToast(alert));
468
664
  */
469
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
+
470
705
  // Notify server about channel subscription
471
- 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);
472
710
 
473
711
  const self = this;
474
712
  const unsubs: Unsubscribe[] = [];
475
713
  const isAuth = options?.auth ?? false;
714
+ let left = false;
715
+ const key = (event: string) => `${name}${CHANNEL_KEY_SEP}${event}`;
476
716
 
477
717
  const ch: Channel = {
478
718
  name,
719
+ ready,
479
720
  on(event: string, handler: EventHandler): Unsubscribe {
480
- const unsub = self.subs.on(`${name}:${event}`, handler);
721
+ const unsub = self.subs.on(key(event), handler);
481
722
  unsubs.push(unsub);
482
723
  return unsub;
483
724
  },
484
725
  once(event: string, handler: EventHandler): Unsubscribe {
485
- const unsub = self.subs.once(`${name}:${event}`, handler);
726
+ const unsub = self.subs.once(key(event), handler);
486
727
  unsubs.push(unsub);
487
728
  return unsub;
488
729
  },
489
730
  send(event: string, data: unknown): void {
490
- 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 });
491
739
  },
492
740
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
493
- return self.subs.stream(`${name}:${event}`, signal);
741
+ return self.subs.stream(key(event), signal);
494
742
  },
495
743
  leave(): void {
496
- 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 });
497
748
  for (const unsub of unsubs) unsub();
498
749
  unsubs.length = 0;
499
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);
500
754
  },
501
755
  };
502
756
 
@@ -519,7 +773,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
519
773
  * ws.subscribe(`user:${userId}:mentions`);
520
774
  */
521
775
  subscribe(topic: string, options?: { auth?: boolean }): void {
522
- this.send(this.proto.topicSubscribe, { topic });
776
+ this.dispatch('topic-subscribe', { topic });
777
+ this.topics.add(topic);
523
778
  if (options?.auth) {
524
779
  this.authTopics.add(topic);
525
780
  }
@@ -531,7 +786,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
531
786
  * Sends topicUnsubscribe event (default: "$topic:unsubscribe").
532
787
  */
533
788
  unsubscribe(topic: string): void {
534
- this.send(this.proto.topicUnsubscribe, { topic });
789
+ this.dispatch('topic-unsubscribe', { topic });
790
+ this.topics.delete(topic);
535
791
  this.authTopics.delete(topic);
536
792
  this.log.debug('[SharedWS] unsubscribe topic', topic);
537
793
  }
@@ -659,12 +915,138 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
659
915
  this[Symbol.dispose]();
660
916
  }
661
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);
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);
1036
+ return;
1037
+ }
1038
+ }
1039
+ this.log.debug('[SharedWS] → send', kind, payload);
1040
+ this.socket.send(frame);
1041
+ }
1042
+
662
1043
  private createSocket(): SocketAdapter {
663
1044
  const socketOptions = {
664
1045
  protocols: this.options.protocols,
665
1046
  reconnect: this.options.reconnect,
666
1047
  reconnectMaxDelay: this.options.reconnectMaxDelay,
667
1048
  reconnectMaxRetries: this.options.reconnectMaxRetries,
1049
+ authFailureCloseCodes: this.options.authFailureCloseCodes,
668
1050
  heartbeatInterval: this.options.heartbeatInterval,
669
1051
  sendBuffer: this.options.sendBuffer,
670
1052
  pingPayload: this.proto.ping,
@@ -695,6 +1077,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
695
1077
  private handleBecomeLeader(): void {
696
1078
  this.log.info('[SharedWS] 👑 became leader');
697
1079
  this.socket = this.createSocket();
1080
+ this.startRefreshTimer();
698
1081
 
699
1082
  this.socket.onMessage((raw: unknown) => {
700
1083
  let data: unknown = raw;
@@ -717,15 +1100,15 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
717
1100
  }
718
1101
 
719
1102
  this.log.debug('[SharedWS] ← recv', event, payload);
720
- this.bus.broadcast('ws:message', { event, data: payload });
1103
+ this.bus.broadcast('ws:message', { event, data: payload, raw: data });
721
1104
  });
722
1105
 
723
1106
  this.socket.onStateChange((state: string) => {
724
- this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);
1107
+ this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : state === 'failed' ? '✗ reconnect failed' : `state: ${state}`);
725
1108
  switch (state) {
726
1109
  case 'connected':
727
1110
  this.bus.broadcast('ws:lifecycle', { type: 'connect' });
728
- this.reAuthenticateOnReconnect();
1111
+ void this.onConnected();
729
1112
  break;
730
1113
  case 'closed':
731
1114
  this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
@@ -733,6 +1116,10 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
733
1116
  case 'reconnecting':
734
1117
  this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });
735
1118
  break;
1119
+ case 'failed':
1120
+ this.bus.broadcast('ws:lifecycle', { type: 'reconnectFailed' });
1121
+ this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
1122
+ break;
736
1123
  }
737
1124
  });
738
1125
 
@@ -746,7 +1133,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
746
1133
  resolve(res?.[this.proto.dataField] ?? response);
747
1134
  }
748
1135
  });
749
- this.socket!.send({ event: req.event, data: req.data });
1136
+ this.transmit('event', { event: req.event, data: req.data });
750
1137
  });
751
1138
  }),
752
1139
  );
@@ -754,45 +1141,193 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
754
1141
  void this.socket.connect();
755
1142
  }
756
1143
 
757
- private reAuthenticateOnReconnect(): void {
758
- if (!this._isAuthenticated || !this.socket) return;
1144
+ /**
1145
+ * Re-establish all server-side state on the freshly connected leader socket:
1146
+ * 1. auth-login (so server accepts subsequent joins on auth channels)
1147
+ * 2. channel-join for the union of channels held by ALL surviving tabs
1148
+ * 3. topic-subscribe for the union of topics held by ALL surviving tabs
1149
+ *
1150
+ * The union covers leader handover: when a follower with handlers is
1151
+ * promoted, no tab's subscriptions get silently dropped. Frames are sent
1152
+ * in FIFO order over the single WebSocket, so auth precedes the joins
1153
+ * that depend on it.
1154
+ */
1155
+ /**
1156
+ * Orchestrate post-connect recovery: replay subscriptions first (so the
1157
+ * server is ready to route events for any channels we still care about),
1158
+ * then drain follower-pending dispatches that didn't reach the previous
1159
+ * leader's socket.
1160
+ */
1161
+ private async onConnected(): Promise<void> {
1162
+ await this.resubscribeOnConnect();
1163
+ await this.replayPendingDispatches();
1164
+ }
759
1165
 
760
- const token = this.syncStore.get('$auth:token') as string | undefined;
761
- if (token) {
762
- this.socket.send({
763
- [this.proto.eventField]: this.proto.authLogin,
764
- [this.proto.dataField]: { token },
765
- });
766
- this.log.debug('[SharedWS] re-authenticated after reconnect');
1166
+ private async resubscribeOnConnect(): Promise<void> {
1167
+ if (!this.socket) return;
1168
+ const socket = this.socket;
1169
+
1170
+ // 1. Re-authenticate first so subsequent auth-channel joins succeed.
1171
+ if (this._isAuthenticated) {
1172
+ const token = this.syncStore.get('$auth:token') as string | undefined;
1173
+ if (token) {
1174
+ this.transmit('auth-login', { data: token });
1175
+ this.log.debug('[SharedWS] re-authenticated after reconnect');
1176
+ }
767
1177
  }
768
1178
 
769
- // Re-join auth channels
770
- for (const name of this.authChannels.keys()) {
771
- this.socket.send({
772
- [this.proto.eventField]: this.proto.channelJoin,
773
- [this.proto.dataField]: { channel: name },
774
- });
1179
+ // 2/3. Gather subscriptions from all surviving tabs (including self).
1180
+ const { channels, topics } = await this.gatherSubscriptions();
1181
+ if (this.socket !== socket) return; // socket replaced while we were waiting
1182
+
1183
+ for (const name of channels) {
1184
+ this.transmit('subscribe', { channel: name });
1185
+ }
1186
+ for (const topic of topics) {
1187
+ this.transmit('topic-subscribe', { topic });
775
1188
  }
776
1189
 
777
- // Re-subscribe auth topics
778
- for (const topic of this.authTopics) {
779
- this.socket.send({
780
- [this.proto.eventField]: this.proto.topicSubscribe,
781
- [this.proto.dataField]: { topic },
1190
+ if (channels.length || topics.length) {
1191
+ this.log.info('[SharedWS] replayed subscriptions', {
1192
+ channels: channels.length,
1193
+ topics: topics.length,
782
1194
  });
783
1195
  }
784
1196
  }
785
1197
 
1198
+ /**
1199
+ * Replay buffered follower dispatches over the freshly connected socket.
1200
+ * Gathers from all tabs (including this one), de-dups by id, transmits,
1201
+ * then signals each originator to drop its local entry. Drops own-tab
1202
+ * entries after transmission since `bus.publish` doesn't echo to self.
1203
+ */
1204
+ private async replayPendingDispatches(): Promise<void> {
1205
+ if (!this.socket) return;
1206
+ const socket = this.socket;
1207
+ const entries = await this.gatherPendingDispatches();
1208
+ if (this.socket !== socket) return; // socket replaced while waiting
1209
+ if (entries.length === 0) return;
1210
+
1211
+ let sent = 0;
1212
+ for (const e of entries) {
1213
+ this.transmit(e.kind, e.payload);
1214
+ // Remove from own pending (publish doesn't echo to self) and tell
1215
+ // any other tab that originated the same id to drop it as well.
1216
+ this.pendingOutbound.delete(e.id);
1217
+ this.bus.publish('ws:dispatch-flushed', { id: e.id });
1218
+ sent++;
1219
+ }
1220
+ this.log.info('[SharedWS] replayed pending dispatches', { count: sent });
1221
+ }
1222
+
1223
+ /**
1224
+ * Cross-tab pending-dispatch gather. Same shape as `gatherSubscriptions`
1225
+ * — broadcasts a one-shot request, collects for a short window, dedups
1226
+ * by id (so multiple tabs holding the same id don't double-replay).
1227
+ */
1228
+ private gatherPendingDispatches(timeoutMs = 100): Promise<Array<{ id: string; kind: FrameKind; payload: FramePayload }>> {
1229
+ const seen = new Map<string, { id: string; kind: FrameKind; payload: FramePayload }>();
1230
+ for (const e of this.pendingOutbound.values()) {
1231
+ seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1232
+ }
1233
+ const replyId = generateId();
1234
+
1235
+ return new Promise((resolve) => {
1236
+ const unsub = this.bus.subscribe<{ entries: Array<{ id: string; kind: FrameKind; payload: FramePayload; enqueuedAt: number }> }>(
1237
+ `ws:pending:${replyId}`,
1238
+ (msg) => {
1239
+ for (const e of msg.entries) {
1240
+ if (!seen.has(e.id)) seen.set(e.id, { id: e.id, kind: e.kind, payload: e.payload });
1241
+ }
1242
+ },
1243
+ );
1244
+ this.bus.publish('ws:gather-pending', { replyId });
1245
+ setTimeout(() => {
1246
+ unsub();
1247
+ resolve([...seen.values()]);
1248
+ }, timeoutMs);
1249
+ });
1250
+ }
1251
+
1252
+ /**
1253
+ * Best-effort cross-tab gather. Broadcasts a request and collects responses
1254
+ * for a short window. Times out gracefully — late responses are dropped.
1255
+ * The leader's own subs are seeded into the result to avoid relying on
1256
+ * BroadcastChannel echo to self.
1257
+ */
1258
+ private gatherSubscriptions(timeoutMs = 150): Promise<{ channels: string[]; topics: string[] }> {
1259
+ const channels = new Set<string>(this.channelRefs.keys());
1260
+ const topics = new Set<string>(this.topics);
1261
+ const replyId = generateId();
1262
+
1263
+ return new Promise((resolve) => {
1264
+ const unsub = this.bus.subscribe<{ channels: string[]; topics: string[] }>(
1265
+ `ws:subs:${replyId}`,
1266
+ (msg) => {
1267
+ for (const c of msg.channels) channels.add(c);
1268
+ for (const t of msg.topics) topics.add(t);
1269
+ },
1270
+ );
1271
+
1272
+ this.bus.publish('ws:gather-subs', { replyId });
1273
+
1274
+ setTimeout(() => {
1275
+ unsub();
1276
+ resolve({ channels: [...channels], topics: [...topics] });
1277
+ }, timeoutMs);
1278
+ });
1279
+ }
1280
+
786
1281
  private handleLoseLeadership(): void {
1282
+ this.stopRefreshTimer();
787
1283
  if (this.socket) {
788
1284
  this.socket[Symbol.dispose]();
789
1285
  this.socket = null;
790
1286
  }
791
1287
  }
792
1288
 
1289
+ /**
1290
+ * Start a leader-only periodic refresh of the auth token. The callback
1291
+ * is `options.refresh` (preferred) or `options.auth` (fallback). When
1292
+ * the timer fires and the connection is currently authenticated, the
1293
+ * returned token is fed back through `authenticate()` so subscribers
1294
+ * stay synced and the leader's socket re-issues auth-login.
1295
+ *
1296
+ * Idempotent — calling start while already running is a no-op.
1297
+ */
1298
+ private startRefreshTimer(): void {
1299
+ if (this.refreshTimer) return;
1300
+ const interval = this.options.refreshTokenInterval;
1301
+ const refresh = this.options.refresh ?? this.options.auth;
1302
+ if (!interval || interval <= 0 || !refresh) return;
1303
+ if (!this.coordinator.isLeader) return;
1304
+
1305
+ this.refreshTimer = setInterval(async () => {
1306
+ if (!this.coordinator.isLeader || !this._isAuthenticated) return;
1307
+ try {
1308
+ const token = await refresh();
1309
+ if (!token) {
1310
+ this.log.warn('[SharedWS] refresh() returned empty token — skipping');
1311
+ return;
1312
+ }
1313
+ this.authenticate(token);
1314
+ } catch (err) {
1315
+ this.log.warn('[SharedWS] refresh() failed', err);
1316
+ }
1317
+ }, interval);
1318
+ }
1319
+
1320
+ private stopRefreshTimer(): void {
1321
+ if (this.refreshTimer) {
1322
+ clearInterval(this.refreshTimer);
1323
+ this.refreshTimer = null;
1324
+ }
1325
+ }
1326
+
793
1327
  [Symbol.dispose](): void {
794
1328
  if (this.disposed) return;
795
1329
  this.disposed = true;
1330
+ this.stopRefreshTimer();
796
1331
 
797
1332
  this.coordinator[Symbol.dispose]();
798
1333
 
@@ -808,5 +1343,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
808
1343
  this.syncStore.clear();
809
1344
  this.authChannels.clear();
810
1345
  this.authTopics.clear();
1346
+ this.channelRefs.clear();
1347
+ this.topics.clear();
1348
+ this.rawFrameListeners.clear();
1349
+ this.pendingOutbound.clear();
811
1350
  }
812
1351
  }