@gwakko/shared-websocket 0.12.2 → 0.13.0

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.
Files changed (56) hide show
  1. package/README.md +2 -1
  2. package/dist/SharedSocket.d.ts +8 -1
  3. package/dist/SharedWebSocket.d.ts +22 -0
  4. package/dist/TabSync.d.ts +14 -2
  5. package/dist/WorkerSocket.d.ts +9 -1
  6. package/dist/adapters/react.d.ts +23 -0
  7. package/dist/adapters/sync-react.d.ts +3 -1
  8. package/dist/adapters/sync-vue.d.ts +3 -1
  9. package/dist/adapters/vue.d.ts +23 -0
  10. package/dist/{chunk-RM27CYKT.js → chunk-7WBM2C7H.js} +15 -2
  11. package/dist/chunk-7WBM2C7H.js.map +1 -0
  12. package/dist/{chunk-FZIIMO67.js → chunk-IK4HLA3K.js} +119 -14
  13. package/dist/chunk-IK4HLA3K.js.map +1 -0
  14. package/dist/{chunk-ET3YHQ7V.cjs → chunk-RJKAFACH.cjs} +16 -3
  15. package/dist/chunk-RJKAFACH.cjs.map +1 -0
  16. package/dist/{chunk-ADGLL3J2.cjs → chunk-RKVYLJTQ.cjs} +133 -28
  17. package/dist/chunk-RKVYLJTQ.cjs.map +1 -0
  18. package/dist/index.cjs +4 -4
  19. package/dist/index.js +2 -2
  20. package/dist/react.cjs +31 -13
  21. package/dist/react.cjs.map +1 -1
  22. package/dist/react.js +24 -6
  23. package/dist/react.js.map +1 -1
  24. package/dist/sync-react.cjs +3 -3
  25. package/dist/sync-react.cjs.map +1 -1
  26. package/dist/sync-react.js +3 -3
  27. package/dist/sync-react.js.map +1 -1
  28. package/dist/sync-vue.cjs +3 -3
  29. package/dist/sync-vue.cjs.map +1 -1
  30. package/dist/sync-vue.js +3 -3
  31. package/dist/sync-vue.js.map +1 -1
  32. package/dist/sync.cjs +2 -2
  33. package/dist/sync.d.ts +1 -1
  34. package/dist/sync.js +1 -1
  35. package/dist/types.d.ts +3 -1
  36. package/dist/vue.cjs +26 -4
  37. package/dist/vue.cjs.map +1 -1
  38. package/dist/vue.js +24 -2
  39. package/dist/vue.js.map +1 -1
  40. package/dist/worker/socket.worker.d.ts +6 -2
  41. package/package.json +1 -1
  42. package/src/SharedSocket.ts +27 -3
  43. package/src/SharedWebSocket.ts +56 -3
  44. package/src/TabSync.ts +23 -2
  45. package/src/WorkerSocket.ts +54 -7
  46. package/src/adapters/react.ts +49 -5
  47. package/src/adapters/sync-react.ts +4 -2
  48. package/src/adapters/sync-vue.ts +2 -2
  49. package/src/adapters/vue.ts +48 -1
  50. package/src/sync.ts +1 -1
  51. package/src/types.ts +3 -1
  52. package/src/worker/socket.worker.ts +37 -2
  53. package/dist/chunk-ADGLL3J2.cjs.map +0 -1
  54. package/dist/chunk-ET3YHQ7V.cjs.map +0 -1
  55. package/dist/chunk-FZIIMO67.js.map +0 -1
  56. package/dist/chunk-RM27CYKT.js.map +0 -1
@@ -31,8 +31,9 @@ const NOOP_LOGGER: Logger = {
31
31
  /** Common interface for both SharedSocket and WorkerSocket. */
32
32
  interface SocketAdapter {
33
33
  readonly state: string;
34
- connect(): void;
34
+ connect(): void | Promise<void>;
35
35
  send(data: unknown): void;
36
+ reconnect(): void;
36
37
  disconnect(): void;
37
38
  onMessage(fn: EventHandler): Unsubscribe;
38
39
  onStateChange(fn: (state: string) => void): Unsubscribe;
@@ -103,6 +104,16 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
103
104
  }),
