@gwakko/shared-websocket 0.7.1 → 0.8.1

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/dist/types.d.ts CHANGED
@@ -106,6 +106,12 @@ export interface SocketLifecycleHandlers {
106
106
  onReconnecting?: () => void;
107
107
  onLeaderChange?: (isLeader: boolean) => void;
108
108
  onError?: (error: unknown) => void;
109
+ /** Called when this tab becomes visible/focused. */
110
+ onActive?: () => void;
111
+ /** Called when this tab goes to background/hidden. */
112
+ onInactive?: () => void;
113
+ /** Called on any visibility change. */
114
+ onVisibilityChange?: (isActive: boolean) => void;
109
115
  }
110
116
  /** Scoped channel handle for private/topic-based subscriptions. */
111
117
  export interface Channel {
package/dist/vue.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
- var _chunkPH7NVGUXcjs = require('./chunk-PH7NVGUX.cjs');
3
+ var _chunkQCINVFBGcjs = require('./chunk-QCINVFBG.cjs');
4
4
 
5
5
  // src/adapters/vue.ts
6
6
 
@@ -14,7 +14,7 @@ var SharedWebSocketKey = /* @__PURE__ */ Symbol("SharedWebSocket");
14
14
  function createSharedWebSocketPlugin(url, options) {
15
15
  return {
16
16
  install(app) {
17
- const socket = new (0, _chunkPH7NVGUXcjs.SharedWebSocket)(url, options);
17
+ const socket = new (0, _chunkQCINVFBGcjs.SharedWebSocket)(url, options);
18
18
  socket.connect();
19
19
  app.provide(SharedWebSocketKey, socket);
20
20
  const originalUnmount = app.unmount.bind(app);
@@ -108,6 +108,9 @@ function useSocketLifecycle(handlers) {
108
108
  if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
109
109
  if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
110
110
  if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
111
+ if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
112
+ if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));
113
+ if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));
111
114
  _vue.onUnmounted.call(void 0, () => unsubs.forEach((u) => u()));
112
115
  }
113
116
  function useChannel(name) {
package/dist/vue.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","../src/adapters/vue.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","sourcesContent":[null,"import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n\n onUnmounted(() => unsubs.forEach((u) => u()));\n}\n\n/**\n * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.\n *\n * @example\n * const chat = useChannel('chat:room_123');\n * // Listen via useSocketEvent('chat:room_123:message')\n * // Send via chat.send('message', { text: 'Hello' })\n */\nexport function useChannel(name: string) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name);\n\n onUnmounted(() => channel.leave());\n\n return channel;\n}\n\n/**\n * Subscribe to server-side topics. Auto-unsubscribes on unmount.\n *\n * @example\n * useTopics(['notifications:orders', 'notifications:payments']);\n */\nexport function useTopics(topics: string[]): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t));\n\n onUnmounted(() => {\n topics.forEach((t) => socket.unsubscribe(t));\n });\n}\n\n/**\n * Enable browser push notifications for an event. Auto-cleanup on unmount.\n *\n * @example\n * usePush('notification', {\n * title: (n) => n.title,\n * body: (n) => n.body,\n * icon: '/icon.png',\n * });\n */\nexport function usePush<T = unknown>(\n event: string,\n config: {\n title: string | ((data: T) => string);\n body?: string | ((data: T) => string);\n icon?: string;\n tag?: string | ((data: T) => string);\n leaderOnly?: boolean;\n onlyWhenHidden?: boolean;\n onClick?: (data: T) => void;\n },\n): void {\n const socket = useSharedWebSocket();\n const unsub = socket.push<T>(event, config);\n\n onUnmounted(unsub);\n}\n"]}
1
+ {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","../src/adapters/vue.ts"],"names":[],"mappings":"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","file":"/Users/gwakko/Projects/shared-websocket/dist/vue.cjs","sourcesContent":[null,"import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));\n if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));\n if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));\n\n onUnmounted(() => unsubs.forEach((u) => u()));\n}\n\n/**\n * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.\n *\n * @example\n * const chat = useChannel('chat:room_123');\n * // Listen via useSocketEvent('chat:room_123:message')\n * // Send via chat.send('message', { text: 'Hello' })\n */\nexport function useChannel(name: string) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name);\n\n onUnmounted(() => channel.leave());\n\n return channel;\n}\n\n/**\n * Subscribe to server-side topics. Auto-unsubscribes on unmount.\n *\n * @example\n * useTopics(['notifications:orders', 'notifications:payments']);\n */\nexport function useTopics(topics: string[]): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t));\n\n onUnmounted(() => {\n topics.forEach((t) => socket.unsubscribe(t));\n });\n}\n\n/**\n * Enable browser push notifications for an event. Auto-cleanup on unmount.\n *\n * @example\n * usePush('notification', {\n * title: (n) => n.title,\n * body: (n) => n.body,\n * icon: '/icon.png',\n * });\n */\nexport function usePush<T = unknown>(\n event: string,\n config: {\n title: string | ((data: T) => string);\n body?: string | ((data: T) => string);\n icon?: string;\n tag?: string | ((data: T) => string);\n leaderOnly?: boolean;\n onlyWhenHidden?: boolean;\n onClick?: (data: T) => void;\n },\n): void {\n const socket = useSharedWebSocket();\n const unsub = socket.push<T>(event, config);\n\n onUnmounted(unsub);\n}\n"]}
package/dist/vue.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SharedWebSocket
3
- } from "./chunk-YRJ23OMG.js";
3
+ } from "./chunk-TTOZNGEX.js";
4
4
 
