@gwakko/shared-websocket 0.7.1 → 0.8.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.
package/README.md CHANGED
@@ -1121,16 +1121,25 @@ Server receives `$topic:subscribe` / `$topic:unsubscribe` events (configurable v
1121
1121
 
1122
1122
  ## Push Notifications
1123
1123
 
1124
- Two modes: **custom render** (sonner, react-hot-toast, your UI) and/or **browser Notification API**. Both respect `leaderOnly` + `onlyWhenHidden` to prevent duplicates across tabs.
1124
+ Two modes: **custom render** (sonner, react-hot-toast, your UI) and/or **browser Notification API**.
1125
+
1126
+ `target` controls which tab(s) show the notification:
1127
+
1128
+ | Target | Behavior | Default for |
1129
+ |--------|----------|-------------|
1130
+ | `'active'` | Only the currently visible/focused tab | render (toasts) |
1131
+ | `'leader'` | Only the leader tab | browser Notification |
1132
+ | `'all'` | Every tab (critical alerts) | — |
1125
1133
 
1126
1134
  ### Custom Render — you control the display
1127
1135
 
1128
1136
  ```typescript
1129
- // Vanilla — sonner toast
1137
+ // Vanilla — sonner toast (default: target 'active' — visible tab only)
1130
1138
  import { toast } from 'sonner';
1131
1139
 
1132
1140
  ws.push('notification', {
1133
1141
  render: (n) => toast(n.title, { description: n.body }),
1142
+ // target: 'active' — implicit default
1134
1143
  });
1135
1144
 
1136
1145
  ws.push('order.created', {
@@ -1186,15 +1195,14 @@ usePush('order.created', {
1186
1195
  ### Browser Notification API — native OS notifications
1187
1196
 
1188
1197
  ```typescript
1189
- // Vanilla — browser native (no render needed)
1198
+ // Vanilla — browser native (default: target 'leader' — one notification, not N)
1190
1199
  ws.push('notification', {
1191
1200
  title: (n) => n.title,
1192
1201
  body: (n) => n.body,
1193
1202
  icon: '/icons/bell.png',
1194
1203
  tag: (n) => `notif-${n.id}`, // deduplication
1195
1204
  onClick: (n) => window.open(n.url),
1196
- leaderOnly: true, // default: true
1197
- onlyWhenHidden: true, // default: true
1205
+ // target: 'leader' — implicit default for native notifications
1198
1206
  });
1199
1207
  ```
1200
1208
 
@@ -1218,10 +1226,38 @@ usePush('order.created', {
1218
1226
  </script>
1219
1227
  ```
1220
1228
 
1229
+ ### Critical alerts — show in ALL tabs
1230
+
1231
+ ```typescript
1232
+ // Vanilla — payment failed: show toast in EVERY tab
1233
+ ws.push('payment.failed', {
1234
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1235
+ target: 'all', // every tab sees it
1236
+ });
1237
+ ```
1238
+
1239
+ ```tsx
1240
+ // React
1241
+ usePush('payment.failed', {
1242
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1243
+ target: 'all',
1244
+ });
1245
+ ```
1246
+
1247
+ ```vue
1248
+ <!-- Vue -->
1249
+ <script setup>
1250
+ usePush('payment.failed', {
1251
+ render: (err) => toast.error(`Payment failed: ${err.message}`),
1252
+ target: 'all',
1253
+ });
1254
+ </script>
1255
+ ```
1256
+
1221
1257
  ### Both — toast in UI + browser notification
1222
1258
 
1223
1259
  ```typescript
1224
- // Show sonner toast AND browser notification
1260
+ // Active tab gets sonner toast, leader sends native notification
1225
1261
  ws.push('order.created', {
1226
1262
  render: (order) => toast.success(`Order #${order.id}`), // in-app toast
1227
1263
  title: (order) => `New Order #${order.id}`, // + native notification
@@ -113,46 +113,43 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
113
113
  /**
114
114
  * Subscribe to an event and show notifications.
115
115
  *
116
- * Two modes:
117
- * - **render**you control how to display (sonner, react-hot-toast, custom UI)
118
- * - **browser Notification API** native OS notifications (title/body/icon)
119
- *
120
- * Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
116
+ * **target** controls which tab(s) display the notification:
117
+ * - `'active'`only the currently visible tab (default for render)
118
+ * - `'leader'` only the leader tab (default for browser Notification)
119
+ * - `'all'` — every tab (for critical alerts)
121
120
  *
122
121
  * @example
123
- * // Custom render — sonner toast
124
- * import { toast } from 'sonner';
122
+ * // Custom render — sonner toast on active tab only
125
123
  * ws.push('notification', {
126
- * render: (data) => toast(data.title, { description: data.body }),
124
+ * render: (n) => toast(n.title),
125
+ * target: 'active', // default for render
127
126
  * });
128
127
  *
129
128
  * @example
130
- * // Custom renderreact-hot-toast
131
- * import toast from 'react-hot-toast';
132
- * ws.push('order.created', {
133
- * render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
129
+ * // Critical alertshow in ALL tabs
130
+ * ws.push('payment.failed', {
131
+ * render: (n) => toast.error('Payment failed!'),
132
+ * target: 'all',
134
133
  * });
135
134
  *
136
135
  * @example
137
- * // Browser Notification API (no render — uses title/body/icon)
138
- * ws.push('notification', {
139
- * title: (data) => data.title,
140
- * body: (data) => data.body,
141
- * icon: '/icons/bell.png',
136
+ * // Browser Notification only from leader
137
+ * ws.push('order.created', {
138
+ * title: (order) => `New Order #${order.id}`,
139
+ * target: 'leader', // default for browser Notification
142
140
  * });
143
141
  *
144
142
  * @example
145
- * // Both toast in UI + browser notification
143
+ * // Both render + native with different targets
146
144
  * ws.push('order.created', {
147
- * render: (order) => toast(`Order #${order.id}`),
148
- * title: (order) => `New Order #${order.id}`,
149
- * body: (order) => `$${order.total}`,
145
+ * render: (order) => toast(`Order #${order.id}`), // active tab
146
+ * title: (order) => `New Order #${order.id}`, // leader → native
150
147
  * });
151
148
  */
152
149
  push<T = unknown>(event: string, config: {
153
- /** Custom render function — you decide how to display. Called for every matching event. */
150
+ /** Custom render function — you decide how to display. */
154
151
  render?: (data: T) => void;
155
- /** Title for browser Notification API (ignored if only render is used). */
152
+ /** Title for browser Notification API. */
156
153
  title?: string | ((data: T) => string);
157
154
  /** Body for browser Notification API. */
158
155
  body?: string | ((data: T) => string);
@@ -160,10 +157,13 @@ export declare class SharedWebSocket<TEvents extends EventMap = EventMap> implem
160
157
  icon?: string;
161
158
  /** Tag for browser Notification deduplication. */
162
159
  tag?: string | ((data: T) => string);
163
- /** Only trigger from leader tab (default: true). Prevents N duplicates for N tabs. */
164
- leaderOnly?: boolean;
165
- /** Only trigger when tab is hidden/background (default: true). */
166
- onlyWhenHidden?: boolean;
160
+ /**
161
+ * Which tab(s) show the notification:
162
+ * - `'active'` only the visible/focused tab (default for render)
163
+ * - `'leader'` — only the leader tab (default for browser Notification)
164
+ * - `'all'` — every tab (critical alerts)
165
+ */
166
+ target?: 'active' | 'leader' | 'all';
167
167
  /** Called when browser Notification is clicked. */
168
168
  onClick?: (data: T) => void;
169
169
  }): Unsubscribe;
@@ -849,70 +849,73 @@ var SharedWebSocket = (_class6 = class {
849
849
  /**
850
850
  * Subscribe to an event and show notifications.
851
851
  *
852
- * Two modes:
853
- * - **render**you control how to display (sonner, react-hot-toast, custom UI)
854
- * - **browser Notification API** native OS notifications (title/body/icon)
855
- *
856
- * Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
852
+ * **target** controls which tab(s) display the notification:
853
+ * - `'active'`only the currently visible tab (default for render)
854
+ * - `'leader'` only the leader tab (default for browser Notification)
855
+ * - `'all'` — every tab (for critical alerts)
857
856
  *
858
857
  * @example
859
- * // Custom render — sonner toast
860
- * import { toast } from 'sonner';
858
+ * // Custom render — sonner toast on active tab only
861
859
  * ws.push('notification', {
862
- * render: (data) => toast(data.title, { description: data.body }),
860
+ * render: (n) => toast(n.title),
861
+ * target: 'active', // default for render
863
862
  * });
864
863
  *
865
864
  * @example
866
- * // Custom renderreact-hot-toast
867
- * import toast from 'react-hot-toast';
868
- * ws.push('order.created', {
869
- * render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
865
+ * // Critical alertshow in ALL tabs
866
+ * ws.push('payment.failed', {
867
+ * render: (n) => toast.error('Payment failed!'),
868
+ * target: 'all',
870
869
  * });
871
870
  *
872
871
  * @example
873
- * // Browser Notification API (no render — uses title/body/icon)
874
- * ws.push('notification', {
875
- * title: (data) => data.title,
876
- * body: (data) => data.body,
877
- * icon: '/icons/bell.png',
872
+ * // Browser Notification only from leader
873
+ * ws.push('order.created', {
874
+ * title: (order) => `New Order #${order.id}`,
875
+ * target: 'leader', // default for browser Notification
878
876
  * });
879
877
  *
880
878
  * @example
881
- * // Both toast in UI + browser notification
879
+ * // Both render + native with different targets
882
880
  * ws.push('order.created', {
883
- * render: (order) => toast(`Order #${order.id}`),
884
- * title: (order) => `New Order #${order.id}`,
885
- * body: (order) => `$${order.total}`,
881
+ * render: (order) => toast(`Order #${order.id}`), // active tab
882
+ * title: (order) => `New Order #${order.id}`, // leader → native
886
883
  * });
887
884
  */
888
885
  push(event, config) {
889
- const leaderOnly = _nullishCoalesce(config.leaderOnly, () => ( true));
890
- const onlyWhenHidden = _nullishCoalesce(config.onlyWhenHidden, () => ( true));
891
886
  const useNativeNotification = !!config.title;
887
+ const renderTarget = _nullishCoalesce(config.target, () => ( "active"));
888
+ const nativeTarget = _nullishCoalesce(config.target, () => ( "leader"));
892
889
  if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "default") {
893
890
  Notification.requestPermission();
894
891
  }
895
892
  return this.on(event, ((data) => {
896
893
  const typed = data;
897
- if (leaderOnly && this.tabRole !== "leader") return;
898
- if (onlyWhenHidden && typeof document !== "undefined" && !document.hidden) return;
894
+ const isVisible = typeof document !== "undefined" && !document.hidden;
895
+ const isLeader = this.tabRole === "leader";
899
896
  if (config.render) {
900
- config.render(typed);
901
- this.log.debug("[SharedWS] \u{1F514} push render", event);
897
+ const shouldRender = renderTarget === "all" || renderTarget === "active" && isVisible || renderTarget === "leader" && isLeader;
898
+ if (shouldRender) {
899
+ config.render(typed);
900
+ this.log.debug("[SharedWS] \u{1F514} render", event, `(target: ${renderTarget})`);
901
+ }
902
902
  }
903
903
  if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "granted") {
904
- const title = typeof config.title === "function" ? config.title(typed) : config.title;
905
- const body = typeof config.body === "function" ? config.body(typed) : config.body;
906
- const tag = typeof config.tag === "function" ? config.tag(typed) : config.tag;
907
- const notif = new Notification(title, { body, icon: config.icon, tag });
908
- if (config.onClick) {
909
- const handler = config.onClick;
910
- notif.onclick = () => {
911
- handler(typed);
912
- window.focus();
913
- };
904
+ const shouldNotify = nativeTarget === "all" || nativeTarget === "leader" && isLeader || nativeTarget === "active" && isVisible;
905
+ if (shouldNotify && !isVisible) {
906
+ const title = typeof config.title === "function" ? config.title(typed) : config.title;
907
+ const body = typeof config.body === "function" ? config.body(typed) : config.body;
908
+ const tag = typeof config.tag === "function" ? config.tag(typed) : config.tag;
909
+ const notif = new Notification(title, { body, icon: config.icon, tag });
910
+ if (config.onClick) {
911
+ const handler = config.onClick;
912
+ notif.onclick = () => {
913
+ handler(typed);
914
+ window.focus();
915
+ };
916
+ }
917
+ this.log.debug("[SharedWS] \u{1F514} native", title, `(target: ${nativeTarget})`);
914
918
  }
915
- this.log.debug("[SharedWS] \u{1F514} push native", title);
916
919
  }
917
920
  }));
918
921
  }
@@ -1019,4 +1022,4 @@ var SharedWebSocket = (_class6 = class {
1019
1022
 
1020
1023
 
1021
1024
  exports.MessageBus = MessageBus; exports.TabCoordinator = TabCoordinator; exports.SharedSocket = SharedSocket; exports.WorkerSocket = WorkerSocket; exports.SubscriptionManager = SubscriptionManager; exports.SharedWebSocket = SharedWebSocket;
1022
- //# sourceMappingURL=chunk-PH7NVGUX.cjs.map
1025
+ //# sourceMappingURL=chunk-LGNDGW4C.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/chunk-LGNDGW4C.cjs","../src/utils/disposable.ts","../src/utils/id.ts","../src/MessageBus.ts","../src/TabCoordinator.ts","../src/utils/backoff.ts","../src/SharedSocket.ts","../src/WorkerSocket.ts","../src/SubscriptionManager.ts","../src/SharedWebSocket.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/chunk-LGNDGW4C.cjs","sourcesContent":[null,"/** Polyfill Symbol.dispose if not available. */\nif (typeof Symbol.dispose === 'undefined') {\n (Symbol as any).dispose = Symbol('Symbol.dispose');\n}\n","export function generateId(): string {\n if (typeof crypto !== 'undefined' && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport type { BusMessage, Unsubscribe } from './types';\n\ntype Listener = (msg: BusMessage) => void;\n\nexport class MessageBus implements Disposable {\n private channel: BroadcastChannel;\n private listeners = new Map<string, Set<Listener>>();\n private pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();\n\n constructor(\n channelName: string,\n private readonly tabId: string,\n ) {\n this.channel = new BroadcastChannel(channelName);\n this.channel.onmessage = (ev: MessageEvent<BusMessage>) => {\n this.handleMessage(ev.data);\n };\n }\n\n subscribe<T>(topic: string, fn: (data: T) => void): Unsubscribe {\n const wrapper: Listener = (msg) => {\n if (msg.source !== this.tabId) fn(msg.data as T);\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n publish<T>(topic: string, data: T): void {\n this.postMessage({ topic, type: 'publish', data });\n }\n\n broadcast<T>(topic: string, data: T): void {\n const msg = this.createMessage(topic, 'broadcast', data);\n this.channel.postMessage(msg);\n // Also deliver to self\n this.handleMessage(msg);\n }\n\n async request<T, R>(topic: string, data: T, timeout = 5000): Promise<R> {\n const msg = this.createMessage(topic, 'request', data);\n return new Promise<R>((resolve, reject) => {\n const timer = setTimeout(() => {\n this.pendingRequests.delete(msg.id);\n reject(new Error(`MessageBus.request: timeout for topic \"${topic}\"`));\n }, timeout);\n this.pendingRequests.set(msg.id, { resolve: resolve as (v: unknown) => void, reject, timer });\n this.channel.postMessage(msg);\n });\n }\n\n respond<T, R>(topic: string, fn: (data: T) => R | Promise<R>): Unsubscribe {\n const wrapper: Listener = async (msg) => {\n if (msg.type !== 'request' || msg.source === this.tabId) return;\n const result = await fn(msg.data as T);\n this.postMessage({ topic, type: 'response', data: { requestId: msg.id, result } });\n };\n this.addListener(topic, wrapper);\n return () => this.removeListener(topic, wrapper);\n }\n\n private handleMessage(msg: BusMessage): void {\n // Handle response to pending request\n if (msg.type === 'response') {\n const payload = msg.data as { requestId: string; result: unknown };\n const pending = this.pendingRequests.get(payload.requestId);\n if (pending) {\n clearTimeout(pending.timer);\n this.pendingRequests.delete(payload.requestId);\n pending.resolve(payload.result);\n return;\n }\n }\n\n const listeners = this.listeners.get(msg.topic);\n if (listeners) {\n for (const fn of listeners) fn(msg);\n }\n }\n\n private postMessage(partial: Pick<BusMessage, 'topic' | 'type' | 'data'>): void {\n this.channel.postMessage(this.createMessage(partial.topic, partial.type, partial.data));\n }\n\n private createMessage(topic: string, type: BusMessage['type'], data: unknown): BusMessage {\n return { id: generateId(), source: this.tabId, topic, type, data, timestamp: Date.now() };\n }\n\n private addListener(topic: string, fn: Listener): void {\n let set = this.listeners.get(topic);\n if (!set) {\n set = new Set();\n this.listeners.set(topic, set);\n }\n set.add(fn);\n }\n\n private removeListener(topic: string, fn: Listener): void {\n this.listeners.get(topic)?.delete(fn);\n }\n\n [Symbol.dispose](): void {\n for (const pending of this.pendingRequests.values()) {\n clearTimeout(pending.timer);\n pending.reject(new Error('MessageBus disposed'));\n }\n this.pendingRequests.clear();\n this.listeners.clear();\n this.channel.close();\n }\n}\n","import './utils/disposable';\nimport { MessageBus } from './MessageBus';\nimport type { Unsubscribe } from './types';\n\ninterface CoordinatorOptions {\n electionTimeout?: number; // ms to wait for rejection (default 200)\n heartbeatInterval?: number; // ms between heartbeats (default 2000)\n leaderTimeout?: number; // ms without heartbeat to trigger election (default 5000)\n}\n\nexport class TabCoordinator implements Disposable {\n private _isLeader = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private leaderCheckTimer: ReturnType<typeof setInterval> | null = null;\n private lastHeartbeat = 0;\n private disposed = false;\n\n private onBecomeLeaderFns = new Set<() => void>();\n private onLoseLeadershipFns = new Set<() => void>();\n private cleanups: Unsubscribe[] = [];\n\n private readonly electionTimeout: number;\n private readonly heartbeatInterval: number;\n private readonly leaderTimeout: number;\n\n constructor(\n private readonly bus: MessageBus,\n private readonly tabId: string,\n options: CoordinatorOptions = {},\n ) {\n this.electionTimeout = options.electionTimeout ?? 200;\n this.heartbeatInterval = options.heartbeatInterval ?? 2000;\n this.leaderTimeout = options.leaderTimeout ?? 5000;\n\n // Listen for election requests — reject if we are leader\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:election', () => {\n if (this._isLeader) {\n this.bus.publish('coord:reject', { tabId: this.tabId });\n }\n }),\n );\n\n // Listen for heartbeats\n this.cleanups.push(\n this.bus.subscribe<{ tabId: string }>('coord:heartbeat', () => {\n this.lastHeartbeat = Date.now();\n }),\n );\n\n // Listen for abdication\n this.cleanups.push(\n this.bus.subscribe('coord:abdicate', () => {\n if (!this._isLeader && !this.disposed) {\n this.elect();\n }\n }),\n );\n }\n\n get isLeader(): boolean {\n return this._isLeader;\n }\n\n async elect(): Promise<void> {\n if (this.disposed) return;\n\n return new Promise<void>((resolve) => {\n let rejected = false;\n\n const unsub = this.bus.subscribe('coord:reject', () => {\n rejected = true;\n unsub();\n // We are follower — start monitoring leader heartbeat\n this.startLeaderCheck();\n resolve();\n });\n\n this.bus.publish('coord:election', { tabId: this.tabId });\n\n setTimeout(() => {\n unsub();\n if (!rejected && !this.disposed) {\n this.becomeLeader();\n }\n resolve();\n }, this.electionTimeout);\n });\n }\n\n abdicate(): void {\n if (!this._isLeader) return;\n this._isLeader = false;\n this.stopHeartbeat();\n this.bus.publish('coord:abdicate', { tabId: this.tabId });\n for (const fn of this.onLoseLeadershipFns) fn();\n }\n\n onBecomeLeader(fn: () => void): Unsubscribe {\n this.onBecomeLeaderFns.add(fn);\n return () => this.onBecomeLeaderFns.delete(fn);\n }\n\n onLoseLeadership(fn: () => void): Unsubscribe {\n this.onLoseLeadershipFns.add(fn);\n return () => this.onLoseLeadershipFns.delete(fn);\n }\n\n private becomeLeader(): void {\n this._isLeader = true;\n this.stopLeaderCheck();\n this.startHeartbeat();\n for (const fn of this.onBecomeLeaderFns) fn();\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }, this.heartbeatInterval);\n // Send immediately\n this.bus.publish('coord:heartbeat', { tabId: this.tabId });\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private startLeaderCheck(): void {\n this.stopLeaderCheck();\n this.lastHeartbeat = Date.now();\n this.leaderCheckTimer = setInterval(() => {\n if (Date.now() - this.lastHeartbeat > this.leaderTimeout && !this.disposed) {\n this.stopLeaderCheck();\n this.elect();\n }\n }, 1000);\n }\n\n private stopLeaderCheck(): void {\n if (this.leaderCheckTimer) {\n clearInterval(this.leaderCheckTimer);\n this.leaderCheckTimer = null;\n }\n }\n\n [Symbol.dispose](): void {\n this.disposed = true;\n if (this._isLeader) {\n this.abdicate();\n }\n this.stopHeartbeat();\n this.stopLeaderCheck();\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.onBecomeLeaderFns.clear();\n this.onLoseLeadershipFns.clear();\n }\n}\n","/** Exponential backoff generator with jitter. */\nexport function* backoff(base = 1000, max = 30_000): Generator<number> {\n let delay = base;\n while (true) {\n const jitter = delay * 0.25 * (Math.random() * 2 - 1);\n yield Math.min(delay + jitter, max);\n delay = Math.min(delay * 2, max);\n }\n}\n","import './utils/disposable';\nimport { backoff } from './utils/backoff';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\ninterface SharedSocketOptions {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n heartbeatInterval?: number;\n sendBuffer?: number;\n auth?: () => string | Promise<string>;\n authToken?: string;\n authParam?: string;\n /** Heartbeat payload (default: { type: \"ping\" }). */\n pingPayload?: unknown;\n}\n\nexport class SharedSocket implements Disposable {\n private ws: WebSocket | null = null;\n private _state: SocketState = 'closed';\n private buffer: unknown[] = [];\n private disposed = false;\n private heartbeatTimer: ReturnType<typeof setInterval> | null = null;\n private reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload'>> & {\n auth?: () => string | Promise<string>;\n authToken?: string;\n authParam: string;\n pingPayload: unknown;\n };\n\n constructor(\n private url: string,\n options: SharedSocketOptions = {},\n ) {\n this.opts = {\n protocols: options.protocols ?? [],\n reconnect: options.reconnect ?? true,\n reconnectMaxDelay: options.reconnectMaxDelay ?? 30_000,\n heartbeatInterval: options.heartbeatInterval ?? 30_000,\n sendBuffer: options.sendBuffer ?? 100,\n auth: options.auth,\n authToken: options.authToken,\n authParam: options.authParam ?? 'token',\n pingPayload: options.pingPayload ?? { type: 'ping' },\n };\n }\n\n get state(): SocketState {\n return this._state;\n }\n\n async connect(): Promise<void> {\n if (this.disposed) return;\n\n this.setState('connecting');\n\n const connectUrl = await this.buildUrl();\n this.ws = new WebSocket(connectUrl, this.opts.protocols);\n\n this.ws.onopen = () => {\n this.setState('connected');\n this.flushBuffer();\n this.startHeartbeat();\n };\n\n this.ws.onmessage = (ev: MessageEvent) => {\n let data: unknown;\n try {\n data = JSON.parse(ev.data as string);\n } catch {\n data = ev.data;\n }\n for (const fn of this.onMessageFns) fn(data);\n };\n\n this.ws.onclose = () => {\n this.stopHeartbeat();\n if (!this.disposed && this.opts.reconnect) {\n this.reconnect();\n } else {\n this.setState('closed');\n }\n };\n\n this.ws.onerror = () => {\n // onclose will fire after onerror\n };\n }\n\n disconnect(): void {\n this.disposed = true;\n this.stopHeartbeat();\n this.clearReconnect();\n\n if (this.ws) {\n this.ws.onclose = null;\n this.ws.onmessage = null;\n this.ws.onerror = null;\n if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {\n this.ws.close(1000, 'client disconnect');\n }\n this.ws = null;\n }\n\n this.setState('closed');\n }\n\n send(data: unknown): void {\n if (this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify(data));\n } else if (this._state === 'reconnecting' || this._state === 'connecting') {\n if (this.buffer.length < this.opts.sendBuffer) {\n this.buffer.push(data);\n }\n }\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n private reconnect(): void {\n this.setState('reconnecting');\n const gen = backoff(1000, this.opts.reconnectMaxDelay);\n\n const attempt = () => {\n if (this.disposed) return;\n const delay = gen.next().value;\n this.reconnectTimer = setTimeout(() => {\n if (!this.disposed) this.connect();\n }, delay);\n };\n\n attempt();\n }\n\n private flushBuffer(): void {\n const pending = this.buffer.splice(0);\n for (const item of pending) {\n this.send(item);\n }\n }\n\n private startHeartbeat(): void {\n this.stopHeartbeat();\n this.heartbeatTimer = setInterval(() => {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify(this.opts.pingPayload));\n }\n }, this.opts.heartbeatInterval);\n }\n\n private stopHeartbeat(): void {\n if (this.heartbeatTimer) {\n clearInterval(this.heartbeatTimer);\n this.heartbeatTimer = null;\n }\n }\n\n private clearReconnect(): void {\n if (this.reconnectTimer) {\n clearTimeout(this.reconnectTimer);\n this.reconnectTimer = null;\n }\n }\n\n private async buildUrl(): Promise<string> {\n // Resolve token: callback > static > none\n let token: string | undefined;\n if (this.opts.auth) {\n token = await this.opts.auth();\n } else if (this.opts.authToken) {\n token = this.opts.authToken;\n }\n\n if (!token) return this.url;\n\n // WebSocket URLs (ws://, wss://) are not fully supported by URL API.\n // Convert to http(s) for parsing, then back to ws(s).\n const httpUrl = this.url.replace(/^ws(s?):\\/\\//, 'http$1://');\n const parsed = new URL(httpUrl);\n parsed.searchParams.set(this.opts.authParam, token);\n\n return parsed.toString().replace(/^http(s?):\\/\\//, 'ws$1://');\n }\n\n private setState(state: SocketState): void {\n this._state = state;\n for (const fn of this.onStateChangeFns) fn(state);\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n this.buffer = [];\n }\n}\n","import './utils/disposable';\nimport type { SocketState, Unsubscribe, EventHandler } from './types';\n\n/**\n * WorkerSocket — WebSocket running inside a Web Worker.\n *\n * Same interface as SharedSocket, but WebSocket lives off main thread.\n * Benefits: heartbeat timers and JSON parsing don't block UI rendering.\n *\n * Use when:\n * - High message rate (50+ msgs/sec)\n * - Heavy JSON payloads\n * - UI does complex rendering that could block main thread\n *\n * Don't use when:\n * - Low message rate (simple chat, notifications)\n * - Bundle size matters (adds worker file)\n * - Debugging (Worker DevTools is less convenient)\n */\nexport class WorkerSocket implements Disposable {\n private worker: Worker | null = null;\n private _state: SocketState = 'closed';\n\n private onMessageFns = new Set<EventHandler>();\n private onStateChangeFns = new Set<(state: SocketState) => void>();\n\n constructor(\n private url: string,\n private options: {\n protocols?: string[];\n reconnect?: boolean;\n reconnectMaxDelay?: number;\n heartbeatInterval?: number;\n sendBuffer?: number;\n workerUrl?: string | URL;\n } = {},\n ) {}\n\n get state(): SocketState {\n return this._state;\n }\n\n connect(): void {\n // Create worker from inline blob if no workerUrl provided\n const workerUrl = this.options.workerUrl ?? this.createWorkerBlob();\n\n this.worker = new Worker(workerUrl, { type: 'module' });\n\n this.worker.onmessage = (ev: MessageEvent) => {\n const msg = ev.data;\n\n switch (msg.type) {\n case 'state':\n this._state = msg.state;\n for (const fn of this.onStateChangeFns) fn(msg.state);\n break;\n\n case 'message':\n for (const fn of this.onMessageFns) fn(msg.data);\n break;\n\n case 'open':\n // State already set via 'state' message\n break;\n\n case 'close':\n break;\n\n case 'error':\n console.error('WorkerSocket error:', msg.message);\n break;\n }\n };\n\n this.worker.postMessage({\n type: 'connect',\n url: this.url,\n protocols: this.options.protocols ?? [],\n reconnect: this.options.reconnect ?? true,\n reconnectMaxDelay: this.options.reconnectMaxDelay ?? 30_000,\n heartbeatInterval: this.options.heartbeatInterval ?? 30_000,\n bufferSize: this.options.sendBuffer ?? 100,\n });\n }\n\n send(data: unknown): void {\n this.worker?.postMessage({ type: 'send', data });\n }\n\n disconnect(): void {\n this.worker?.postMessage({ type: 'disconnect' });\n setTimeout(() => {\n this.worker?.terminate();\n this.worker = null;\n }, 100);\n this._state = 'closed';\n }\n\n onMessage(fn: EventHandler): Unsubscribe {\n this.onMessageFns.add(fn);\n return () => this.onMessageFns.delete(fn);\n }\n\n onStateChange(fn: (state: SocketState) => void): Unsubscribe {\n this.onStateChangeFns.add(fn);\n return () => this.onStateChangeFns.delete(fn);\n }\n\n private createWorkerBlob(): URL {\n // Inline the worker code as a blob URL\n // In production, use a bundler (Vite, webpack) to handle worker imports\n const code = `\n let ws = null, state = 'closed', buffer = [], disposed = false;\n let heartbeatTimer = null, reconnectTimer = null;\n let url = '', protocols = [], shouldReconnect = true;\n let maxDelay = 30000, hbInterval = 30000, maxBuf = 100, delay = 1000;\n\n function setState(s) { state = s; self.postMessage({ type: 'state', state: s }); }\n function connect() {\n if (disposed) return;\n setState('connecting');\n ws = new WebSocket(url, protocols);\n ws.onopen = () => { setState('connected'); delay = 1000; self.postMessage({ type: 'open' }); flush(); startHB(); };\n ws.onmessage = (e) => { let d; try { d = JSON.parse(e.data); } catch { d = e.data; } self.postMessage({ type: 'message', data: d }); };\n ws.onclose = (e) => { stopHB(); self.postMessage({ type: 'close', code: e.code, reason: e.reason }); if (!disposed && shouldReconnect && e.code !== 1000) reconnect(); else setState('closed'); };\n ws.onerror = () => { self.postMessage({ type: 'error', message: 'error' }); };\n }\n function send(d) { if (state === 'connected' && ws?.readyState === 1) ws.send(JSON.stringify(d)); else if (buffer.length < maxBuf) buffer.push(d); }\n function flush() { const p = buffer.splice(0); p.forEach(send); }\n function startHB() { stopHB(); heartbeatTimer = setInterval(() => { if (ws?.readyState === 1) ws.send('{\"type\":\"ping\"}'); }, hbInterval); }\n function stopHB() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }\n 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); }\n self.onmessage = (e) => {\n const c = e.data;\n 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(); }\n if (c.type === 'send') send(c.data);\n 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'); }\n };\n `;\n const blob = new Blob([code], { type: 'application/javascript' });\n return new URL(URL.createObjectURL(blob));\n }\n\n [Symbol.dispose](): void {\n this.disconnect();\n this.onMessageFns.clear();\n this.onStateChangeFns.clear();\n }\n}\n","import './utils/disposable';\nimport type { EventHandler, Unsubscribe } from './types';\n\nexport class SubscriptionManager implements Disposable {\n private handlers = new Map<string, Set<EventHandler>>();\n private lastMessages = new Map<string, unknown>();\n\n on(event: string, handler: EventHandler): Unsubscribe {\n let set = this.handlers.get(event);\n if (!set) {\n set = new Set();\n this.handlers.set(event, set);\n }\n set.add(handler);\n return () => set!.delete(handler);\n }\n\n once(event: string, handler: EventHandler): Unsubscribe {\n const wrapper: EventHandler = (data) => {\n unsub();\n handler(data);\n };\n const unsub = this.on(event, wrapper);\n return unsub;\n }\n\n off(event: string, handler?: EventHandler): void {\n if (handler) {\n this.handlers.get(event)?.delete(handler);\n } else {\n this.handlers.delete(event);\n }\n }\n\n emit(event: string, data: unknown): void {\n this.lastMessages.set(event, data);\n const set = this.handlers.get(event);\n if (set) {\n for (const fn of set) fn(data);\n }\n }\n\n getLastMessage(event: string): unknown | undefined {\n return this.lastMessages.get(event);\n }\n\n async *stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n const queue: unknown[] = [];\n let resolve: (() => void) | null = null;\n let done = false;\n\n const unsub = this.on(event, (data) => {\n queue.push(data);\n resolve?.();\n });\n\n const onAbort = () => {\n done = true;\n resolve?.();\n };\n signal?.addEventListener('abort', onAbort);\n\n try {\n while (!done) {\n if (queue.length > 0) {\n yield queue.shift()!;\n } else {\n await new Promise<void>((r) => { resolve = r; });\n resolve = null;\n }\n }\n } finally {\n unsub();\n signal?.removeEventListener('abort', onAbort);\n }\n }\n\n offAll(): void {\n this.handlers.clear();\n this.lastMessages.clear();\n }\n\n [Symbol.dispose](): void {\n this.offAll();\n }\n}\n","import './utils/disposable';\nimport { generateId } from './utils/id';\nimport { MessageBus } from './MessageBus';\nimport { TabCoordinator } from './TabCoordinator';\nimport { SharedSocket } from './SharedSocket';\nimport { WorkerSocket } from './WorkerSocket';\nimport { SubscriptionManager } from './SubscriptionManager';\nimport type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware } from './types';\n\nconst DEFAULT_PROTOCOL: EventProtocol = {\n eventField: 'event',\n dataField: 'data',\n channelJoin: '$channel:join',\n channelLeave: '$channel:leave',\n ping: { type: 'ping' },\n defaultEvent: 'message',\n topicSubscribe: '$topic:subscribe',\n topicUnsubscribe: '$topic:unsubscribe',\n};\n\nconst NOOP_LOGGER: Logger = {\n debug() {},\n info() {},\n warn() {},\n error() {},\n};\n\n/** Common interface for both SharedSocket and WorkerSocket. */\ninterface SocketAdapter {\n readonly state: string;\n connect(): void;\n send(data: unknown): void;\n disconnect(): void;\n onMessage(fn: EventHandler): Unsubscribe;\n onStateChange(fn: (state: string) => void): Unsubscribe;\n [Symbol.dispose](): void;\n}\n\n/**\n * SharedWebSocket — shares ONE WebSocket connection across browser tabs.\n *\n * @typeParam TEvents - Event map for type-safe subscriptions.\n *\n * @example\n * // Typed events\n * type Events = {\n * 'chat.message': { text: string; userId: string };\n * 'order.created': { id: string; total: number };\n * };\n * const ws = new SharedWebSocket<Events>(url);\n * ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId }\n */\nexport class SharedWebSocket<TEvents extends EventMap = EventMap> implements Disposable {\n private bus: MessageBus;\n private coordinator: TabCoordinator;\n private socket: SocketAdapter | null = null;\n private subs = new SubscriptionManager();\n private syncStore = new Map<string, unknown>();\n private tabId: string;\n private cleanups: Unsubscribe[] = [];\n private disposed = false;\n private readonly proto: EventProtocol;\n private readonly log: Logger;\n private outgoingMiddleware: Middleware[] = [];\n private incomingMiddleware: Middleware[] = [];\n\n constructor(\n private readonly url: string,\n private readonly options: SharedWebSocketOptions<TEvents> = {} as SharedWebSocketOptions<TEvents>,\n ) {\n this.proto = { ...DEFAULT_PROTOCOL, ...options.events };\n this.log = options.debug ? (options.logger ?? console) : NOOP_LOGGER;\n this.tabId = generateId();\n this.log.debug('[SharedWS] init', { tabId: this.tabId, url });\n this.bus = new MessageBus('shared-ws', this.tabId);\n this.coordinator = new TabCoordinator(this.bus, this.tabId, {\n electionTimeout: options.electionTimeout,\n heartbeatInterval: options.leaderHeartbeat,\n leaderTimeout: options.leaderTimeout,\n });\n\n // When ANY tab receives a WS message via bus → emit to local subscribers\n this.cleanups.push(\n this.bus.subscribe<{ event: string; data: unknown }>('ws:message', (msg) => {\n this.subs.emit(msg.event, msg.data);\n }),\n );\n\n // Leader listens for send requests from followers\n this.cleanups.push(\n this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {\n if (this.coordinator.isLeader && this.socket) {\n this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });\n }\n }),\n );\n\n // Sync across tabs\n this.cleanups.push(\n this.bus.subscribe<{ key: string; value: unknown }>('ws:sync', (msg) => {\n this.syncStore.set(msg.key, msg.value);\n this.subs.emit(`sync:${msg.key}`, msg.value);\n }),\n );\n\n // Leader lifecycle\n this.coordinator.onBecomeLeader(() => {\n this.handleBecomeLeader();\n this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: true });\n });\n this.coordinator.onLoseLeadership(() => {\n this.handleLoseLeadership();\n this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: false });\n });\n\n // Lifecycle events from bus (all tabs receive)\n this.cleanups.push(\n this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown }>('ws:lifecycle', (msg) => {\n switch (msg.type) {\n case 'connect':\n this.subs.emit('$lifecycle:connect', undefined);\n break;\n case 'disconnect':\n this.subs.emit('$lifecycle:disconnect', undefined);\n break;\n case 'reconnecting':\n this.subs.emit('$lifecycle:reconnecting', undefined);\n break;\n case 'leader':\n this.subs.emit('$lifecycle:leader', msg.isLeader);\n break;\n case 'error':\n this.subs.emit('$lifecycle:error', msg.error);\n break;\n }\n }),\n );\n\n // Cleanup on tab close\n if (typeof window !== 'undefined') {\n const onBeforeUnload = () => this[Symbol.dispose]();\n window.addEventListener('beforeunload', onBeforeUnload);\n this.cleanups.push(() => window.removeEventListener('beforeunload', onBeforeUnload));\n }\n }\n\n get connected(): boolean {\n return this.socket?.state === 'connected' || !this.coordinator.isLeader;\n }\n\n get tabRole(): TabRole {\n return this.coordinator.isLeader ? 'leader' : 'follower';\n }\n\n /** Start leader election and connect. */\n async connect(): Promise<void> {\n await this.coordinator.elect();\n }\n\n // ─── Lifecycle Hooks ─────────────────────────────────\n\n /** Called when WebSocket connection opens (broadcast to all tabs). */\n onConnect(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:connect', fn);\n }\n\n /** Called when WebSocket connection closes (broadcast to all tabs). */\n onDisconnect(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:disconnect', fn);\n }\n\n /** Called when WebSocket starts reconnecting (broadcast to all tabs). */\n onReconnecting(fn: () => void): Unsubscribe {\n return this.subs.on('$lifecycle:reconnecting', fn);\n }\n\n /** Called when this tab becomes leader or loses leadership. */\n onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {\n return this.subs.on('$lifecycle:leader', fn as EventHandler);\n }\n\n /** Called on WebSocket or network error (broadcast to all tabs). */\n onError(fn: (error: unknown) => void): Unsubscribe {\n return this.subs.on('$lifecycle:error', fn as EventHandler);\n }\n\n // ─── Middleware ───────────────────────────────────────\n\n /**\n * Add middleware to transform messages before send or after receive.\n * Return null from middleware to drop the message.\n *\n * @example\n * // Add timestamp to every outgoing message\n * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));\n *\n * @example\n * // Decrypt incoming messages\n * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));\n *\n * @example\n * // Drop messages from blocked users\n * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);\n */\n use(direction: 'outgoing' | 'incoming', fn: Middleware): this {\n if (direction === 'outgoing') {\n this.outgoingMiddleware.push(fn);\n } else {\n this.incomingMiddleware.push(fn);\n }\n return this;\n }\n\n // ─── Event Subscription ──────────────────────────────\n\n /** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */\n on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;\n on(event: string, handler: EventHandler<unknown>): Unsubscribe;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n on(event: string, handler: (data: any) => void): Unsubscribe {\n return this.subs.on(event, handler);\n }\n\n once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;\n once(event: string, handler: EventHandler<unknown>): Unsubscribe;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n once(event: string, handler: (data: any) => void): Unsubscribe {\n return this.subs.once(event, handler);\n }\n\n off(event: string, handler?: EventHandler): void {\n this.subs.off(event, handler);\n }\n\n /** Async generator for consuming events. Type-safe with EventMap. */\n stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n return this.subs.stream(event, signal);\n }\n\n /** Send message to server (auto-routed through leader). Type-safe with EventMap. */\n send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;\n send(event: string, data: unknown): void;\n send(event: string, data: unknown): void {\n let payload: unknown = { [this.proto.eventField]: event, [this.proto.dataField]: data };\n\n for (const mw of this.outgoingMiddleware) {\n payload = mw(payload);\n if (payload === null) {\n this.log.debug('[SharedWS] ✗ outgoing dropped by middleware', event);\n return;\n }\n }\n\n this.log.debug('[SharedWS] → send', event, data);\n\n if (this.coordinator.isLeader && this.socket) {\n this.socket.send(payload);\n } else {\n this.bus.publish('ws:send', { event, data });\n }\n }\n\n /** Request/response through server via leader. */\n async request<T>(event: string, data: unknown, timeout = 5000): Promise<T> {\n return this.bus.request('ws:request', { event, data }, timeout);\n }\n\n /** Sync state across tabs (no server roundtrip). */\n sync<T>(key: string, value: T): void {\n this.syncStore.set(key, value);\n this.bus.broadcast('ws:sync', { key, value });\n }\n\n getSync<T>(key: string): T | undefined {\n return this.syncStore.get(key) as T | undefined;\n }\n\n onSync<T>(key: string, fn: (value: T) => void): Unsubscribe {\n return this.subs.on(`sync:${key}`, fn as EventHandler);\n }\n\n /**\n * Subscribe to a private/scoped channel. Returns a channel handle with\n * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.\n *\n * @example\n * const chat = ws.channel('chat:room_123');\n * chat.on('message', (msg) => render(msg));\n * chat.send('message', { text: 'Hello' });\n * chat.leave(); // sends leave + unsubscribes\n *\n * @example\n * // Private notifications for tenant\n * const notifications = ws.channel(`tenant:${tenantId}:notifications`);\n * notifications.on('alert', (alert) => showToast(alert));\n */\n channel(name: string): Channel {\n // Notify server about channel subscription\n this.send(this.proto.channelJoin, { channel: name });\n\n const self = this;\n const unsubs: Unsubscribe[] = [];\n\n return {\n name,\n on(event: string, handler: EventHandler): Unsubscribe {\n const unsub = self.subs.on(`${name}:${event}`, handler);\n unsubs.push(unsub);\n return unsub;\n },\n once(event: string, handler: EventHandler): Unsubscribe {\n const unsub = self.subs.once(`${name}:${event}`, handler);\n unsubs.push(unsub);\n return unsub;\n },\n send(event: string, data: unknown): void {\n self.send(`${name}:${event}`, data);\n },\n stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {\n return self.subs.stream(`${name}:${event}`, signal);\n },\n leave(): void {\n self.send(self.proto.channelLeave, { channel: name });\n for (const unsub of unsubs) unsub();\n unsubs.length = 0;\n },\n };\n }\n\n // ─── Topics ──────────────────────────────────────────\n\n /**\n * Subscribe to a server-side topic. Server will start sending events for this topic.\n * Sends topicSubscribe event (default: \"$topic:subscribe\").\n *\n * @example\n * ws.subscribe('notifications:orders');\n * ws.subscribe('notifications:payments');\n * ws.subscribe(`user:${userId}:mentions`);\n */\n subscribe(topic: string): void {\n this.send(this.proto.topicSubscribe, { topic });\n this.log.debug('[SharedWS] 📌 subscribe topic', topic);\n }\n\n /**\n * Unsubscribe from a server-side topic.\n * Sends topicUnsubscribe event (default: \"$topic:unsubscribe\").\n */\n unsubscribe(topic: string): void {\n this.send(this.proto.topicUnsubscribe, { topic });\n this.log.debug('[SharedWS] 📌 unsubscribe topic', topic);\n }\n\n // ─── Push Notifications ─────────────────────────────\n\n /**\n * Subscribe to an event and show notifications.\n *\n * **target** controls which tab(s) display the notification:\n * - `'active'` — only the currently visible tab (default for render)\n * - `'leader'` — only the leader tab (default for browser Notification)\n * - `'all'` — every tab (for critical alerts)\n *\n * @example\n * // Custom render — sonner toast on active tab only\n * ws.push('notification', {\n * render: (n) => toast(n.title),\n * target: 'active', // default for render\n * });\n *\n * @example\n * // Critical alert — show in ALL tabs\n * ws.push('payment.failed', {\n * render: (n) => toast.error('Payment failed!'),\n * target: 'all',\n * });\n *\n * @example\n * // Browser Notification — only from leader\n * ws.push('order.created', {\n * title: (order) => `New Order #${order.id}`,\n * target: 'leader', // default for browser Notification\n * });\n *\n * @example\n * // Both render + native with different targets\n * ws.push('order.created', {\n * render: (order) => toast(`Order #${order.id}`), // active tab\n * title: (order) => `New Order #${order.id}`, // leader → native\n * });\n */\n push<T = unknown>(\n event: string,\n config: {\n /** Custom render function — you decide how to display. */\n render?: (data: T) => void;\n /** Title for browser Notification API. */\n title?: string | ((data: T) => string);\n /** Body for browser Notification API. */\n body?: string | ((data: T) => string);\n /** Icon URL for browser Notification. */\n icon?: string;\n /** Tag for browser Notification deduplication. */\n tag?: string | ((data: T) => string);\n /**\n * Which tab(s) show the notification:\n * - `'active'` — only the visible/focused tab (default for render)\n * - `'leader'` — only the leader tab (default for browser Notification)\n * - `'all'` — every tab (critical alerts)\n */\n target?: 'active' | 'leader' | 'all';\n /** Called when browser Notification is clicked. */\n onClick?: (data: T) => void;\n },\n ): Unsubscribe {\n const useNativeNotification = !!config.title;\n\n // Default target: 'active' for render, 'leader' for native\n const renderTarget = config.target ?? 'active';\n const nativeTarget = config.target ?? 'leader';\n\n if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'default') {\n Notification.requestPermission();\n }\n\n return this.on(event, ((data: unknown) => {\n const typed = data as T;\n const isVisible = typeof document !== 'undefined' && !document.hidden;\n const isLeader = this.tabRole === 'leader';\n\n // Custom render\n if (config.render) {\n const shouldRender =\n renderTarget === 'all' ||\n (renderTarget === 'active' && isVisible) ||\n (renderTarget === 'leader' && isLeader);\n\n if (shouldRender) {\n config.render(typed);\n this.log.debug('[SharedWS] 🔔 render', event, `(target: ${renderTarget})`);\n }\n }\n\n // Browser Notification API\n if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'granted') {\n const shouldNotify =\n nativeTarget === 'all' ||\n (nativeTarget === 'leader' && isLeader) ||\n (nativeTarget === 'active' && isVisible);\n\n // Native notifications make sense when tab is hidden\n if (shouldNotify && !isVisible) {\n const title = typeof config.title === 'function' ? config.title(typed) : config.title!;\n const body = typeof config.body === 'function' ? config.body(typed) : config.body;\n const tag = typeof config.tag === 'function' ? config.tag(typed) : config.tag;\n\n const notif = new Notification(title, { body, icon: config.icon, tag });\n\n if (config.onClick) {\n const handler = config.onClick;\n notif.onclick = () => {\n handler(typed);\n window.focus();\n };\n }\n\n this.log.debug('[SharedWS] 🔔 native', title, `(target: ${nativeTarget})`);\n }\n }\n }) as EventHandler);\n }\n\n disconnect(): void {\n this[Symbol.dispose]();\n }\n\n private createSocket(): SocketAdapter {\n const socketOptions = {\n protocols: this.options.protocols,\n reconnect: this.options.reconnect,\n reconnectMaxDelay: this.options.reconnectMaxDelay,\n heartbeatInterval: this.options.heartbeatInterval,\n sendBuffer: this.options.sendBuffer,\n pingPayload: this.proto.ping,\n };\n\n if (this.options.useWorker) {\n // WebSocket runs in a Web Worker — main thread stays free\n return new WorkerSocket(this.url, {\n ...socketOptions,\n workerUrl: this.options.workerUrl,\n });\n }\n\n // WebSocket runs in main thread (default)\n return new SharedSocket(this.url, {\n ...socketOptions,\n auth: this.options.auth,\n authToken: this.options.authToken,\n authParam: this.options.authParam,\n });\n }\n\n private handleBecomeLeader(): void {\n this.log.info('[SharedWS] 👑 became leader');\n this.socket = this.createSocket();\n\n this.socket.onMessage((raw: unknown) => {\n let data: unknown = raw;\n for (const mw of this.incomingMiddleware) {\n data = mw(data);\n if (data === null) {\n this.log.debug('[SharedWS] ✗ incoming dropped by middleware');\n return;\n }\n }\n\n const msg = data as Record<string, unknown> | null | undefined;\n const event = (msg?.[this.proto.eventField] as string) ?? this.proto.defaultEvent;\n const payload = msg?.[this.proto.dataField] ?? data;\n this.log.debug('[SharedWS] ← recv', event, payload);\n this.bus.broadcast('ws:message', { event, data: payload });\n });\n\n this.socket.onStateChange((state: string) => {\n this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);\n switch (state) {\n case 'connected':\n this.bus.broadcast('ws:lifecycle', { type: 'connect' });\n break;\n case 'closed':\n this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });\n break;\n case 'reconnecting':\n this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });\n break;\n }\n });\n\n this.cleanups.push(\n this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {\n return new Promise((resolve) => {\n const unsub = this.socket!.onMessage((response: unknown) => {\n const res = response as Record<string, unknown> | undefined;\n if (res?.[this.proto.eventField] === req.event || res?.requestId) {\n unsub();\n resolve(res?.[this.proto.dataField] ?? response);\n }\n });\n this.socket!.send({ event: req.event, data: req.data });\n });\n }),\n );\n\n this.socket.connect();\n }\n\n private handleLoseLeadership(): void {\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n }\n\n [Symbol.dispose](): void {\n if (this.disposed) return;\n this.disposed = true;\n\n this.coordinator[Symbol.dispose]();\n\n if (this.socket) {\n this.socket[Symbol.dispose]();\n this.socket = null;\n }\n\n for (const unsub of this.cleanups) unsub();\n this.cleanups = [];\n this.subs[Symbol.dispose]();\n this.bus[Symbol.dispose]();\n this.syncStore.clear();\n }\n}\n"]}
@@ -849,70 +849,73 @@ var SharedWebSocket = class {
849
849
  /**
850
850
  * Subscribe to an event and show notifications.
851
851
  *
852
- * Two modes:
853
- * - **render**you control how to display (sonner, react-hot-toast, custom UI)
854
- * - **browser Notification API** native OS notifications (title/body/icon)
855
- *
856
- * Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
852
+ * **target** controls which tab(s) display the notification:
853
+ * - `'active'`only the currently visible tab (default for render)
854
+ * - `'leader'` only the leader tab (default for browser Notification)
855
+ * - `'all'` — every tab (for critical alerts)
857
856
  *
858
857
  * @example
859
- * // Custom render — sonner toast
860
- * import { toast } from 'sonner';
858
+ * // Custom render — sonner toast on active tab only
861
859
  * ws.push('notification', {
862
- * render: (data) => toast(data.title, { description: data.body }),
860
+ * render: (n) => toast(n.title),
861
+ * target: 'active', // default for render
863
862
  * });
864
863
  *
865
864
  * @example
866
- * // Custom renderreact-hot-toast
867
- * import toast from 'react-hot-toast';
868
- * ws.push('order.created', {
869
- * render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
865
+ * // Critical alertshow in ALL tabs
866
+ * ws.push('payment.failed', {
867
+ * render: (n) => toast.error('Payment failed!'),
868
+ * target: 'all',
870
869
  * });
871
870
  *
872
871
  * @example
873
- * // Browser Notification API (no render — uses title/body/icon)
874
- * ws.push('notification', {
875
- * title: (data) => data.title,
876
- * body: (data) => data.body,
877
- * icon: '/icons/bell.png',
872
+ * // Browser Notification only from leader
873
+ * ws.push('order.created', {
874
+ * title: (order) => `New Order #${order.id}`,
875
+ * target: 'leader', // default for browser Notification
878
876
  * });
879
877
  *
880
878
  * @example
881
- * // Both toast in UI + browser notification
879
+ * // Both render + native with different targets
882
880
  * ws.push('order.created', {
883
- * render: (order) => toast(`Order #${order.id}`),
884
- * title: (order) => `New Order #${order.id}`,
885
- * body: (order) => `$${order.total}`,
881
+ * render: (order) => toast(`Order #${order.id}`), // active tab
882
+ * title: (order) => `New Order #${order.id}`, // leader → native
886
883
  * });
887
884
  */
888
885
  push(event, config) {
889
- const leaderOnly = config.leaderOnly ?? true;
890
- const onlyWhenHidden = config.onlyWhenHidden ?? true;
891
886
  const useNativeNotification = !!config.title;
887
+ const renderTarget = config.target ?? "active";
888
+ const nativeTarget = config.target ?? "leader";
892
889
  if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "default") {
893
890
  Notification.requestPermission();
894
891
  }
895
892
  return this.on(event, ((data) => {
896
893
  const typed = data;
897
- if (leaderOnly && this.tabRole !== "leader") return;
898
- if (onlyWhenHidden && typeof document !== "undefined" && !document.hidden) return;
894
+ const isVisible = typeof document !== "undefined" && !document.hidden;
895
+ const isLeader = this.tabRole === "leader";
899
896
  if (config.render) {
900
- config.render(typed);
901
- this.log.debug("[SharedWS] \u{1F514} push render", event);
897
+ const shouldRender = renderTarget === "all" || renderTarget === "active" && isVisible || renderTarget === "leader" && isLeader;
898
+ if (shouldRender) {
899
+ config.render(typed);
900
+ this.log.debug("[SharedWS] \u{1F514} render", event, `(target: ${renderTarget})`);
901
+ }
902
902
  }
903
903
  if (useNativeNotification && typeof Notification !== "undefined" && Notification.permission === "granted") {
904
- const title = typeof config.title === "function" ? config.title(typed) : config.title;
905
- const body = typeof config.body === "function" ? config.body(typed) : config.body;
906
- const tag = typeof config.tag === "function" ? config.tag(typed) : config.tag;
907
- const notif = new Notification(title, { body, icon: config.icon, tag });
908
- if (config.onClick) {
909
- const handler = config.onClick;
910
- notif.onclick = () => {
911
- handler(typed);
912
- window.focus();
913
- };
904
+ const shouldNotify = nativeTarget === "all" || nativeTarget === "leader" && isLeader || nativeTarget === "active" && isVisible;
905
+ if (shouldNotify && !isVisible) {
906
+ const title = typeof config.title === "function" ? config.title(typed) : config.title;
907
+ const body = typeof config.body === "function" ? config.body(typed) : config.body;
908
+ const tag = typeof config.tag === "function" ? config.tag(typed) : config.tag;
909
+ const notif = new Notification(title, { body, icon: config.icon, tag });
910
+ if (config.onClick) {
911
+ const handler = config.onClick;
912
+ notif.onclick = () => {
913
+ handler(typed);
914
+ window.focus();
915
+ };
916
+ }
917
+ this.log.debug("[SharedWS] \u{1F514} native", title, `(target: ${nativeTarget})`);
914
918
  }
915
- this.log.debug("[SharedWS] \u{1F514} push native", title);
916
919
  }
917
920
  }));
918
921
  }
@@ -1019,4 +1022,4 @@ export {
1019
1022
  SubscriptionManager,
1020
1023
  SharedWebSocket
1021
1024
  };
1022
- //# sourceMappingURL=chunk-YRJ23OMG.js.map
1025
+ //# sourceMappingURL=chunk-TYYPQ457.js.map