104
105
  );
105
106
 
107
+ // Leader listens for reconnect requests from followers
108
+ this.cleanups.push(
109
+ this.bus.subscribe<void>('ws:reconnect', () => {
110
+ if (this.coordinator.isLeader && this.socket) {
111
+ this.log.info('[SharedWS] manual reconnect requested by follower');
112
+ this.socket.reconnect();
113
+ }
114
+ }),
115
+ );
116
+
106
117
  // Sync across tabs
107
118
  this.cleanups.push(
108
119
  this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {
@@ -134,6 +145,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
134
145
  case 'reconnecting':
135
146
  this.subs.emit('$lifecycle:reconnecting', undefined);
136
147
  break;
148
+ case 'reconnectFailed':
149
+ this.subs.emit('$lifecycle:reconnectFailed', undefined);
150
+ break;
137
151
  case 'leader':
138
152
  this.subs.emit('$lifecycle:leader', msg.isLeader);
139
153
  break;
@@ -228,6 +242,38 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
228
242
  return this.subs.on('$lifecycle:reconnecting', fn);
229
243
  }
230
244
 
245
+ /**
246
+ * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
247
+ * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
248
+ * so the user can call `ws.reconnect()` to try again.
249
+ *
250
+ * @example
251
+ * ws.onReconnectFailed(() => {
252
+ * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
253
+ * });
254
+ */
255
+ onReconnectFailed(fn: () => void): Unsubscribe {
256
+ return this.subs.on('$lifecycle:reconnectFailed', fn);
257
+ }
258
+
259
+ /**
260
+ * Manually trigger a reconnect. Resets the retry counter and attempts a
261
+ * fresh connection. Safe to call from any tab — the leader actually owns
262
+ * the socket, followers route the request via BroadcastChannel.
263
+ *
264
+ * Use after `onReconnectFailed` fires to let the user retry.
265
+ *
266
+ * @example
267
+ * snackbar.action('Reconnect', () => ws.reconnect());
268
+ */
269
+ reconnect(): void {
270
+ if (this.coordinator.isLeader && this.socket) {
271
+ this.socket.reconnect();
272
+ } else {
273
+ this.bus.publish('ws:reconnect', undefined);
274
+ }
275
+ }
276
+
231
277
  /** Called when this tab becomes leader or loses leadership. */
232
278
  onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {
233
279
  return this.subs.on('$lifecycle:leader', fn as EventHandler);
@@ -675,6 +721,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
675
721
  return new WorkerSocket(this.url, {
676
722
  ...socketOptions,
677
723
  workerUrl: this.options.workerUrl,
724
+ auth: this.options.auth,
725
+ authToken: this.options.authToken,
726
+ authParam: this.options.authParam,
678
727
  });
679
728
  }
680
729
 
@@ -718,7 +767,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
718
767
  });
719
768
 
720
769
  this.socket.onStateChange((state: string) => {
721
- this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);
770
+ this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : state === 'failed' ? '✗ reconnect failed' : `state: ${state}`);
722
771
  switch (state) {
723
772
  case 'connected':
724
773
  this.bus.broadcast('ws:lifecycle', { type: 'connect' });
@@ -730,6 +779,10 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
730
779
  case 'reconnecting':
731
780
  this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });
732
781
  break;
782
+ case 'failed':
783
+ this.bus.broadcast('ws:lifecycle', { type: 'reconnectFailed' });
784
+ this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
785
+ break;
733
786
  }
734
787
  });
735
788
 
@@ -748,7 +801,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
748
801
  }),
749
802
  );
750
803
 
751
- this.socket.connect();
804
+ void this.socket.connect();
752
805
  }
753
806
 