5
5
  // src/adapters/vue.ts
6
6
  import {
@@ -108,6 +108,9 @@ function useSocketLifecycle(handlers) {
108
108
  if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
109
109
  if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
110
110
  if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
111
+ if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
112
+ if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));
113
+ if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));
111
114
  onUnmounted(() => unsubs.forEach((u) => u()));
112
115
  }
113
116
  function useChannel(name) {
package/dist/vue.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n\n onUnmounted(() => unsubs.forEach((u) => u()));\n}\n\n/**\n * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.\n *\n * @example\n * const chat = useChannel('chat:room_123');\n * // Listen via useSocketEvent('chat:room_123:message')\n * // Send via chat.send('message', { text: 'Hello' })\n */\nexport function useChannel(name: string) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name);\n\n onUnmounted(() => channel.leave());\n\n return channel;\n}\n\n/**\n * Subscribe to server-side topics. Auto-unsubscribes on unmount.\n *\n * @example\n * useTopics(['notifications:orders', 'notifications:payments']);\n */\nexport function useTopics(topics: string[]): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t));\n\n onUnmounted(() => {\n topics.forEach((t) => socket.unsubscribe(t));\n });\n}\n\n/**\n * Enable browser push notifications for an event. Auto-cleanup on unmount.\n *\n * @example\n * usePush('notification', {\n * title: (n) => n.title,\n * body: (n) => n.body,\n * icon: '/icon.png',\n * });\n */\nexport function usePush<T = unknown>(\n event: string,\n config: {\n title: string | ((data: T) => string);\n body?: string | ((data: T) => string);\n icon?: string;\n tag?: string | ((data: T) => string);\n leaderOnly?: boolean;\n onlyWhenHidden?: boolean;\n onClick?: (data: T) => void;\n },\n): void {\n const socket = useSharedWebSocket();\n const unsub = socket.push<T>(event, config);\n\n onUnmounted(unsub);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));\n if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));\n if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));\n\n onUnmounted(() => unsubs.forEach((u) => u()));\n}\n\n/**\n * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.\n *\n * @example\n * const chat = useChannel('chat:room_123');\n * // Listen via useSocketEvent('chat:room_123:message')\n * // Send via chat.send('message', { text: 'Hello' })\n */\nexport function useChannel(name: string) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name);\n\n onUnmounted(() => channel.leave());\n\n return channel;\n}\n\n/**\n * Subscribe to server-side topics. Auto-unsubscribes on unmount.\n *\n * @example\n * useTopics(['notifications:orders', 'notifications:payments']);\n */\nexport function useTopics(topics: string[]): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t));\n\n onUnmounted(() => {\n topics.forEach((t) => socket.unsubscribe(t));\n });\n}\n\n/**\n * Enable browser push notifications for an event. Auto-cleanup on unmount.\n *\n * @example\n * usePush('notification', {\n * title: (n) => n.title,\n * body: (n) => n.body,\n * icon: '/icon.png',\n * });\n */\nexport function usePush<T = unknown>(\n event: string,\n config: {\n title: string | ((data: T) => string);\n body?: string | ((data: T) => string);\n icon?: string;\n tag?: string | ((data: T) => string);\n leaderOnly?: boolean;\n onlyWhenHidden?: boolean;\n onClick?: (data: T) => void;\n },\n): void {\n const socket = useSharedWebSocket();\n const unsub = socket.push<T>(event, config);\n\n onUnmounted(unsub);\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gwakko/shared-websocket",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Share ONE WebSocket connection across browser tabs — leader election, BroadcastChannel sync, optional Web Worker",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -136,6 +136,17 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
136
136
  }),
137
137
  );
