@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.
- package/README.md +37 -0
- package/dist/SharedSocket.d.ts +2 -0
- package/dist/SharedWebSocket.d.ts +141 -5
- package/dist/SubscriptionManager.d.ts +1 -1
- package/dist/WorkerSocket.d.ts +2 -0
- package/dist/adapters/react.d.ts +3 -3
- package/dist/adapters/vue.d.ts +3 -3
- package/dist/{chunk-RKVYLJTQ.cjs → chunk-HIKH74NQ.cjs} +505 -69
- package/dist/chunk-HIKH74NQ.cjs.map +1 -0
- package/dist/{chunk-IK4HLA3K.js → chunk-N63ZMMWV.js} +496 -60
- package/dist/chunk-N63ZMMWV.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +8 -8
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +7 -7
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +152 -1
- package/dist/vue.cjs +8 -8
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +7 -7
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +2 -0
- package/package.json +1 -1
- package/src/MessageBus.ts +8 -1
- package/src/SharedSocket.ts +28 -3
- package/src/SharedWebSocket.ts +577 -63
- package/src/SubscriptionManager.ts +4 -4
- package/src/WorkerSocket.ts +27 -3
- package/src/adapters/react.ts +9 -9
- package/src/adapters/vue.ts +9 -9
- package/src/index.ts +3 -0
- package/src/types.ts +162 -1
- package/src/worker/socket.worker.ts +7 -0
- package/dist/chunk-IK4HLA3K.js.map +0 -1
- package/dist/chunk-RKVYLJTQ.cjs.map +0 -1
package/src/SharedWebSocket.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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<{
|
|
151
|
+
this.bus.subscribe<{ kind: FrameKind; payload: FramePayload; id?: string }>('ws:dispatch', (msg) => {
|
|
101
152
|
if (this.coordinator.isLeader && this.socket) {
|
|
102
|
-
this.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
612
|
+
this.dispatch('event', { event, data: serializedData, extras });
|
|
613
|
+
}
|
|
463
614
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
741
|
+
return self.subs.stream(key(event), signal);
|
|
540
742
|
},
|
|
541
743
|
leave(): void {
|
|
542
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
811
|
-
if (
|
|
812
|
-
this.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
}
|