@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
package/README.md CHANGED
@@ -210,7 +210,8 @@ usePush('notification', {
210
210
  | **Custom Serialization** | `serialize`/`deserialize` — JSON, MessagePack, Protobuf |
211
211
  | **Per-Event Serializers** | `ws.serializer(event, fn)` — binary for specific events |
212
212
  | **Runtime Auth** | `authenticate(token)` / `deauthenticate()` on existing connection |
213
- | **Lifecycle Hooks** | onConnect, onDisconnect, onActive, onInactive, onLeaderChange, onAuthChange |
213
+ | **Lifecycle Hooks** | onConnect, onDisconnect, onReconnecting, onReconnectFailed, onActive, onInactive, onLeaderChange, onAuthChange |
214
+ | **Manual Reconnect** | `ws.reconnect()` resets retry counter — pair with `onReconnectFailed` for a "Reconnect" snackbar |
214
215
  | **Debug/Logger** | `debug: true` + injectable logger (pino, Sentry) |
215
216
  | **Event Protocol** | Configurable field names (Socket.IO, Phoenix, Laravel Echo) |
216
217
  | **Auth** | URL param (`auth` callback / `authToken`) + runtime `authenticate()`/`deauthenticate()` |
@@ -37,7 +37,14 @@ export declare class SharedSocket implements Disposable {
37
37
  send(data: unknown): void;
38
38
  onMessage(fn: EventHandler): Unsubscribe;
39
39
  onStateChange(fn: (state: SocketState) => void): Unsubscribe;
40
- private reconnect;
40
+ /**
41
+ * Manually trigger a reconnect. Resets the retry counter and clears any
42
+ * scheduled backoff so the next attempt happens immediately. Use after
43
+ * `state === 'failed'` to let the user retry, or any time to force a
44
+ * fresh connection.
45
+ */
46
+ reconnect(): void;
47
+ private scheduleReconnect;
41
48
  private flushBuffer;
42
49
  private startHeartbeat;
43
50
  private stopHeartbeat;
@@ -49,6 +49,28 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
49
49
  onDisconnect(fn: () => void): Unsubscribe;
50
50
  /** Called when WebSocket starts reconnecting (broadcast to all tabs). */
51
51
  onReconnecting(fn: () => void): Unsubscribe;
52
+ /**
53
+ * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
54
+ * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
55
+ * so the user can call `ws.reconnect()` to try again.
56
+ *
57
+ * @example
58
+ * ws.onReconnectFailed(() => {
59
+ * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
60
+ * });
61
+ */
62
+ onReconnectFailed(fn: () => void): Unsubscribe;
63
+ /**
64
+ * Manually trigger a reconnect. Resets the retry counter and attempts a
65
+ * fresh connection. Safe to call from any tab — the leader actually owns
66
+ * the socket, followers route the request via BroadcastChannel.
67
+ *
68
+ * Use after `onReconnectFailed` fires to let the user retry.
69
+ *
70
+ * @example
71
+ * snackbar.action('Reconnect', () => ws.reconnect());
72
+ */
73
+ reconnect(): void;
52
74
  /** Called when this tab becomes leader or loses leadership. */
53
75
  onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe;
54
76
  /** Called on WebSocket or network error (broadcast to all tabs). */
package/dist/TabSync.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import './utils/disposable';
2
- import type { Unsubscribe } from './types';
2
+ import type { Unsubscribe, Logger } from './types';
3
+ interface TabSyncOptions {
4
+ /** Enable debug logging (default: false). */
5
+ debug?: boolean;
6
+ /** Custom logger (default: console). */
7
+ logger?: Logger;
8
+ }
3
9
  /**
4
10
  * Cross-tab state synchronization via BroadcastChannel.
5
11
  * No WebSocket needed — works standalone for sharing state between browser tabs.
@@ -8,13 +14,18 @@ import type { Unsubscribe } from './types';
8
14
  * const sync = new TabSync('my-app');
9
15
  * sync.set('theme', 'dark');
10
16
  * sync.on('theme', (theme) => applyTheme(theme));
17
+ *
18
+ * @example
19
+ * // With debug logging
20
+ * const sync = new TabSync('my-app', { debug: true });
11
21
  */
12
22
  export declare class TabSync implements Disposable {
13
23
  private store;
14
24
  private listeners;
15
25
  private bc;
16
26
  private disposed;
17
- constructor(channel?: string);
27
+ private readonly log;
28
+ constructor(channel?: string, options?: TabSyncOptions);
18
29
  /** Set a value and broadcast to all tabs. Local listeners also fire. */
19
30
  set<T>(key: string, value: T): void;
20
31
  /** Get current value from local store. */
@@ -43,3 +54,4 @@ export declare class TabSync implements Disposable {
43
54
  private emit;
44
55
  [Symbol.dispose](): void;
45
56
  }
57
+ export {};
@@ -27,13 +27,21 @@ export declare class WorkerSocket implements Disposable {
27
27
  protocols?: string[];
28
28
  reconnect?: boolean;
29
29
  reconnectMaxDelay?: number;
30
+ reconnectMaxRetries?: number;
30
31
  heartbeatInterval?: number;
31
32
  sendBuffer?: number;
32
33
  workerUrl?: string | URL;
34
+ auth?: () => string | Promise<string>;
35
+ authToken?: string;
36
+ authParam?: string;
37
+ pingPayload?: unknown;
33
38
  });
34
39
  get state(): SocketState;
35
- connect(): void;
40
+ connect(): Promise<void>;
41
+ private buildUrl;
36
42
  send(data: unknown): void;
43
+ /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
44
+ reconnect(): void;
37
45
  disconnect(): void;
38
46
  onMessage(fn: EventHandler): Unsubscribe;
39
47
  onStateChange(fn: (state: SocketState) => void): Unsubscribe;
@@ -178,6 +178,29 @@ export declare function useSocketStatus(): {
178
178
  * });
179
179
  */
180
180
  export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
181
+ /**
182
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
183
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
184
+ *
185
+ * `hasFailed` is `true` after `reconnectMaxRetries` are exhausted. It resets
186
+ * to `false` once the connection succeeds again or the user calls `reconnect()`.
187
+ *
188
+ * @example
189
+ * function ConnectionBanner() {
190
+ * const { hasFailed, reconnect } = useSocketReconnect();
191
+ * if (!hasFailed) return null;
192
+ * return (
193
+ * <div className="snackbar">
194
+ * Connection lost.
195
+ * <button onClick={reconnect}>Reconnect</button>
196
+ * </div>
197
+ * );
198
+ * }
199
+ */
200
+ export declare function useSocketReconnect(): {
201
+ hasFailed: boolean;
202
+ reconnect: () => void;
203
+ };
181
204
  /**
182
205
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
183
206
  *
@@ -15,9 +15,11 @@ import { TabSync } from '../TabSync';
15
15
  export interface TabSyncProviderProps {
16
16
  /** BroadcastChannel name (default: "tab-sync"). */
17
17
  channel?: string;
18
+ /** Enable debug logging. */
19
+ debug?: boolean;
18
20
  children: ReactNode;
19
21
  }
20
- export declare function TabSyncProvider({ channel, children }: TabSyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<TabSync | null>>;
22
+ export declare function TabSyncProvider({ channel, debug, children }: TabSyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<TabSync | null>>;
21
23
  /**
22
24
  * Access the TabSync instance from context.
23
25
  *
@@ -8,7 +8,9 @@ export declare const TabSyncKey: InjectionKey<TabSync>;
8
8
  * const app = createApp(App);
9
9
  * app.use(createTabSyncPlugin('my-app'));
10
10
  */
11
- export declare function createTabSyncPlugin(channel?: string): {
11
+ export declare function createTabSyncPlugin(channel?: string, options?: {
12
+ debug?: boolean;
13
+ }): {
12
14
  install(app: App): void;
13
15
  };
14
16
  /**
@@ -135,6 +135,29 @@ export declare function useSocketStatus(): {
135
135
  * });
136
136
  */
137
137
  export declare function useSocketLifecycle(handlers: SocketLifecycleHandlers): void;
138
+ /**
139
+ * Reactive reconnect state with a manual `reconnect` action. Use this to
140
+ * power a "Reconnect" snackbar/banner after auto-reconnect gives up.
141
+ *
142
+ * `hasFailed` flips to `true` once `reconnectMaxRetries` are exhausted, and
143
+ * back to `false` once the connection succeeds or the user calls `reconnect()`.
144
+ *
145
+ * @example
146
+ * <script setup>
147
+ * const { hasFailed, reconnect } = useSocketReconnect();
148
+ * </script>
149
+ *
150
+ * <template>
151
+ * <div v-if="hasFailed" class="snackbar">
152
+ * Connection lost.
153
+ * <button @click="reconnect">Reconnect</button>
154
+ * </div>
155
+ * </template>
156
+ */
157
+ export declare function useSocketReconnect(): {
158
+ hasFailed: Ref<boolean>;
159
+ reconnect: () => void;
160
+ };
138
161
  /**
139
162
  * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
140
163
  *
@@ -1,17 +1,27 @@
1
1
  // src/TabSync.ts
2
+ var NOOP_LOGGER = { debug() {
3
+ }, info() {
4
+ }, warn() {
5
+ }, error() {
6
+ } };
2
7
  var TabSync = class {
3
8
  store = /* @__PURE__ */ new Map();
4
9
  listeners = /* @__PURE__ */ new Map();
5
10
  bc;
6
11
  disposed = false;
7
- constructor(channel = "tab-sync") {
12
+ log;
13
+ constructor(channel = "tab-sync", options) {
14
+ this.log = options?.debug ? options.logger ?? console : NOOP_LOGGER;
15
+ this.log.debug("[TabSync] init", { channel });
8
16
  this.bc = new BroadcastChannel(channel);
9
17
  this.bc.onmessage = (ev) => {
10
18
  const { key, value, deleted } = ev.data;
11
19
  if (deleted) {
12
20
  this.store.delete(key);
21
+ this.log.debug("[TabSync] \u2190 remote delete", key);
13
22
  } else {
14
23
  this.store.set(key, value);
24
+ this.log.debug("[TabSync] \u2190 remote set", key, value);
15
25
  }
16
26
  this.emit(key, value);
17
27
  };
@@ -20,6 +30,7 @@ var TabSync = class {
20
30
  set(key, value) {
21
31
  this.store.set(key, value);
22
32
  this.bc.postMessage({ key, value });
33
+ this.log.debug("[TabSync] \u2192 set", key, value);
23
34
  this.emit(key, value);
24
35
  }
25
36
  /** Get current value from local store. */
@@ -30,6 +41,7 @@ var TabSync = class {
30
41
  delete(key) {
31
42
  this.store.delete(key);
32
43
  this.bc.postMessage({ key, value: void 0, deleted: true });
44
+ this.log.debug("[TabSync] \u2192 delete", key);
33
45
  this.emit(key, void 0);
34
46
  }
35
47
  /** Check if a key exists in the local store. */
@@ -71,6 +83,7 @@ var TabSync = class {
71
83
  /** Clear all keys and notify listeners. */
72
84
  clear() {
73
85
  const keys = [...this.store.keys()];
86
+ this.log.debug("[TabSync] \u2192 clear", keys);
74
87
  this.store.clear();
75
88
  for (const key of keys) {
76
89
  this.bc.postMessage({ key, value: void 0, deleted: true });
@@ -99,4 +112,4 @@ var TabSync = class {
99
112
  export {
100
113
  TabSync
101
114
  };
102
- //# sourceMappingURL=chunk-RM27CYKT.js.map
115
+ //# sourceMappingURL=chunk-7WBM2C7H.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/TabSync.ts"],"sourcesContent":["import './utils/disposable';\nimport type { Unsubscribe, Logger } from './types';\n\ninterface SyncMessage {\n key: string;\n value: unknown;\n deleted?: boolean;\n}\n\ninterface TabSyncOptions {\n /** Enable debug logging (default: false). */\n debug?: boolean;\n /** Custom logger (default: console). */\n logger?: Logger;\n}\n\nconst NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };\n\n/**\n * Cross-tab state synchronization via BroadcastChannel.\n * No WebSocket needed — works standalone for sharing state between browser tabs.\n *\n * @example\n * const sync = new TabSync('my-app');\n * sync.set('theme', 'dark');\n * sync.on('theme', (theme) => applyTheme(theme));\n *\n * @example\n * // With debug logging\n * const sync = new TabSync('my-app', { debug: true });\n */\nexport class TabSync implements Disposable {\n private store = new Map<string, unknown>();\n private listeners = new Map<string, Set<(value: unknown) => void>>();\n private bc: BroadcastChannel;\n private disposed = false;\n private readonly log: Logger;\n\n constructor(channel = 'tab-sync', options?: TabSyncOptions) {\n this.log = options?.debug ? (options.logger ?? console) : NOOP_LOGGER;\n this.log.debug('[TabSync] init', { channel });\n this.bc = new BroadcastChannel(channel);\n this.bc.onmessage = (ev: MessageEvent<SyncMessage>) => {\n const { key, value, deleted } = ev.data;\n if (deleted) {\n this.store.delete(key);\n this.log.debug('[TabSync] ← remote delete', key);\n } else {\n this.store.set(key, value);\n this.log.debug('[TabSync] ← remote set', key, value);\n }\n this.emit(key, value);\n };\n }\n\n /** Set a value and broadcast to all tabs. Local listeners also fire. */\n set<T>(key: string, value: T): void {\n this.store.set(key, value);\n this.bc.postMessage({ key, value } satisfies SyncMessage);\n this.log.debug('[TabSync] → set', key, value);\n this.emit(key, value);\n }\n\n /** Get current value from local store. */\n get<T>(key: string): T | undefined {\n return this.store.get(key) as T | undefined;\n }\n\n /** Delete a key and broadcast deletion to all tabs. */\n delete(key: string): void {\n this.store.delete(key);\n this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);\n this.log.debug('[TabSync] → delete', key);\n this.emit(key, undefined);\n }\n\n /** Check if a key exists in the local store. */\n has(key: string): boolean {\n return this.store.has(key);\n }\n\n /** Get all keys in the local store. */\n keys(): string[] {\n return [...this.store.keys()];\n }\n\n /** Get number of entries. */\n get size(): number {\n return this.store.size;\n }\n\n /**\n * Listen for changes to a key. Fires when any tab (including this one) calls set().\n *\n * @example\n * sync.on('cart', (cart) => updateBadge(cart.items.length));\n */\n on<T>(key: string, fn: (value: T) => void): Unsubscribe {\n let set = this.listeners.get(key);\n if (!set) {\n set = new Set();\n this.listeners.set(key, set);\n }\n const wrapper = fn as (value: unknown) => void;\n set.add(wrapper);\n return () => set!.delete(wrapper);\n }\n\n /** Listen for a key change once, then auto-unsubscribe. */\n once<T>(key: string, fn: (value: T) => void): Unsubscribe {\n const unsub = this.on<T>(key, (value) => {\n unsub();\n fn(value);\n });\n return unsub;\n }\n\n /** Clear all keys and notify listeners. */\n clear(): void {\n const keys = [...this.store.keys()];\n this.log.debug('[TabSync] → clear', keys);\n this.store.clear();\n for (const key of keys) {\n this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);\n this.emit(key, undefined);\n }\n }\n\n /** Dispose — close BroadcastChannel and clear all state. */\n dispose(): void {\n this[Symbol.dispose]();\n }\n\n private emit(key: string, value: unknown): void {\n const set = this.listeners.get(key);\n if (set) {\n for (const fn of set) fn(value);\n }\n }\n\n [Symbol.dispose](): void {\n if (this.disposed) return;\n this.disposed = true;\n this.bc.close();\n this.store.clear();\n this.listeners.clear();\n }\n}\n"],"mappings":";AAgBA,IAAM,cAAsB,EAAE,QAAQ;AAAC,GAAG,OAAO;AAAC,GAAG,OAAO;AAAC,GAAG,QAAQ;AAAC,EAAE;AAepE,IAAM,UAAN,MAAoC;AAAA,EACjC,QAAQ,oBAAI,IAAqB;AAAA,EACjC,YAAY,oBAAI,IAA2C;AAAA,EAC3D;AAAA,EACA,WAAW;AAAA,EACF;AAAA,EAEjB,YAAY,UAAU,YAAY,SAA0B;AAC1D,SAAK,MAAM,SAAS,QAAS,QAAQ,UAAU,UAAW;AAC1D,SAAK,IAAI,MAAM,kBAAkB,EAAE,QAAQ,CAAC;AAC5C,SAAK,KAAK,IAAI,iBAAiB,OAAO;AACtC,SAAK,GAAG,YAAY,CAAC,OAAkC;AACrD,YAAM,EAAE,KAAK,OAAO,QAAQ,IAAI,GAAG;AACnC,UAAI,SAAS;AACX,aAAK,MAAM,OAAO,GAAG;AACrB,aAAK,IAAI,MAAM,kCAA6B,GAAG;AAAA,MACjD,OAAO;AACL,aAAK,MAAM,IAAI,KAAK,KAAK;AACzB,aAAK,IAAI,MAAM,+BAA0B,KAAK,KAAK;AAAA,MACrD;AACA,WAAK,KAAK,KAAK,KAAK;AAAA,IACtB;AAAA,EACF;AAAA;AAAA,EAGA,IAAO,KAAa,OAAgB;AAClC,SAAK,MAAM,IAAI,KAAK,KAAK;AACzB,SAAK,GAAG,YAAY,EAAE,KAAK,MAAM,CAAuB;AACxD,SAAK,IAAI,MAAM,wBAAmB,KAAK,KAAK;AAC5C,SAAK,KAAK,KAAK,KAAK;AAAA,EACtB;AAAA;AAAA,EAGA,IAAO,KAA4B;AACjC,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,OAAO,KAAmB;AACxB,SAAK,MAAM,OAAO,GAAG;AACrB,SAAK,GAAG,YAAY,EAAE,KAAK,OAAO,QAAW,SAAS,KAAK,CAAuB;AAClF,SAAK,IAAI,MAAM,2BAAsB,GAAG;AACxC,SAAK,KAAK,KAAK,MAAS;AAAA,EAC1B;AAAA;AAAA,EAGA,IAAI,KAAsB;AACxB,WAAO,KAAK,MAAM,IAAI,GAAG;AAAA,EAC3B;AAAA;AAAA,EAGA,OAAiB;AACf,WAAO,CAAC,GAAG,KAAK,MAAM,KAAK,CAAC;AAAA,EAC9B;AAAA;AAAA,EAGA,IAAI,OAAe;AACjB,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,GAAM,KAAa,IAAqC;AACtD,QAAI,MAAM,KAAK,UAAU,IAAI,GAAG;AAChC,QAAI,CAAC,KAAK;AACR,YAAM,oBAAI,IAAI;AACd,WAAK,UAAU,IAAI,KAAK,GAAG;AAAA,IAC7B;AACA,UAAM,UAAU;AAChB,QAAI,IAAI,OAAO;AACf,WAAO,MAAM,IAAK,OAAO,OAAO;AAAA,EAClC;AAAA;AAAA,EAGA,KAAQ,KAAa,IAAqC;AACxD,UAAM,QAAQ,KAAK,GAAM,KAAK,CAAC,UAAU;AACvC,YAAM;AACN,SAAG,KAAK;AAAA,IACV,CAAC;AACD,WAAO;AAAA,EACT;AAAA;AAAA,EAGA,QAAc;AACZ,UAAM,OAAO,CAAC,GAAG,KAAK,MAAM,KAAK,CAAC;AAClC,SAAK,IAAI,MAAM,0BAAqB,IAAI;AACxC,SAAK,MAAM,MAAM;AACjB,eAAW,OAAO,MAAM;AACtB,WAAK,GAAG,YAAY,EAAE,KAAK,OAAO,QAAW,SAAS,KAAK,CAAuB;AAClF,WAAK,KAAK,KAAK,MAAS;AAAA,IAC1B;AAAA,EACF;AAAA;AAAA,EAGA,UAAgB;AACd,SAAK,OAAO,OAAO,EAAE;AAAA,EACvB;AAAA,EAEQ,KAAK,KAAa,OAAsB;AAC9C,UAAM,MAAM,KAAK,UAAU,IAAI,GAAG;AAClC,QAAI,KAAK;AACP,iBAAW,MAAM,IAAK,IAAG,KAAK;AAAA,IAChC;AAAA,EACF;AAAA,EAEA,CAAC,OAAO,OAAO,IAAU;AACvB,QAAI,KAAK,SAAU;AACnB,SAAK,WAAW;AAChB,SAAK,GAAG,MAAM;AACd,SAAK,MAAM,MAAM;AACjB,SAAK,UAAU,MAAM;AAAA,EACvB;AACF;","names":[]}
@@ -295,7 +295,7 @@ var SharedSocket = class {
295
295
  this.ws.onclose = () => {
296
296
  this.stopHeartbeat();
297
297
  if (!this.disposed && this.opts.reconnect) {
298
- this.reconnect();
298
+ this.scheduleReconnect();
299
299
  } else {
300
300
  this.setState("closed");
301
301
  }
@@ -335,10 +335,31 @@ var SharedSocket = class {
335
335
  this.onStateChangeFns.add(fn);
336
336
  return () => this.onStateChangeFns.delete(fn);
337
337
  }
338
+ /**
339
+ * Manually trigger a reconnect. Resets the retry counter and clears any
340
+ * scheduled backoff so the next attempt happens immediately. Use after
341
+ * `state === 'failed'` to let the user retry, or any time to force a
342
+ * fresh connection.
343
+ */
338
344
  reconnect() {
345
+ if (this.disposed) return;
346
+ this.clearReconnect();
347
+ this.reconnectAttempts = 0;
348
+ if (this.ws) {
349
+ this.ws.onclose = null;
350
+ this.ws.onmessage = null;
351
+ this.ws.onerror = null;
352
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
353
+ this.ws.close(1e3, "manual reconnect");
354
+ }
355
+ this.ws = null;
356
+ }
357
+ void this.connect();
358
+ }
359
+ scheduleReconnect() {
339
360
  this.reconnectAttempts++;
340
361
  if (this.reconnectAttempts > this.opts.reconnectMaxRetries) {
341
- this.setState("closed");
362
+ this.setState("failed");
342
363
  return;
343
364
  }
344
365
  this.setState("reconnecting");
@@ -418,7 +439,14 @@ var WorkerSocket = class {
418
439
  get state() {
419
440
  return this._state;
420
441
  }
421
- connect() {
442
+ async connect() {
443
+ let authToken;
444
+ if (this.options.auth) {
445
+ authToken = await this.options.auth();
446
+ } else if (this.options.authToken) {
447
+ authToken = this.options.authToken;
448
+ }
449
+ const connectUrl = authToken ? this.buildUrl(authToken) : this.url;
422
450
  const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();
423
451
  this.worker = new Worker(workerUrl, { type: "module" });
424
452
  this.worker.onmessage = (ev) => {
@@ -442,17 +470,30 @@ var WorkerSocket = class {
442
470
  };
443
471
  this.worker.postMessage({
444
472
  type: "connect",
445
- url: this.url,
473
+ url: connectUrl,
446
474
  protocols: this.options.protocols ?? [],
447
475
  reconnect: this.options.reconnect ?? true,
448
476
  reconnectMaxDelay: this.options.reconnectMaxDelay ?? 3e4,
477
+ reconnectMaxRetries: this.options.reconnectMaxRetries ?? Infinity,
449
478
  heartbeatInterval: this.options.heartbeatInterval ?? 3e4,
450
- bufferSize: this.options.sendBuffer ?? 100
479
+ bufferSize: this.options.sendBuffer ?? 100,
480
+ pingPayload: this.options.pingPayload
451
481
  });
452
482
  }
483
+ buildUrl(token) {
484
+ const param = this.options.authParam ?? "token";
485
+ const httpUrl = this.url.replace(/^ws(s?):\/\//, "http$1://");
486
+ const parsed = new URL(httpUrl);
487
+ parsed.searchParams.set(param, token);
488
+ return parsed.toString().replace(/^http(s?):\/\//, "ws$1://");
489
+ }
453
490
  send(data) {
454
491
  this.worker?.postMessage({ type: "send", data });
455
492
  }
493
+ /** Manually trigger reconnect: resets retry counter, attempts a fresh connection. */
494
+ reconnect() {
495
+ this.worker?.postMessage({ type: "reconnect" });
496
+ }
456
497
  disconnect() {
457
498
  this.worker?.postMessage({ type: "disconnect" });
458
499
  setTimeout(() => {
@@ -474,27 +515,43 @@ var WorkerSocket = class {
474
515
  let ws = null, state = 'closed', buffer = [], disposed = false;
475
516
  let heartbeatTimer = null, reconnectTimer = null;
476
517
  let url = '', protocols = [], shouldReconnect = true;
477
- let maxDelay = 30000, hbInterval = 30000, maxBuf = 100, delay = 1000;
518
+ let maxDelay = 30000, maxRetries = Infinity, hbInterval = 30000, maxBuf = 100;
519
+ let delay = 1000, attempts = 0, pingPayload = '{"type":"ping"}';
478
520
 
479
521
  function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }
480
522
  function connect() {
481
523
  if (disposed) return;
482
524
  setState('connecting');
483
525
  ws = new WebSocket(url, protocols);
484
- ws.onopen = () => { setState('connected'); delay = 1000; self.postMessage({ type: 'open' }); flush(); startHB(); };
526
+ ws.onopen = () => { attempts = 0; delay = 1000; setState('connected'); self.postMessage({ type: 'open' }); flush(); startHB(); };
485
527
  ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };
486
528
  ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };
487
529
  ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };
488
530
  }
489
531
  function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }
490
532
  function flush() { const p = buffer.splice(0); p.forEach(send); }
491
- function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send('{"type":"ping"}'); }, hbInterval); }
533
+ function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send(pingPayload); }, hbInterval); }
492
534
  function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
493
- 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); }
535
+ function reconnect() {
536
+ attempts++;
537
+ if (attempts > maxRetries) { setState('failed'); return; }
538
+ setState('reconnecting');
539
+ const j = delay * 0.25 * (Math.random() * 2 - 1);
540
+ reconnectTimer = setTimeout(() => { if (!disposed) connect(); }, Math.min(delay + j, maxDelay));
541
+ delay = Math.min(delay * 2, maxDelay);
542
+ }
543
+ function manualReconnect() {
544
+ if (disposed) return;
545
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
546
+ attempts = 0; delay = 1000;
547
+ if (ws) { ws.onclose = null; ws.onmessage = null; ws.onerror = null; if (ws.readyState < 2) ws.close(1000, 'manual reconnect'); ws = null; }
548
+ connect();
549
+ }
494
550
  self.onmessage = (e) => {
495
551
  const c = e.data;
496
- 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(); }
552
+ 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(); }
497
553
  if (c.type === 'send') send(c.data);
554
+ if (c.type === 'reconnect') manualReconnect();
498
555
  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'); }
499
556
  };
500
557
  `;
@@ -634,6 +691,14 @@ var SharedWebSocket = class {
634
691
  }
635
692
  })
636
693
  );
694
+ this.cleanups.push(
695
+ this.bus.subscribe("ws:reconnect", () => {
696
+ if (this.coordinator.isLeader && this.socket) {
697
+ this.log.info("[SharedWS] manual reconnect requested by follower");
698
+ this.socket.reconnect();
699
+ }
700
+ })
701
+ );
637
702
  this.cleanups.push(
638
703
  this.bus.subscribe("ws:sync", (msg) => {
639
704
  this.syncStore.set(msg.key, msg.value);
@@ -660,6 +725,9 @@ var SharedWebSocket = class {
660
725
  case "reconnecting":
661
726
  this.subs.emit("$lifecycle:reconnecting", void 0);
662
727
  break;
728
+ case "reconnectFailed":
729
+ this.subs.emit("$lifecycle:reconnectFailed", void 0);
730
+ break;
663
731
  case "leader":
664
732
  this.subs.emit("$lifecycle:leader", msg.isLeader);
665
733
  break;
@@ -757,6 +825,36 @@ var SharedWebSocket = class {
757
825
  onReconnecting(fn) {
758
826
  return this.subs.on("$lifecycle:reconnecting", fn);
759
827
  }
828
+ /**
829
+ * Called when auto-reconnect gives up after exhausting `reconnectMaxRetries`.
830
+ * Use this to show a "Reconnect" UI affordance (snackbar, banner, modal)
831
+ * so the user can call `ws.reconnect()` to try again.
832
+ *
833
+ * @example
834
+ * ws.onReconnectFailed(() => {
835
+ * showSnackbar('Connection lost', { action: { label: 'Reconnect', onClick: () => ws.reconnect() } });
836
+ * });
837
+ */
838
+ onReconnectFailed(fn) {
839
+ return this.subs.on("$lifecycle:reconnectFailed", fn);
840
+ }
841
+ /**
842
+ * Manually trigger a reconnect. Resets the retry counter and attempts a
843
+ * fresh connection. Safe to call from any tab — the leader actually owns
844
+ * the socket, followers route the request via BroadcastChannel.
845
+ *
846
+ * Use after `onReconnectFailed` fires to let the user retry.
847
+ *
848
+ * @example
849
+ * snackbar.action('Reconnect', () => ws.reconnect());
850
+ */
851
+ reconnect() {
852
+ if (this.coordinator.isLeader && this.socket) {
853
+ this.socket.reconnect();
854
+ } else {
855
+ this.bus.publish("ws:reconnect", void 0);
856
+ }
857
+ }
760
858
  /** Called when this tab becomes leader or loses leadership. */
761
859
  onLeaderChange(fn) {
762
860
  return this.subs.on("$lifecycle:leader", fn);
@@ -1103,7 +1201,10 @@ var SharedWebSocket = class {
1103
1201
  if (this.options.useWorker) {
1104
1202
  return new WorkerSocket(this.url, {
1105
1203
  ...socketOptions,
1106
- workerUrl: this.options.workerUrl
1204
+ workerUrl: this.options.workerUrl,
1205
+ auth: this.options.auth,
1206
+ authToken: this.options.authToken,
1207
+ authParam: this.options.authParam
1107
1208
  });
1108
1209
  }
1109
1210
  return new SharedSocket(this.url, {
@@ -1138,7 +1239,7 @@ var SharedWebSocket = class {
1138
1239
  this.bus.broadcast("ws:message", { event, data: payload });
1139
1240
  });
1140
1241
  this.socket.onStateChange((state) => {
1141
- this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : `state: ${state}`);
1242
+ this.log.info("[SharedWS]", state === "connected" ? "\u2713 connected" : state === "reconnecting" ? "\u{1F504} reconnecting" : state === "failed" ? "\u2717 reconnect failed" : `state: ${state}`);
1142
1243
  switch (state) {
1143
1244
  case "connected":
1144
1245
  this.bus.broadcast("ws:lifecycle", { type: "connect" });
@@ -1150,6 +1251,10 @@ var SharedWebSocket = class {
1150
1251
  case "reconnecting":
1151
1252
  this.bus.broadcast("ws:lifecycle", { type: "reconnecting" });
1152
1253
  break;
1254
+ case "failed":
1255
+ this.bus.broadcast("ws:lifecycle", { type: "reconnectFailed" });
1256
+ this.bus.broadcast("ws:lifecycle", { type: "disconnect" });
1257
+ break;
1153
1258
  }
1154
1259
  });
1155
1260
  this.cleanups.push(
@@ -1166,7 +1271,7 @@ var SharedWebSocket = class {
1166
1271
  });
1167
1272
  })
1168
1273
  );
1169
- this.socket.connect();
1274
+ void this.socket.connect();
1170
1275
  }
1171
1276
  reAuthenticateOnReconnect() {
1172
1277
  if (!this._isAuthenticated || !this.socket) return;
@@ -1223,4 +1328,4 @@ export {
1223
1328
  SubscriptionManager,
1224
1329
  SharedWebSocket
1225
1330
  };
1226
- //# sourceMappingURL=chunk-FZIIMO67.js.map
1331
+ //# sourceMappingURL=chunk-IK4HLA3K.js.map