138
138
 
139
+ // Track tab visibility
140
+ if (typeof document !== 'undefined') {
141
+ const onVisibilityChange = () => {
142
+ const active = !document.hidden;
143
+ this.subs.emit('$lifecycle:active', active);
144
+ this.log.debug('[SharedWS]', active ? '👁 tab active' : '👁 tab hidden');
145
+ };
146
+ document.addEventListener('visibilitychange', onVisibilityChange);
147
+ this.cleanups.push(() => document.removeEventListener('visibilitychange', onVisibilityChange));
148
+ }
149
+
139
150
  // Cleanup on tab close
140
151
  if (typeof window !== 'undefined') {
141
152
  const onBeforeUnload = () => this[Symbol.dispose]();
@@ -152,6 +163,11 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
152
163
  return this.coordinator.isLeader ? 'leader' : 'follower';
153
164
  }
154
165
 
166
+ /** Whether this tab is currently visible/focused. */
167
+ get isActive(): boolean {
168
+ return typeof document !== 'undefined' ? !document.hidden : true;
169
+ }
170
+
155
171
  /** Start leader election and connect. */
156
172
  async connect(): Promise<void> {
157
173
  await this.coordinator.elect();
@@ -184,6 +200,25 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
184
200
  return this.subs.on('$lifecycle:error', fn as EventHandler);
185
201
  }
186
202
 
203
+ /** Called when this tab becomes visible/focused. */
204
+ onActive(fn: () => void): Unsubscribe {
205
+ return this.subs.on('$lifecycle:active', ((isActive: unknown) => {
206
+ if (isActive === true) fn();
207
+ }) as EventHandler);
208
+ }
209
+
210
+ /** Called when this tab goes to background/hidden. */
211
+ onInactive(fn: () => void): Unsubscribe {
212
+ return this.subs.on('$lifecycle:active', ((isActive: unknown) => {
213
+ if (isActive === false) fn();
214
+ }) as EventHandler);
215
+ }
216
+
217
+ /** Called on any visibility change. */
218
+ onVisibilityChange(fn: (isActive: boolean) => void): Unsubscribe {
219
+ return this.subs.on('$lifecycle:active', fn as EventHandler);
220
+ }
221
+
187
222
  // ─── Middleware ───────────────────────────────────────
188
223
 
189
224
  /**
@@ -359,48 +394,45 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
359
394
  /**
360
395
  * Subscribe to an event and show notifications.
361
396
  *
362
- * Two modes:
363
- * - **render**you control how to display (sonner, react-hot-toast, custom UI)
364
- * - **browser Notification API** native OS notifications (title/body/icon)
365
- *
366
- * Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.
397
+ * **target** controls which tab(s) display the notification:
398
+ * - `'active'`only the currently visible tab (default for render)
399
+ * - `'leader'` only the leader tab (default for browser Notification)
400
+ * - `'all'` — every tab (for critical alerts)
367
401
  *
368
402
  * @example
369
- * // Custom render — sonner toast
370
- * import { toast } from 'sonner';
403
+ * // Custom render — sonner toast on active tab only
371
404
  * ws.push('notification', {
372
- * render: (data) => toast(data.title, { description: data.body }),
405
+ * render: (n) => toast(n.title),
406
+ * target: 'active', // default for render
373
407
  * });
374
408
  *
375
409
  * @example
376
- * // Custom renderreact-hot-toast
377
- * import toast from 'react-hot-toast';
378
- * ws.push('order.created', {
379
- * render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),
410
+ * // Critical alertshow in ALL tabs
411
+ * ws.push('payment.failed', {
412
+ * render: (n) => toast.error('Payment failed!'),
413
+ * target: 'all',
380
414
  * });
381
415
  *
382
416
  * @example
383
- * // Browser Notification API (no render — uses title/body/icon)
384
- * ws.push('notification', {
385
- * title: (data) => data.title,
386
- * body: (data) => data.body,
387
- * icon: '/icons/bell.png',
417
+ * // Browser Notification only from leader
418
+ * ws.push('order.created', {
419
+ * title: (order) => `New Order #${order.id}`,
420
+ * target: 'leader', // default for browser Notification
388
421
  * });
389
422
  *
390
423
  * @example
391
- * // Both toast in UI + browser notification
424
+ * // Both render + native with different targets
392
425
  * ws.push('order.created', {
393
- * render: (order) => toast(`Order #${order.id}`),
394
- * title: (order) => `New Order #${order.id}`,
395
- * body: (order) => `$${order.total}`,
426
+ * render: (order) => toast(`Order #${order.id}`), // active tab
427
+ * title: (order) => `New Order #${order.id}`, // leader → native
396
428
  * });
397
429
  */
