@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.
- package/README.md +39 -1
- package/dist/SharedSocket.d.ts +10 -1
- package/dist/SharedWebSocket.d.ts +154 -5
- package/dist/SubscriptionManager.d.ts +1 -1
- package/dist/WorkerSocket.d.ts +4 -0
- package/dist/adapters/react.d.ts +26 -3
- package/dist/adapters/vue.d.ts +26 -3
- package/dist/{chunk-SQZHBLWT.js → chunk-OQMJRH6C.js} +545 -61
- package/dist/chunk-OQMJRH6C.js.map +1 -0
- package/dist/{chunk-OVKB2KLE.cjs → chunk-YZLE4TZB.cjs} +561 -77
- package/dist/chunk-YZLE4TZB.cjs.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 +36 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +28 -7
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +155 -2
- package/dist/vue.cjs +31 -9
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +29 -7
- package/dist/vue.js.map +1 -1
- package/dist/worker/socket.worker.d.ts +8 -2
- package/package.json +1 -1
- package/src/SharedSocket.ts +55 -6
- package/src/SharedWebSocket.ts +601 -62
- package/src/SubscriptionManager.ts +4 -4
- package/src/WorkerSocket.ts +41 -4
- package/src/adapters/react.ts +56 -9
- package/src/adapters/vue.ts +56 -9
- package/src/index.ts +3 -0
- package/src/types.ts +165 -2
- package/src/worker/socket.worker.ts +44 -2
- package/dist/chunk-OVKB2KLE.cjs.map +0 -1
- package/dist/chunk-SQZHBLWT.js.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,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
|
-
|
|
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
|
|
182
|
+
// Leader listens for reconnect requests from followers
|
|
98
183
|
this.cleanups.push(
|
|
99
|
-
this.bus.subscribe<
|
|
184
|
+
this.bus.subscribe<void>('ws:reconnect', () => {
|
|
100
185
|
if (this.coordinator.isLeader && this.socket) {
|
|
101
|
-
this.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
612
|
+
this.dispatch('event', { event, data: serializedData, extras });
|
|
613
|
+
}
|
|
417
614
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
741
|
+
return self.subs.stream(key(event), signal);
|
|
494
742
|
},
|
|
495
743
|
leave(): void {
|
|
496
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
if (
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
this.
|
|
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
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
}
|