@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.
@@ -16,9 +16,9 @@ export class SubscriptionManager implements Disposable {
16
16
  }
17
17
 
18
18
  once(event: string, handler: EventHandler): Unsubscribe {
19
- const wrapper: EventHandler = (data) => {
19
+ const wrapper: EventHandler = (data, raw) => {
20
20
  unsub();
21
- handler(data);
21
+ handler(data, raw);
22
22
  };
23
23
  const unsub = this.on(event, wrapper);
24
24
  return unsub;
@@ -32,11 +32,11 @@ export class SubscriptionManager implements Disposable {
32
32
  }
33
33
  }
34
34
 
35
- emit(event: string, data: unknown): void {
35
+ emit(event: string, data: unknown, raw?: unknown): void {
36
36
  this.lastMessages.set(event, data);
37
37
  const set = this.handlers.get(event);
38
38
  if (set) {
39
- for (const fn of set) fn(data);
39
+ for (const fn of set) fn(data, raw);
40
40
  }
41
41
  }
42
42
 
@@ -31,6 +31,7 @@ export class WorkerSocket implements Disposable {
31
31
  reconnect?: boolean;
32
32
  reconnectMaxDelay?: number;
33
33
  reconnectMaxRetries?: number;
34
+ authFailureCloseCodes?: number[];
34
35
  heartbeatInterval?: number;
35
36
  sendBuffer?: number;
36
37
  workerUrl?: string | URL;
@@ -45,11 +46,27 @@ export class WorkerSocket implements Disposable {
45
46
  return this._state;
46
47
  }
47
48
 
49
+ private setState(s: SocketState): void {
50
+ this._state = s;
51
+ for (const fn of this.onStateChangeFns) fn(s);
52
+ }
53
+
48
54
  async connect(): Promise<void> {
49
55
  // Resolve auth token before sending to worker (functions can't cross worker boundary)
50
56
  let authToken: string | undefined;
51
57
  if (this.options.auth) {
52
- authToken = await this.options.auth();
58
+ try {
59
+ authToken = await this.options.auth();
60
+ } catch {
61
+ // auth() threw — pause reconnect until user provides fresh creds.
62
+ this.setState('failed');
63
+ return;
64
+ }
65
+ if (!authToken) {
66
+ // Configured auth callback returned nothing — same fail-closed behavior.
67
+ this.setState('failed');
68
+ return;
69
+ }
53
70
  } else if (this.options.authToken) {
54
71
  authToken = this.options.authToken;
55
72
  }
@@ -95,6 +112,7 @@ export class WorkerSocket implements Disposable {
95
112
  reconnect: this.options.reconnect ?? true,
96
113
  reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,
97
114
  reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
115
+ authFailureCloseCodes: this.options.authFailureCloseCodes ?? [1008],
98
116
  heartbeatInterval: this.options.heartbeatInterval ?? 30_000,
99
117
  bufferSize: this.options.sendBuffer ?? 100,
100
118
  pingPayload: this.options.pingPayload,
@@ -113,6 +131,11 @@ export class WorkerSocket implements Disposable {
113
131
  this.worker?.postMessage({ type: 'send', data });
114
132
  }
115
133
 
134
+ /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
135
+ reconnect(): void {
136
+ this.worker?.postMessage({ type: 'reconnect' });
137
+ }
138
+
116
139
  disconnect(): void {
117
140
  this.worker?.postMessage({ type: 'disconnect' });
118
141
  setTimeout(() => {
@@ -140,6 +163,7 @@ export class WorkerSocket implements Disposable {
140
163
  let heartbeatTimer = null, reconnectTimer = null;
141
164
  let url = '', protocols = [], shouldReconnect = true;
142
165
  let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
166
+ let authFailCodes = new Set([1008]);
143
167
  let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
144
168
 
145
169
  function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
@@ -149,7 +173,12 @@ export class WorkerSocket implements Disposable {
149
173
  ws = new WebSocket(url, protocols);
150
174
  ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
151
175
  ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
152
- ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
176
+ ws.onclose = (e) => {
177
+ stopHB();
178
+ self.postMessage({ type: 'close', code: e.code, reason: e.reason });
179
+ if (authFailCodes.has(e.code)) { setState('failed'); return; }
180
+ if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed');
181
+ };
153
182
  ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
154
183
  }
155
184
  function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
@@ -158,16 +187,24 @@ export class WorkerSocket implements Disposable {
158
187
  function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
159
188
  function reconnect() {
160
189
  attempts++;
161
- if (attempts > maxRetries) { setState('closed'); return; }
190
+ if (attempts > maxRetries) { setState('failed'); return; }
162
191
  setState('reconnecting');
163
192
  const j = delay * 0.25 * (Math.random() * 2 - 1);
164
193
  reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
165
194
  delay = Math.min(delay * 2, maxDelay);
166
195
  }
196
+ function manualReconnect() {
197
+ if (disposed) return;
198
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
199
+ attempts = 0; delay = 1000;
200
+ if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
201
+ connect();
202
+ }
167
203
  self.onmessage = (e) => {
168
204
  const c = e.data;
169
- if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
205
+ if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; maxRetries = c.reconnectMaxRetries ?? Infinity; if (c.authFailureCloseCodes) authFailCodes = new Set(c.authFailureCloseCodes); hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; if (c.pingPayload) pingPayload = JSON.stringify(c.pingPayload); connect(); }
170
206
  if (c.type === 'send') send(c.data);
207
+ if (c.type === 'reconnect') manualReconnect();
171
208
  if (c.type === 'disconnect') { disposed = true; stopHB(); if (reconnectTimer) clearTimeout(reconnectTimer); if (ws) { ws.onclose = null; if (ws.readyState < 2) ws.close(1000); ws = null; } buffer = []; setState('closed'); }
172
209
  };
173
210
  `;
@@ -149,13 +149,13 @@ export function useSocketAuth(): {
149
149
  * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50
150
150
  * });
151
151
  */
152
- export function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined {
152
+ export function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): T | undefined {
153
153
  const socket = useSharedWebSocket();
154
154
  const [value, setValue] = useState<T | undefined>(undefined);
155
155
 
156
- const onEvent = useEffectEvent((data: T) => {
156
+ const onEvent = useEffectEvent((data: T, raw?: unknown) => {
157
157
  if (callback) {
158
- callback(data);
158
+ callback(data, raw);
159
159
  } else {
160
160
  setValue(data);
161
161
  }
@@ -192,13 +192,13 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
192
192
  * if (entry.level === 'error') setErrors(prev => [...prev, entry]);
193
193
  * });
194
194
  */
195
- export function useSocketStream<T>(event: string, callback?: (data: T) => void): T[] {
195
+ export function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): T[] {
196
196
  const socket = useSharedWebSocket();
197
197
  const [items, setItems] = useState<T[]>([]);
198
198
 
199
- const onEvent = useEffectEvent((data: T) => {
199
+ const onEvent = useEffectEvent((data: T, raw?: unknown) => {
200
200
  if (callback) {
201
- callback(data);
201
+ callback(data, raw);
202
202
  } else {
203
203
  setItems((prev) => [...prev, data]);
204
204
  }
@@ -276,11 +276,11 @@ export function useSocketSync<T>(
276
276
  * }
277
277
  * });
278
278
  */
279
- export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
279
+ export function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void {
280
280
  const socket = useSharedWebSocket();
281
281
 
282
- const handler = useEffectEvent((data: T) => {
283
- callback(data);
282
+ const handler = useEffectEvent((data: T, raw?: unknown) => {
283
+ callback(data, raw);
284
284
  });
285
285
 
286
286
  useEffect(() => {
@@ -338,6 +338,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
338
338
  const onConnect = useEffectEvent(() => handlers.onConnect?.());
339
339
  const onDisconnect = useEffectEvent(() => handlers.onDisconnect?.());
340
340
  const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
341
+ const onReconnectFailed = useEffectEvent(() => handlers.onReconnectFailed?.());
341
342
  const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
342
343
  const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
343
344
  const onActive = useEffectEvent(() => handlers.onActive?.());
@@ -350,6 +351,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
350
351
  socket.onConnect(onConnect),
351
352
  socket.onDisconnect(onDisconnect),
352
353
  socket.onReconnecting(onReconnecting),
354
+ socket.onReconnectFailed(onReconnectFailed),
353
355
  socket.onLeaderChange(onLeaderChange),
354
356
  socket.onError(onError),
355
357
  socket.onActive(onActive),
@@ -361,6 +363,51 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
361
363
  }, [socket]);
362
364
  }
363
365
 
366
+ /**
367
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
368
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
369
+ *
370
+ * `hasFailed` is `true` after `reconnectMaxRetries` are exhausted. It resets
371
+ * to `false` once the connection succeeds again or the user calls `reconnect()`.
372
+ *
373
+ * @example
374
+ * function ConnectionBanner() {
375
+ * const { hasFailed, reconnect } = useSocketReconnect();
376
+ * if (!hasFailed) return null;
377
+ * return (
378
+ * <div className="snackbar">
379
+ * Connection lost.
380
+ * <button onClick={reconnect}>Reconnect</button>
381
+ * </div>
382
+ * );
383
+ * }
384
+ */
385
+ export function useSocketReconnect(): {
386
+ hasFailed: boolean;
387
+ reconnect: () => void;
388
+ } {
389
+ const socket = useSharedWebSocket();
390
+ const [hasFailed, setHasFailed] = useState(false);
391
+
392
+ const onFailed = useEffectEvent(() => setHasFailed(true));
393
+ const onConnected = useEffectEvent(() => setHasFailed(false));
394
+
395
+ useEffect(() => {
396
+ const unsubs = [
397
+ socket.onReconnectFailed(onFailed),
398
+ socket.onConnect(onConnected),
399
+ ];
400
+ return () => unsubs.forEach((u) => u());
401
+ }, [socket]);
402
+
403
+ const reconnect = useEffectEvent(() => {
404
+ setHasFailed(false);
405
+ socket.reconnect();
406
+ });
407
+
408
+ return { hasFailed, reconnect };
409
+ }
410
+
364
411
  /**
365
412
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
366
413
  *
@@ -110,14 +110,14 @@ export function useSocketAuth(): {
110
110
  * analytics.track('order_received', order);
111
111
  * });
112
112
  */
113
- export function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {
113
+ export function useSocketEvent<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T | undefined> {
114
114
  const socket = useSharedWebSocket();
115
115
  const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
116
116
 
117
- const handler = (data: unknown) => {
117
+ const handler = (data: unknown, raw?: unknown) => {
118
118
  const typed = data as T;
119
119
  if (callback) {
120
- callback(typed);
120
+ callback(typed, raw);
121
121
  } else {
122
122
  value.value = typed;
123
123
  }
@@ -151,14 +151,14 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
151
151
  * if (entry.level === 'error') errors.value = [...errors.value, entry];
152
152
  * });
153
153
  */
154
- export function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {
154
+ export function useSocketStream<T>(event: string, callback?: (data: T, raw?: unknown) => void): Ref<T[]> {
155
155
  const socket = useSharedWebSocket();
156
156
  const items = ref<T[]>([]) as Ref<T[]>;
157
157
 
158
- const handler = (data: unknown) => {
158
+ const handler = (data: unknown, raw?: unknown) => {
159
159
  const typed = data as T;
160
160
  if (callback) {
161
- callback(typed);
161
+ callback(typed, raw);
162
162
  } else {
163
163
  items.value = [...items.value, typed];
164
164
  }
@@ -215,11 +215,11 @@ export function useSocketSync<T>(key: string, initialValue: T, callback?: (value
215
215
  * showToast(n.title);
216
216
  * });
217
217
  */
218
- export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
218
+ export function useSocketCallback<T>(event: string, callback: (data: T, raw?: unknown) => void): void {
219
219
  const socket = useSharedWebSocket();
220
220
 
221
- const unsub = socket.on(event, (data: unknown) => {
222
- callback(data as T);
221
+ const unsub = socket.on(event, (data: unknown, raw?: unknown) => {
222
+ callback(data as T, raw);
223
223
  });
224
224
 
225
225
  onUnmounted(unsub);
@@ -275,6 +275,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
275
275
  if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));
276
276
  if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));
277
277
  if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
278
+ if (handlers.onReconnectFailed) unsubs.push(socket.onReconnectFailed(handlers.onReconnectFailed));
278
279
  if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
279
280
  if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
280
281
  if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
@@ -285,6 +286,52 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
285
286
  onUnmounted(() => unsubs.forEach((u) => u()));
286
287
  }
287
288
 
289
+ /**
290
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
291
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
292
+ *
293
+ * `hasFailed` flips to `true` once `reconnectMaxRetries` are exhausted, and
294
+ * back to `false` once the connection succeeds or the user calls `reconnect()`.
295
+ *
296
+ * @example
297
+ * <script setup>
298
+ * const { hasFailed, reconnect } = useSocketReconnect();
299
+ * </script>
300
+ *
301
+ * <template>
302
+ * <div v-if="hasFailed" class="snackbar">
303
+ * Connection lost.
304
+ * <button @click="reconnect">Reconnect</button>
305
+ * </div>
306
+ * </template>
307
+ */
308
+ export function useSocketReconnect(): {
309
+ hasFailed: Ref<boolean>;
310
+ reconnect: () => void;
311
+ } {
312
+ const socket = useSharedWebSocket();
313
+ const hasFailed = ref(false);
314
+
315
+ const unsubs = [
316
+ socket.onReconnectFailed(() => {
317
+ hasFailed.value = true;
318
+ }),
319
+ socket.onConnect(() => {
320
+ hasFailed.value = false;
321
+ }),
322
+ ];
323
+
324
+ onUnmounted(() => unsubs.forEach((u) => u()));
325
+
326
+ return {
327
+ hasFailed: readonly(hasFailed) as Ref<boolean>,
328
+ reconnect: () => {
329
+ hasFailed.value = false;
330
+ socket.reconnect();
331
+ },
332
+ };
333
+ }
334
+
288
335
  /**
289
336
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
290
337
  *
package/src/index.ts CHANGED
@@ -29,4 +29,7 @@ export type {
29
29
  Middleware,
30
30
  PushNotificationOptions,
31
31
  Codec,
32
+ FrameKind,
33
+ FramePayload,
34
+ ChannelAckResult,
32
35
  } from './types';
package/src/types.ts CHANGED
@@ -1,7 +1,12 @@
1
- export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
1
+ export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed';
2
2
  export type TabRole = 'leader' | 'follower';
3
3
  export type Unsubscribe = () => void;
4
- export type EventHandler<T = unknown> = (data: T) => void;
4
+ /**
5
+ * Event handler. Receives the extracted `data` (per `dataField`) plus the
6
+ * full raw envelope as a second argument. Use `raw` to access top-level
7
+ * fields outside `dataField` (e.g. `id`, `kind`, `channel`, `type`).
8
+ */
9
+ export type EventHandler<T = unknown> = (data: T, raw?: unknown) => void;
5
10
 
6
11
  /** Type-safe event map. Keys are event names, values are payload types. */
7
12
  export type EventMap = Record<string, unknown>;
@@ -48,12 +53,61 @@ export interface Logger {
48
53
  /** Middleware function — transform or inspect messages. Return null to drop. */
49
54
  export type Middleware<T = unknown> = (message: T) => T | null;
50
55
 
56
+ /**
57
+ * Kinds of frames the library emits. Lets `EventProtocol.frameBuilder`
58
+ * take full control over the wire shape per kind — e.g. produce flat
59
+ * `{ type, channel, event, data }` envelopes for Pusher/Reverb/custom
60
+ * servers instead of the default `{ event, data }` two-key wrapper.
61
+ */
62
+ export type FrameKind =
63
+ | 'event' // user payload via ws.send / Channel.send
64
+ | 'subscribe' // channel join
65
+ | 'unsubscribe' // channel leave
66
+ | 'topic-subscribe' // topic subscribe
67
+ | 'topic-unsubscribe' // topic unsubscribe
68
+ | 'auth-login' // authenticate(token)
69
+ | 'auth-logout'; // deauthenticate()
70
+
71
+ /**
72
+ * Structured payload passed to `frameBuilder`. Fields are populated
73
+ * based on `kind`:
74
+ *
75
+ * - `event` → `{ event, data, channel?, extras? }`
76
+ * `channel` is set when sent via `Channel.send`.
77
+ * - `subscribe`/`unsubscribe` → `{ channel, extras? }`
78
+ * - `topic-subscribe`/`topic-unsubscribe` → `{ topic, extras? }`
79
+ * - `auth-login` → `{ data: token, extras? }` (`data` is the raw token)
80
+ * - `auth-logout` → `{ extras? }`
81
+ */
82
+ export interface FramePayload {
83
+ /** Channel name. Set for subscribe/unsubscribe and channel-scoped events. */
84
+ channel?: string;
85
+ /** Topic name. Set for topic-subscribe/topic-unsubscribe. */
86
+ topic?: string;
87
+ /** Bare event name (without channel prefix). Set for `kind: 'event'`. */
88
+ event?: string;
89
+ /** Payload — user data, auth token, etc. */
90
+ data?: unknown;
91
+ /** Extra top-level fields to merge into the wire envelope. */
92
+ extras?: Record<string, unknown>;
93
+ }
94
+
51
95
  export interface SharedWebSocketOptions<TEvents extends EventMap = EventMap> {
52
96
  protocols?: string[];
53
97
  reconnect?: boolean;
54
98
  reconnectMaxDelay?: number;
55
99
  /** Max reconnect attempts before giving up (default: Infinity — retry forever). */
56
100
  reconnectMaxRetries?: number;
101
+ /**
102
+ * WebSocket close codes that indicate "auth failed — don't retry."
103
+ * On these codes the library sets state to 'failed' and stops auto-reconnect
104
+ * instead of looping with the same expired credentials. Default: `[1008]`
105
+ * (PolicyViolation). Add 4xxx app-specific codes if your server uses them.
106
+ *
107
+ * To recover, call `ws.authenticate(newToken)` (auto-reconnects when
108
+ * the local tab is the leader) or `ws.reconnect()` directly.
109
+ */
110
+ authFailureCloseCodes?: number[];
57
111
  heartbeatInterval?: number;
58
112
  electionTimeout?: number;
59
113
  leaderHeartbeat?: number;
@@ -65,6 +119,42 @@ export interface SharedWebSocketOptions<TEvents extends EventMap = EventMap> {
65
119
  authToken?: string;
66
120
  /** Query parameter name for the token (default: "token"). */
67
121
  authParam?: string;
122
+ /**
123
+ * Optional. Periodic token refresh — runs on the leader tab only via
124
+ * `setInterval(refresh, refreshTokenInterval)`. When the timer fires
125
+ * and the connection is currently authenticated, the returned token
126
+ * is passed to `authenticate()` so the server sees the new credentials
127
+ * before the old one expires. Falls back to `auth` if unset.
128
+ *
129
+ * Use this for long-running tabs where the server would otherwise
130
+ * close with an auth-failure code mid-session. Pair with a sensible
131
+ * interval — typically ~80% of your token TTL (e.g. 48 minutes for a
132
+ * 60-minute token).
133
+ *
134
+ * If the callback throws, the failure is logged at `warn` and the
135
+ * timer keeps running for the next interval; the server will still
136
+ * close on its own when the token expires, at which point
137
+ * `authFailureCloseCodes` and `ws.authenticate(...)` handle recovery.
138
+ */
139
+ refresh?: () => string | Promise<string>;
140
+ /**
141
+ * Refresh interval in milliseconds. Disabled when unset or `<= 0`.
142
+ * No default — opt-in.
143
+ */
144
+ refreshTokenInterval?: number;
145
+ /**
146
+ * Max number of follower-routed dispatches each tab buffers locally for
147
+ * replay across leader handover. When the leader dies between receiving
148
+ * a follower's dispatch and writing it to the socket, the new leader
149
+ * gathers pending entries from all tabs and replays them. Cap protects
150
+ * memory; oldest entries are dropped on overflow. Set to `0` to disable
151
+ * the buffer entirely. Default: 100.
152
+ *
153
+ * Note: the replay is at-least-once — a leader that dies AFTER socket
154
+ * write but BEFORE broadcasting "flushed" will cause a duplicate. Make
155
+ * server-side handlers idempotent if duplicates would matter.
156
+ */
157
+ outboundBufferSize?: number;
68
158
  /** Run WebSocket inside a Web Worker (offloads JSON parsing, heartbeat from main thread). */
69
159
  useWorker?: boolean;
70
160
  /** Custom worker URL (if useWorker is true and you want to provide your own worker file). */
@@ -117,6 +207,56 @@ export interface EventProtocol {
117
207
  authLogout: string;
118
208
  /** Event name server sends to revoke auth (default: "$auth:revoked"). */
119
209
  authRevoked: string;
210
+ /**
211
+ * Optional. Takes full control of outgoing frame shape per `FrameKind`.
212
+ * If unset, the default builder reproduces the legacy two-key envelope:
213
+ * `{ ...extras, [eventField]: <event-name>, [dataField]: <data> }`
214
+ * using `channelJoin` / `channelLeave` / `topicSubscribe` / etc. for
215
+ * control-frame event names.
216
+ *
217
+ * Return-value contract:
218
+ * - any concrete value → use as the wire frame
219
+ * - `null` → drop the frame (intentional filter / no-op)
220
+ * - `undefined` → fall back to the library default for this kind
221
+ *
222
+ * @example Flat envelope (Pusher / Reverb / custom Go server)
223
+ * frameBuilder: (kind, p) => {
224
+ * switch (kind) {
225
+ * case 'subscribe': return { type: 'subscribe', channel: p.channel };
226
+ * case 'unsubscribe': return { type: 'unsubscribe', channel: p.channel };
227
+ * case 'auth-login': return { type: 'auth', token: p.data };
228
+ * case 'auth-logout': return { type: 'logout' };
229
+ * case 'event':
230
+ * return p.channel
231
+ * ? { type: 'event', channel: p.channel, event: p.event, data: p.data, ...p.extras }
232
+ * : { type: 'event', event: p.event, data: p.data, ...p.extras };
233
+ * default: return undefined; // unknown kind → library default
234
+ * }
235
+ * }
236
+ */
237
+ frameBuilder?: (kind: FrameKind, payload: FramePayload) => unknown;
238
+ /**
239
+ * Optional. If provided, `channel(name).ready` waits for an incoming
240
+ * frame this matcher classifies as `'ok'` before resolving. Returns:
241
+ * - `'ok'` → resolve.
242
+ * - `'reject'` → reject with a "subscribe rejected" error.
243
+ * - `'pending'` → keep watching subsequent frames.
244
+ *
245
+ * Without a matcher, `Channel.ready` resolves immediately after the
246
+ * subscribe frame is dispatched — appropriate for fire-and-forget
247
+ * servers that don't send subscribe acks.
248
+ *
249
+ * @example Phoenix `phx_reply`
250
+ * channelAckMatcher: (frame, channel) => {
251
+ * const f = frame as { topic: string; event: string; payload: { status: 'ok' | 'error' } };
252
+ * if (f.topic !== channel) return 'pending';
253
+ * if (f.event !== 'phx_reply') return 'pending';
254
+ * return f.payload.status === 'ok' ? 'ok' : 'reject';
255
+ * }
256
+ */
257
+ channelAckMatcher?: (frame: unknown, channel: string) => ChannelAckResult;
258
+ /** Timeout in ms for `Channel.ready` when `channelAckMatcher` is set. Default: 5000. */
259
+ channelAckTimeout?: number;
120
260
  }
121
261
 
122
262
  /** Push notification options. */
@@ -136,6 +276,8 @@ export interface SocketLifecycleHandlers {
136
276
  onConnect?: () => void;
137
277
  onDisconnect?: () => void;
138
278
  onReconnecting?: () => void;
279
+ /** Called when auto-reconnect gives up after exhausting reconnectMaxRetries. */
280
+ onReconnectFailed?: () => void;
139
281
  onLeaderChange?: (isLeader: boolean) => void;
140
282
  onError?: (error: unknown) => void;
141
283
  /** Called when this tab becomes visible/focused. */
@@ -148,9 +290,30 @@ export interface SocketLifecycleHandlers {
148
290
  onAuthChange?: (authenticated: boolean) => void;
149
291
  }
150
292
 
293
+ /**
294
+ * Result returned by `EventProtocol.channelAckMatcher` for each
295
+ * incoming frame while a `Channel` is awaiting its subscribe ack.
296
+ *
297
+ * - `'ok'` → resolve `Channel.ready`, stop watching.
298
+ * - `'reject'` → reject `Channel.ready` with a "subscribe rejected"
299
+ * error, stop watching.
300
+ * - `'pending'` → keep watching for subsequent frames.
301
+ */
302
+ export type ChannelAckResult = 'ok' | 'reject' | 'pending';
303
+
151
304
  /** Scoped channel handle for private/topic-based subscriptions. */
152
305
  export interface Channel {
153
306
  readonly name: string;
307
+ /**
308
+ * Resolves once the server has accepted the subscription. By default
309
+ * (no `channelAckMatcher` configured) this resolves immediately after
310
+ * the subscribe frame is dispatched — fire-and-forget servers don't
311
+ * send acks. Configure `EventProtocol.channelAckMatcher` to wait for
312
+ * a real ack frame; the promise then rejects on a matched
313
+ * "rejected" frame, on `channelAckTimeout`, or if `.leave()` is
314
+ * called before the ack arrives.
315
+ */
316
+ readonly ready: Promise<void>;
154
317
  on(event: string, handler: EventHandler): Unsubscribe;
155
318
  once(event: string, handler: EventHandler): Unsubscribe;
156
319
  send(event: string, data: unknown): void;