398
430
  push<T = unknown>(
399
431
  event: string,
400
432
  config: {
401
- /** Custom render function — you decide how to display. Called for every matching event. */
433
+ /** Custom render function — you decide how to display. */
402
434
  render?: (data: T) => void;
403
- /** Title for browser Notification API (ignored if only render is used). */
435
+ /** Title for browser Notification API. */
404
436
  title?: string | ((data: T) => string);
405
437
  /** Body for browser Notification API. */
406
438
  body?: string | ((data: T) => string);
@@ -408,51 +440,70 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
408
440
  icon?: string;
409
441
  /** Tag for browser Notification deduplication. */
410
442
  tag?: string | ((data: T) => string);
411
- /** Only trigger from leader tab (default: true). Prevents N duplicates for N tabs. */
412
- leaderOnly?: boolean;
413
- /** Only trigger when tab is hidden/background (default: true). */
414
- onlyWhenHidden?: boolean;
443
+ /**
444
+ * Which tab(s) show the notification:
445
+ * - `'active'` only the visible/focused tab (default for render)
446
+ * - `'leader'` — only the leader tab (default for browser Notification)
447
+ * - `'all'` — every tab (critical alerts)
448
+ */
449
+ target?: 'active' | 'leader' | 'all';
415
450
  /** Called when browser Notification is clicked. */
416
451
  onClick?: (data: T) => void;
417
452
  },
418
453
  ): Unsubscribe {
419
- const leaderOnly = config.leaderOnly ?? true;
420
- const onlyWhenHidden = config.onlyWhenHidden ?? true;
421
454
  const useNativeNotification = !!config.title;
422
455
 
456
+ // Default target: 'active' for render, 'leader' for native
457
+ const renderTarget = config.target ?? 'active';
458
+ const nativeTarget = config.target ?? 'leader';
459
+
423
460
  if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'default') {
424
461
  Notification.requestPermission();
425
462
  }
426
463
 
427
464
  return this.on(event, ((data: unknown) => {
428
465
  const typed = data as T;
466
+ const isVisible = typeof document !== 'undefined' && !document.hidden;
467
+ const isLeader = this.tabRole === 'leader';
429
468
 
430
- if (leaderOnly && this.tabRole !== 'leader') return;
431
- if (onlyWhenHidden && typeof document !== 'undefined' && !document.hidden) return;
432
-
433
- // Custom render — always called if provided
469
+ // Custom render
434
470
  if (config.render) {
435
- config.render(typed);
436
- this.log.debug('[SharedWS] 🔔 push render', event);
471
+ const shouldRender =
472
+ renderTarget === 'all' ||
473
+ (renderTarget === 'active' && isVisible) ||
474
+ (renderTarget === 'leader' && isLeader);
475
+
476
+ if (shouldRender) {
477
+ config.render(typed);
478
+ this.log.debug('[SharedWS] 🔔 render', event, `(target: ${renderTarget})`);
479
+ }
437
480
  }
438
481
 
439
- // Browser Notification API — only if title provided and permission granted
482
+ // Browser Notification API
440
483
  if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
441
- const title = typeof config.title === 'function' ? config.title(typed) : config.title!;
442
- const body = typeof config.body === 'function' ? config.body(typed) : config.body;
443
- const tag = typeof config.tag === 'function' ? config.tag(typed) : config.tag;
444
-
445
- const notif = new Notification(title, { body, icon: config.icon, tag });
446
-
447
- if (config.onClick) {
448
- const handler = config.onClick;
449
- notif.onclick = () => {
450
- handler(typed);
451
- window.focus();
452
- };
484
+ const shouldNotify =
485
+ nativeTarget === 'all' ||
486
+ (nativeTarget === 'leader' && isLeader) ||
487
+ (nativeTarget === 'active' && isVisible);
488
+
489
+ // Native notifications make sense when tab is hidden
490
+ if (shouldNotify && !isVisible) {
491
+ const title = typeof config.title === 'function' ? config.title(typed) : config.title!;
492
+ const body = typeof config.body === 'function' ? config.body(typed) : config.body;
493
+ const tag = typeof config.tag === 'function' ? config.tag(typed) : config.tag;
494
+
495
+ const notif = new Notification(title, { body, icon: config.icon, tag });
496
+
497
+ if (config.onClick) {
498
+ const handler = config.onClick;
499
+ notif.onclick = () => {
500
+ handler(typed);
501
+ window.focus();
502
+ };
503
+ }
504
+
505
+ this.log.debug('[SharedWS] 🔔 native', title, `(target: ${nativeTarget})`);
453
506
  }
454
-
455
- this.log.debug('[SharedWS] 🔔 push native', title);
456
507
  }
457
508
  }) as EventHandler);