754
807
  private reAuthenticateOnReconnect(): void {
package/src/TabSync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import './utils/disposable';
2
- import type { Unsubscribe } from './types';
2
+ import type { Unsubscribe, Logger } from './types';
3
3
 
4
4
  interface SyncMessage {
5
5
  key: string;
@@ -7,6 +7,15 @@ interface SyncMessage {
7
7
  deleted?: boolean;
8
8
  }
9
9
 
10
+ interface TabSyncOptions {
11
+ /** Enable debug logging (default: false). */
12
+ debug?: boolean;
13
+ /** Custom logger (default: console). */
14
+ logger?: Logger;
15
+ }
16
+
17
+ const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
18
+
10
19
  /**
11
20
  * Cross-tab state synchronization via BroadcastChannel.
12
21
  * No WebSocket needed — works standalone for sharing state between browser tabs.
@@ -15,21 +24,30 @@ interface SyncMessage {
15
24
  * const sync = new TabSync('my-app');
16
25
  * sync.set('theme', 'dark');
17
26
  * sync.on('theme', (theme) => applyTheme(theme));
27
+ *
28
+ * @example
29
+ * // With debug logging
30
+ * const sync = new TabSync('my-app', { debug: true });
18
31
  */
19
32
  export class TabSync implements Disposable {
20
33
  private store = new Map<string, unknown>();
21
34
  private listeners = new Map<string, Set<(value: unknown) => void>>();
22
35
  private bc: BroadcastChannel;
23
36
  private disposed = false;
37
+ private readonly log: Logger;
24
38
 
25
- constructor(channel = 'tab-sync') {
39
+ constructor(channel = 'tab-sync', options?: TabSyncOptions) {
40
+ this.log = options?.debug ? (options.logger ?? console) : NOOP_LOGGER;
41
+ this.log.debug('[TabSync] init', { channel });
26
42
  this.bc = new BroadcastChannel(channel);
27
43
  this.bc.onmessage = (ev: MessageEvent<SyncMessage>) => {
28
44
  const { key, value, deleted } = ev.data;
29
45
  if (deleted) {
30
46
  this.store.delete(key);
47
+ this.log.debug('[TabSync] ← remote delete', key);
31
48
  } else {
32
49
  this.store.set(key, value);
50
+ this.log.debug('[TabSync] ← remote set', key, value);
33
51
  }
34
52
  this.emit(key, value);
35
53
  };
@@ -39,6 +57,7 @@ export class TabSync implements Disposable {
39
57
  set<T>(key: string, value: T): void {
40
58
  this.store.set(key, value);
41
59
  this.bc.postMessage({ key, value } satisfies SyncMessage);
60
+ this.log.debug('[TabSync] → set', key, value);
42
61
  this.emit(key, value);
43
62
  }
44
63
 
@@ -51,6 +70,7 @@ export class TabSync implements Disposable {
51
70
  delete(key: string): void {
52
71
  this.store.delete(key);
53
72
  this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
73
+ this.log.debug('[TabSync] → delete', key);
54
74
  this.emit(key, undefined);
55
75
  }
56
76
 
@@ -98,6 +118,7 @@ export class TabSync implements Disposable {
98
118
  /** Clear all keys and notify listeners. */
99
119
  clear(): void {
100
120
  const keys = [...this.store.keys()];
121
+ this.log.debug('[TabSync] → clear', keys);
101
122
  this.store.clear();
102
123
  for (const key of keys) {
103
124
  this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
@@ -30,9 +30,14 @@ export class WorkerSocket implements Disposable {
30
30
  protocols?: string[];
31
31
  reconnect?: boolean;
32
32
  reconnectMaxDelay?: number;
33
+ reconnectMaxRetries?: number;
33
34
  heartbeatInterval?: number;
34
35
  sendBuffer?: number;
35
36
  workerUrl?: string | URL;
37
+ auth?: () => string | Promise<string>;
38
+ authToken?: string;
39
+ authParam?: string;
40
+ pingPayload?: unknown;
36
41
  } = {},
37
42
  ) {}
38
43
 
@@ -40,7 +45,18 @@ export class WorkerSocket implements Disposable {
40
45
  return this._state;
41
46
  }
42
47
 
43
- connect(): void {
48
+ async connect(): Promise<void> {
49
+ // Resolve auth token before sending to worker (functions can't cross worker boundary)
50
+ let authToken: string | undefined;
51
+ if (this.options.auth) {
52
+ authToken = await this.options.auth();
53
+ } else if (this.options.authToken) {
54
+ authToken = this.options.authToken;
55
+ }
56
+
57
+ // Build URL with auth token
58
+ const connectUrl = authToken ? this.buildUrl(authToken) : this.url;
59
+
44
60
  // Create worker from inline blob if no workerUrl provided
45
61
  const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();
46
62
 
@@ -74,19 +90,34 @@ export class WorkerSocket implements Disposable {
74
90
 
75
91
  this.worker.postMessage({
76
92
  type: 'connect',
77
- url: this.url,
93
+ url: connectUrl,
78
94
  protocols: this.options.protocols ?? [],
79
95
  reconnect: this.options.reconnect ?? true,
80
96
  reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,
97
+ reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
81
98
  heartbeatInterval: this.options.heartbeatInterval ?? 30_000,
82
99
  bufferSize: this.options.sendBuffer ?? 100,
100
+ pingPayload: this.options.pingPayload,
83
101
  });
84
102
  }
85
103
 
104
+ private buildUrl(token: string): string {
105
+ const param = this.options.authParam ?? 'token';
106
+ const httpUrl = this.url.replace(/^ws(s?):\/\//, 'http$1://');
107
+ const parsed = new URL(httpUrl);
108
+ parsed.searchParams.set(param, token);
109
+ return parsed.toString().replace(/^http(s?):\/\//, 'ws$1://');
110
+ }
111
+
86
112
  send(data: unknown): void {
87
113
  this.worker?.postMessage({ type: 'send', data });
88
114
  }
89
115
 
116
+ /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
117
+ reconnect(): void {
118
+ this.worker?.postMessage({ type: 'reconnect' });
119
+ }
120
+
90
121
  disconnect(): void {
91
122
  this.worker?.postMessage({ type: 'disconnect' });
92
123
  setTimeout(() => {
@@ -113,27 +144,43 @@ export class WorkerSocket implements Disposable {
113
144
  let ws = null, state = 'closed', buffer = [], disposed = false;
114
145
  let heartbeatTimer = null, reconnectTimer = null;
115
146
  let url = '', protocols = [], shouldReconnect = true;
116
- let maxDelay = 30000, hbInterval = 30000, maxBuf = 100, delay = 1000;
147
+ let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
148
+ let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
117
149
 
118
150
  function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
119
151
  function connect() {
120
152
  if (disposed) return;
121
153
  setState('connecting');
122
154
  ws = new WebSocket(url, protocols);
123
- ws.onopen = () => { setState('connected'); delay = 1000; self.postMessage({ type: 'open' }); flush(); startHB(); };
155
+ ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
124
156
  ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
125
157
  ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
126
158
  ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
127
159
  }
128
160
  function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
129
161
  function flush() { const p = buffer.splice(0); p.forEach(send); }
130
- function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send('{"type":"ping"}'); }, hbInterval); }
162
+ function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(pingPayload); }, hbInterval); }
131
163
  function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
132
- function reconnect() { setState('reconnecting'); const j = delay * 0.25 * (Math.random() * 2 - 1); reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay)); delay = Math.min(delay * 2, maxDelay); }
164
+ function reconnect() {
165
+ attempts++;
166
+ if (attempts > maxRetries) { setState('failed'); return; }
167
+ setState('reconnecting');
168
+ const j = delay * 0.25 * (Math.random() * 2 - 1);
169
+ reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
170
+ delay = Math.min(delay * 2, maxDelay);
171
+ }
172
+ function manualReconnect() {
173
+ if (disposed) return;
174
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
175
+ attempts = 0; delay = 1000;
176
+ if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
177
+ connect();
178
+ }
133
179
  self.onmessage = (e) => {
134
180
  const c = e.data;
135
- if (c.type === 'connect') { url = c.url; protocols = c.protocols || []; shouldReconnect = c.reconnect ?? true; maxDelay = c.reconnectMaxDelay || 30000; hbInterval = c.heartbeatInterval || 30000; maxBuf = c.bufferSize || 100; connect(); }
181
+ 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(); }
136
182
  if (c.type === 'send') send(c.data);
183
+ if (c.type === 'reconnect') manualReconnect();
137
184
  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'); }
138
185
  };
139
186
  `;
@@ -48,13 +48,10 @@ export interface SharedWebSocketProviderProps {
48
48
  * }
49
49
  */
50
50
  export function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {
51
- const [socket] = useState(() => {
52
- const ws = new SharedWebSocket(url, options);
53
- ws.connect();
54
- return ws;
55
- });
51
+ const [socket] = useState(() => new SharedWebSocket(url, options));
56
52
 
57
53
  useEffect(() => {
54
+ socket.connect();
58
55
  return () => {
59
56
  socket[Symbol.dispose]();
60
57
  };
@@ -341,6 +338,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
341
338
  const onConnect = useEffectEvent(() => handlers.onConnect?.());
342
339
  const onDisconnect = useEffectEvent(() => handlers.onDisconnect?.());
343
340
  const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
341
+ const onReconnectFailed = useEffectEvent(() => handlers.onReconnectFailed?.());
344
342
  const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
345
343
  const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
346
344
  const onActive = useEffectEvent(() => handlers.onActive?.());
@@ -353,6 +351,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
353
351
  socket.onConnect(onConnect),
354
352
  socket.onDisconnect(onDisconnect),
355
353
  socket.onReconnecting(onReconnecting),
354
+ socket.onReconnectFailed(onReconnectFailed),
356
355
  socket.onLeaderChange(onLeaderChange),
357
356
  socket.onError(onError),
358
357
  socket.onActive(onActive),
@@ -364,6 +363,51 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
364
363
  }, [socket]);
365
364
  }
366
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
+
367
411
  /**
368
412
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
369
413
  *
@@ -28,11 +28,13 @@ const TabSyncContext = createContext<TabSync | null>(null);
28
28
  export interface TabSyncProviderProps {
29
29
  /** BroadcastChannel name (default: "tab-sync"). */
30
30
  channel?: string;
31
+ /** Enable debug logging. */
32
+ debug?: boolean;
31
33
  children: ReactNode;
32
34
  }
33
35
 
34
- export function TabSyncProvider({ channel, children }: TabSyncProviderProps) {
35
- const [sync] = useState(() => new TabSync(channel));
36
+ export function TabSyncProvider({ channel, debug, children }: TabSyncProviderProps) {
37
+ const [sync] = useState(() => new TabSync(channel, { debug }));
36
38
 
37
39
  useEffect(() => {
38
40
  return () => sync[Symbol.dispose]();
@@ -21,10 +21,10 @@ export const TabSyncKey: InjectionKey<TabSync> = Symbol('TabSync');
21
21
  * const app = createApp(App);
22
22
  * app.use(createTabSyncPlugin('my-app'));
23
23
  */
24
- export function createTabSyncPlugin(channel?: string) {
24
+ export function createTabSyncPlugin(channel?: string, options?: { debug?: boolean }) {
25
25
  return {
26
26
  install(app: App) {
27
- const sync = new TabSync(channel);
27
+ const sync = new TabSync(channel, options);
28
28
  app.provide(TabSyncKey, sync);
29
29
 
30
30
  const originalUnmount = app.unmount.bind(app);
@@ -29,7 +29,7 @@ export function createSharedWebSocketPlugin(url: string, options?: SharedWebSock
29
29
  return {
30
30
  install(app: App) {
31
31
  const socket = new SharedWebSocket(url, options);
32
- socket.connect();
32
+ void socket.connect();
33
33
  app.provide(SharedWebSocketKey, socket);
34
34
 
35
35
  const originalUnmount = app.unmount.bind(app);
@@ -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/sync.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { TabSync } from './TabSync';
2
- export type { Unsubscribe } from './types';
2
+ export type { Unsubscribe, Logger } from './types';
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
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
4
  export type EventHandler<T = unknown> = (data: T) => void;
@@ -136,6 +136,8 @@ export interface SocketLifecycleHandlers {
136
136
  onConnect?: () => void;
137
137
  onDisconnect?: () => void;
138
138
  onReconnecting?: () => void;
139
+ /** Called when auto-reconnect gives up after exhausting reconnectMaxRetries. */
140
+ onReconnectFailed?: () => void;
139
141
  onLeaderChange?: (isLeader: boolean) => void;
140
142
  onError?: (error: unknown) => void;
141
143
  /** Called when this tab becomes visible/focused. */
@@ -16,15 +16,16 @@
16
16
  * { type: 'state', state: SocketState }
17
17
  */
18
18
 
19
- type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
19
+ type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'closed' | 'failed';
20
20
 
21
21
  interface WorkerCommand {
22
- type: 'connect' | 'send' | 'disconnect';
22
+ type: 'connect' | 'send' | 'disconnect' | 'reconnect';
23
23
  url?: string;
24
24
  protocols?: string[];
25
25
  data?: unknown;
26
26
  reconnect?: boolean;
27
27
  reconnectMaxDelay?: number;
28
+ reconnectMaxRetries?: number;
28
29
  heartbeatInterval?: number;
29
30
  bufferSize?: number;
30
31
  }
@@ -40,11 +41,13 @@ let currentUrl = '';
40
41
  let currentProtocols: string[] = [];
41
42
  let shouldReconnect = true;
42
43
  let maxDelay = 30_000;
44
+ let maxRetries = Infinity;
43
45
  let heartbeatInterval = 30_000;
44
46
  let maxBuffer = 100;
45
47
 
46
48
  // Backoff state
47
49
  let backoffDelay = 1000;
50
+ let reconnectAttempts = 0;
48
51
 
49
52
  function setState(s: SocketState) {
50
53
  state = s;
@@ -57,6 +60,7 @@ function connect(url: string, protocols: string[]) {
57
60
  currentUrl = url;
58
61
  currentProtocols = protocols;
59
62
  backoffDelay = 1000;
63
+ reconnectAttempts = 0;
60
64
 
61
65
  doConnect();
62
66
  }
@@ -76,6 +80,7 @@ function doConnect() {
76
80
  ws.onopen = () => {
77
81
  setState('connected');
78
82
  backoffDelay = 1000;
83
+ reconnectAttempts = 0;
79
84
  self.postMessage({ type: 'open' });
80
85
  flushBuffer();
81
86
  startHeartbeat();
@@ -161,6 +166,12 @@ function stopHeartbeat() {
161
166
  }
162
167
 
163
168
  function scheduleReconnect() {
169
+ reconnectAttempts++;
170
+ if (reconnectAttempts > maxRetries) {
171
+ setState('failed');
172
+ return;
173
+ }
174
+
164
175
  setState('reconnecting');
165
176
  clearReconnect();
166
177
 
@@ -174,6 +185,25 @@ function scheduleReconnect() {
174
185
  backoffDelay = Math.min(backoffDelay * 2, maxDelay);
175
186
  }
176
187
 
188
+ function manualReconnect() {
189
+ if (disposed) return;
190
+ clearReconnect();
191
+ reconnectAttempts = 0;
192
+ backoffDelay = 1000;
193
+
194
+ if (ws) {
195
+ ws.onclose = null;
196
+ ws.onmessage = null;
197
+ ws.onerror = null;
198
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
199
+ ws.close(1000, 'manual reconnect');
200
+ }
201
+ ws = null;
202
+ }
203
+
204
+ doConnect();
205
+ }
206
+
177
207
  function clearReconnect() {
178
208
  if (reconnectTimer) {
179
209
  clearTimeout(reconnectTimer);
@@ -189,6 +219,7 @@ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
189
219
  case 'connect':
190
220
  if (cmd.reconnect !== undefined) shouldReconnect = cmd.reconnect;
191
221
  if (cmd.reconnectMaxDelay) maxDelay = cmd.reconnectMaxDelay;
222
+ if (cmd.reconnectMaxRetries !== undefined) maxRetries = cmd.reconnectMaxRetries;
192
223
  if (cmd.heartbeatInterval) heartbeatInterval = cmd.heartbeatInterval;
193
224
  if (cmd.bufferSize) maxBuffer = cmd.bufferSize;
194
225
  connect(cmd.url!, cmd.protocols ?? []);
@@ -198,6 +229,10 @@ self.onmessage = (ev: MessageEvent<WorkerCommand>) => {
198
229
  send(cmd.data);
199
230
  break;
200
231
 
232
+ case 'reconnect':
233
+ manualReconnect();
234
+ break;
235
+
201
236
  case 'disconnect':
202
237
  disconnect();
203
238
  break;