458
509
  }
@@ -291,6 +291,9 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
291
291
  const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
292
292
  const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
293
293
  const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
294
+ const onActive = useEffectEvent(() => handlers.onActive?.());
295
+ const onInactive = useEffectEvent(() => handlers.onInactive?.());
296
+ const onVisibilityChange = useEffectEvent((isActive: boolean) => handlers.onVisibilityChange?.(isActive));
294
297
 
295
298
  useEffect(() => {
296
299
  const unsubs = [
@@ -299,6 +302,9 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
299
302
  socket.onReconnecting(onReconnecting),
300
303
  socket.onLeaderChange(onLeaderChange),
301
304
  socket.onError(onError),
305
+ socket.onActive(onActive),
306
+ socket.onInactive(onInactive),
307
+ socket.onVisibilityChange(onVisibilityChange),
302
308
  ];
303
309
  return () => unsubs.forEach((u) => u());
304
310
  }, [socket]);
@@ -237,6 +237,9 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
237
237
  if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
238
238
  if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
239
239
  if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
240
+ if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
241
+ if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));
242
+ if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));
240
243
 
241
244
  onUnmounted(() => unsubs.forEach((u) => u()));
242
245
  }
package/src/types.ts CHANGED
@@ -114,6 +114,12 @@ export interface SocketLifecycleHandlers {
114
114
  onReconnecting?: () => void;
115
115
  onLeaderChange?: (isLeader: boolean) => void;
116
116
  onError?: (error: unknown) => void;
117
+ /** Called when this tab becomes visible/focused. */
118
+ onActive?: () => void;
119
+ /** Called when this tab goes to background/hidden. */
120
+ onInactive?: () => void;
121
+ /** Called on any visibility change. */
122
+ onVisibilityChange?: (isActive: boolean) => void;
117
123
  }
118
124
 
119
125
  /** Scoped channel handle for private/topic-based subscriptions. */
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/chunk-PH7NVGUX.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-PH7NVGUX.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 * Two modes:\n * - **render** — you control how to display (sonner, react-hot-toast, custom UI)\n * - **browser Notification API** — native OS notifications (title/body/icon)\n *\n * Both modes respect leaderOnly + onlyWhenHidden to prevent duplicates.\n *\n * @example\n * // Custom render — sonner toast\n * import { toast } from 'sonner';\n * ws.push('notification', {\n * render: (data) => toast(data.title, { description: data.body }),\n * });\n *\n * @example\n * // Custom render — react-hot-toast\n * import toast from 'react-hot-toast';\n * ws.push('order.created', {\n * render: (order) => toast.success(`New Order #${order.id} — $${order.total}`),\n * });\n *\n * @example\n * // Browser Notification API (no render — uses title/body/icon)\n * ws.push('notification', {\n * title: (data) => data.title,\n * body: (data) => data.body,\n * icon: '/icons/bell.png',\n * });\n *\n * @example\n * // Both — toast in UI + browser notification\n * ws.push('order.created', {\n * render: (order) => toast(`Order #${order.id}`),\n * title: (order) => `New Order #${order.id}`,\n * body: (order) => `$${order.total}`,\n * });\n */\n push<T = unknown>(\n event: string,\n config: {\n /** Custom render function — you decide how to display. Called for every matching event. */\n render?: (data: T) => void;\n /** Title for browser Notification API (ignored if only render is used). */\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 /** Only trigger from leader tab (default: true). Prevents N duplicates for N tabs. */\n leaderOnly?: boolean;\n /** Only trigger when tab is hidden/background (default: true). */\n onlyWhenHidden?: boolean;\n /** Called when browser Notification is clicked. */\n onClick?: (data: T) => void;\n },\n ): Unsubscribe {\n const leaderOnly = config.leaderOnly ?? true;\n const onlyWhenHidden = config.onlyWhenHidden ?? true;\n const useNativeNotification = !!config.title;\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\n if (leaderOnly && this.tabRole !== 'leader') return;\n if (onlyWhenHidden && typeof document !== 'undefined' && !document.hidden) return;\n\n // Custom render — always called if provided\n if (config.render) {\n config.render(typed);\n this.log.debug('[SharedWS] 🔔 push render', event);\n }\n\n // Browser Notification API — only if title provided and permission granted\n if (useNativeNotification && typeof Notification !== 'undefined' && Notification.permission === 'granted') {\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] 🔔 push native', title);\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"]}