@gwakko/shared-websocket 0.11.1 → 0.12.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 +2 -0
- package/dist/TabSync.d.ts +45 -0
- package/dist/adapters/sync-react.d.ts +72 -0
- package/dist/adapters/sync-vue.d.ts +59 -0
- package/dist/chunk-B2V5HX77.js +5 -0
- package/dist/chunk-B2V5HX77.js.map +1 -0
- package/dist/chunk-ET3YHQ7V.cjs +102 -0
- package/dist/chunk-ET3YHQ7V.cjs.map +1 -0
- package/dist/{chunk-CKFXM6UP.js → chunk-INJYCCW7.js} +1 -6
- package/dist/chunk-INJYCCW7.js.map +1 -0
- package/dist/{chunk-3DDE3RGB.cjs → chunk-MP3K5IEI.cjs} +2 -7
- package/dist/chunk-MP3K5IEI.cjs.map +1 -0
- package/dist/chunk-PNQIHDJF.cjs +5 -0
- package/dist/chunk-PNQIHDJF.cjs.map +1 -0
- package/dist/chunk-RM27CYKT.js +102 -0
- package/dist/chunk-RM27CYKT.js.map +1 -0
- package/dist/index.cjs +8 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +3 -2
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/sync-react.cjs +73 -0
- package/dist/sync-react.cjs.map +1 -0
- package/dist/sync-react.js +73 -0
- package/dist/sync-react.js.map +1 -0
- package/dist/sync-vue.cjs +74 -0
- package/dist/sync-vue.cjs.map +1 -0
- package/dist/sync-vue.js +74 -0
- package/dist/sync-vue.js.map +1 -0
- package/dist/sync.cjs +8 -0
- package/dist/sync.cjs.map +1 -0
- package/dist/sync.d.ts +2 -0
- package/dist/sync.js +8 -0
- package/dist/sync.js.map +1 -0
- package/dist/vue.cjs +3 -2
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +2 -1
- package/dist/vue.js.map +1 -1
- package/package.json +25 -8
- package/src/TabSync.ts +127 -0
- package/src/adapters/sync-react.ts +152 -0
- package/src/adapters/sync-vue.ts +128 -0
- package/src/index.ts +3 -0
- package/src/sync.ts +2 -0
- package/dist/chunk-3DDE3RGB.cjs.map +0 -1
- package/dist/chunk-CKFXM6UP.js.map +0 -1
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/**\n * Reactive auth state with authenticate/deauthenticate actions.\n * Syncs across all tabs.\n *\n * @example\n * const { isAuthenticated, authenticate, deauthenticate } = useSocketAuth();\n *\n * async function login(email: string, password: string) {\n * const { token } = await api.login(email, password);\n * authenticate(token);\n * }\n *\n * @example\n * // In template: <button v-if=\"isAuthenticated\" @click=\"deauthenticate\">Logout</button>\n */\nexport function useSocketAuth(): {\n isAuthenticated: Ref<boolean>;\n authenticate: (token: string) => void;\n deauthenticate: () => void;\n} {\n const socket = useSharedWebSocket();\n const isAuthenticated = ref(socket.isAuthenticated);\n\n const unsub = socket.onAuthChange((authenticated: boolean) => {\n isAuthenticated.value = authenticated;\n });\n\n onUnmounted(unsub);\n\n return {\n isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,\n authenticate: (token: string) => socket.authenticate(token),\n deauthenticate: () => socket.deauthenticate(),\n };\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 isAuthenticated: Ref<boolean>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n const isAuthenticated = ref(socket.isAuthenticated);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n isAuthenticated.value = socket.isAuthenticated;\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 isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,\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 if (handlers.onAuthChange) unsubs.push(socket.onAuthChange(handlers.onAuthChange));\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, options?: { auth?: boolean }) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name, options);\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[], options?: { auth?: boolean }): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t, options));\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/**\n * Reactive auth state with authenticate/deauthenticate actions.\n * Syncs across all tabs.\n *\n * @example\n * const { isAuthenticated, authenticate, deauthenticate } = useSocketAuth();\n *\n * async function login(email: string, password: string) {\n * const { token } = await api.login(email, password);\n * authenticate(token);\n * }\n *\n * @example\n * // In template: <button v-if=\"isAuthenticated\" @click=\"deauthenticate\">Logout</button>\n */\nexport function useSocketAuth(): {\n isAuthenticated: Ref<boolean>;\n authenticate: (token: string) => void;\n deauthenticate: () => void;\n} {\n const socket = useSharedWebSocket();\n const isAuthenticated = ref(socket.isAuthenticated);\n\n const unsub = socket.onAuthChange((authenticated: boolean) => {\n isAuthenticated.value = authenticated;\n });\n\n onUnmounted(unsub);\n\n return {\n isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,\n authenticate: (token: string) => socket.authenticate(token),\n deauthenticate: () => socket.deauthenticate(),\n };\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 isAuthenticated: Ref<boolean>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n const isAuthenticated = ref(socket.isAuthenticated);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n isAuthenticated.value = socket.isAuthenticated;\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 isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,\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 if (handlers.onAuthChange) unsubs.push(socket.onAuthChange(handlers.onAuthChange));\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, options?: { auth?: boolean }) {\n const socket = useSharedWebSocket();\n const channel = socket.channel(name, options);\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[], options?: { auth?: boolean }): void {\n const socket = useSharedWebSocket();\n\n topics.forEach((t) => socket.subscribe(t, options));\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.
|
|
3
|
+
"version": "0.12.0",
|
|
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",
|
|
@@ -8,19 +8,34 @@
|
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
11
12
|
"import": "./dist/index.js",
|
|
12
|
-
"require": "./dist/index.cjs"
|
|
13
|
-
"types": "./dist/index.d.ts"
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
14
|
},
|
|
15
15
|
"./react": {
|
|
16
|
+
"types": "./dist/react.d.ts",
|
|
16
17
|
"import": "./dist/react.js",
|
|
17
|
-
"require": "./dist/react.cjs"
|
|
18
|
-
"types": "./dist/react.d.ts"
|
|
18
|
+
"require": "./dist/react.cjs"
|
|
19
19
|
},
|
|
20
20
|
"./vue": {
|
|
21
|
+
"types": "./dist/vue.d.ts",
|
|
21
22
|
"import": "./dist/vue.js",
|
|
22
|
-
"require": "./dist/vue.cjs"
|
|
23
|
-
|
|
23
|
+
"require": "./dist/vue.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./sync": {
|
|
26
|
+
"types": "./dist/sync.d.ts",
|
|
27
|
+
"import": "./dist/sync.js",
|
|
28
|
+
"require": "./dist/sync.cjs"
|
|
29
|
+
},
|
|
30
|
+
"./sync/react": {
|
|
31
|
+
"types": "./dist/sync-react.d.ts",
|
|
32
|
+
"import": "./dist/sync-react.js",
|
|
33
|
+
"require": "./dist/sync-react.cjs"
|
|
34
|
+
},
|
|
35
|
+
"./sync/vue": {
|
|
36
|
+
"types": "./dist/sync-vue.d.ts",
|
|
37
|
+
"import": "./dist/sync-vue.js",
|
|
38
|
+
"require": "./dist/sync-vue.cjs"
|
|
24
39
|
}
|
|
25
40
|
},
|
|
26
41
|
"files": [
|
|
@@ -41,6 +56,8 @@
|
|
|
41
56
|
"broadcast-channel",
|
|
42
57
|
"shared",
|
|
43
58
|
"tabs",
|
|
59
|
+
"tab-sync",
|
|
60
|
+
"cross-tab",
|
|
44
61
|
"leader-election",
|
|
45
62
|
"real-time",
|
|
46
63
|
"react",
|
|
@@ -52,7 +69,7 @@
|
|
|
52
69
|
"license": "MIT",
|
|
53
70
|
"repository": {
|
|
54
71
|
"type": "git",
|
|
55
|
-
"url": "https://github.com/Gwakko/shared-websocket.git"
|
|
72
|
+
"url": "git+https://github.com/Gwakko/shared-websocket.git"
|
|
56
73
|
},
|
|
57
74
|
"homepage": "https://github.com/Gwakko/shared-websocket#readme",
|
|
58
75
|
"peerDependencies": {
|
package/src/TabSync.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { Unsubscribe } from './types';
|
|
3
|
+
|
|
4
|
+
interface SyncMessage {
|
|
5
|
+
key: string;
|
|
6
|
+
value: unknown;
|
|
7
|
+
deleted?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cross-tab state synchronization via BroadcastChannel.
|
|
12
|
+
* No WebSocket needed — works standalone for sharing state between browser tabs.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const sync = new TabSync('my-app');
|
|
16
|
+
* sync.set('theme', 'dark');
|
|
17
|
+
* sync.on('theme', (theme) => applyTheme(theme));
|
|
18
|
+
*/
|
|
19
|
+
export class TabSync implements Disposable {
|
|
20
|
+
private store = new Map<string, unknown>();
|
|
21
|
+
private listeners = new Map<string, Set<(value: unknown) => void>>();
|
|
22
|
+
private bc: BroadcastChannel;
|
|
23
|
+
private disposed = false;
|
|
24
|
+
|
|
25
|
+
constructor(channel = 'tab-sync') {
|
|
26
|
+
this.bc = new BroadcastChannel(channel);
|
|
27
|
+
this.bc.onmessage = (ev: MessageEvent<SyncMessage>) => {
|
|
28
|
+
const { key, value, deleted } = ev.data;
|
|
29
|
+
if (deleted) {
|
|
30
|
+
this.store.delete(key);
|
|
31
|
+
} else {
|
|
32
|
+
this.store.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
this.emit(key, value);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Set a value and broadcast to all tabs. Local listeners also fire. */
|
|
39
|
+
set<T>(key: string, value: T): void {
|
|
40
|
+
this.store.set(key, value);
|
|
41
|
+
this.bc.postMessage({ key, value } satisfies SyncMessage);
|
|
42
|
+
this.emit(key, value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Get current value from local store. */
|
|
46
|
+
get<T>(key: string): T | undefined {
|
|
47
|
+
return this.store.get(key) as T | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Delete a key and broadcast deletion to all tabs. */
|
|
51
|
+
delete(key: string): void {
|
|
52
|
+
this.store.delete(key);
|
|
53
|
+
this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
|
|
54
|
+
this.emit(key, undefined);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check if a key exists in the local store. */
|
|
58
|
+
has(key: string): boolean {
|
|
59
|
+
return this.store.has(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get all keys in the local store. */
|
|
63
|
+
keys(): string[] {
|
|
64
|
+
return [...this.store.keys()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Get number of entries. */
|
|
68
|
+
get size(): number {
|
|
69
|
+
return this.store.size;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Listen for changes to a key. Fires when any tab (including this one) calls set().
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* sync.on('cart', (cart) => updateBadge(cart.items.length));
|
|
77
|
+
*/
|
|
78
|
+
on<T>(key: string, fn: (value: T) => void): Unsubscribe {
|
|
79
|
+
let set = this.listeners.get(key);
|
|
80
|
+
if (!set) {
|
|
81
|
+
set = new Set();
|
|
82
|
+
this.listeners.set(key, set);
|
|
83
|
+
}
|
|
84
|
+
const wrapper = fn as (value: unknown) => void;
|
|
85
|
+
set.add(wrapper);
|
|
86
|
+
return () => set!.delete(wrapper);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Listen for a key change once, then auto-unsubscribe. */
|
|
90
|
+
once<T>(key: string, fn: (value: T) => void): Unsubscribe {
|
|
91
|
+
const unsub = this.on<T>(key, (value) => {
|
|
92
|
+
unsub();
|
|
93
|
+
fn(value);
|
|
94
|
+
});
|
|
95
|
+
return unsub;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Clear all keys and notify listeners. */
|
|
99
|
+
clear(): void {
|
|
100
|
+
const keys = [...this.store.keys()];
|
|
101
|
+
this.store.clear();
|
|
102
|
+
for (const key of keys) {
|
|
103
|
+
this.bc.postMessage({ key, value: undefined, deleted: true } satisfies SyncMessage);
|
|
104
|
+
this.emit(key, undefined);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Dispose — close BroadcastChannel and clear all state. */
|
|
109
|
+
dispose(): void {
|
|
110
|
+
this[Symbol.dispose]();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private emit(key: string, value: unknown): void {
|
|
114
|
+
const set = this.listeners.get(key);
|
|
115
|
+
if (set) {
|
|
116
|
+
for (const fn of set) fn(value);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
[Symbol.dispose](): void {
|
|
121
|
+
if (this.disposed) return;
|
|
122
|
+
this.disposed = true;
|
|
123
|
+
this.bc.close();
|
|
124
|
+
this.store.clear();
|
|
125
|
+
this.listeners.clear();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
useEffectEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
createElement,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { TabSync } from '../TabSync';
|
|
11
|
+
|
|
12
|
+
// ─── Context ─────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const TabSyncContext = createContext<TabSync | null>(null);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Provider for TabSync — creates instance, auto-disposes on unmount.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* function App() {
|
|
21
|
+
* return (
|
|
22
|
+
* <TabSyncProvider channel="my-app">
|
|
23
|
+
* <Dashboard />
|
|
24
|
+
* </TabSyncProvider>
|
|
25
|
+
* );
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
export interface TabSyncProviderProps {
|
|
29
|
+
/** BroadcastChannel name (default: "tab-sync"). */
|
|
30
|
+
channel?: string;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function TabSyncProvider({ channel, children }: TabSyncProviderProps) {
|
|
35
|
+
const [sync] = useState(() => new TabSync(channel));
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
return () => sync[Symbol.dispose]();
|
|
39
|
+
}, [sync]);
|
|
40
|
+
|
|
41
|
+
return createElement(TabSyncContext.Provider, { value: sync }, children);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Access the TabSync instance from context.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* const sync = useTabSyncContext();
|
|
49
|
+
* sync.set('theme', 'dark');
|
|
50
|
+
*/
|
|
51
|
+
export function useTabSyncContext(): TabSync {
|
|
52
|
+
const ctx = useContext(TabSyncContext);
|
|
53
|
+
if (!ctx) {
|
|
54
|
+
throw new Error('useTabSync must be used within <TabSyncProvider>');
|
|
55
|
+
}
|
|
56
|
+
return ctx;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Hooks ───────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Two-way state sync across browser tabs — like useState but shared.
|
|
63
|
+
* - Without callback: returns [value, setter] synced across tabs.
|
|
64
|
+
* - With callback: also calls your handler on every change (side effects).
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* // Shared state — updates propagate to all tabs
|
|
68
|
+
* const [theme, setTheme] = useTabSync('theme', 'light');
|
|
69
|
+
* <button onClick={() => setTheme('dark')}>Dark mode</button>
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* // With side effect callback
|
|
73
|
+
* const [cart, setCart] = useTabSync('cart', { items: [] }, (cart) => {
|
|
74
|
+
* document.title = `Cart (${cart.items.length})`;
|
|
75
|
+
* });
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Form state synced across tabs
|
|
79
|
+
* const [draft, setDraft] = useTabSync('email-draft', '');
|
|
80
|
+
* <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
|
|
81
|
+
*/
|
|
82
|
+
export function useTabSync<T>(
|
|
83
|
+
key: string,
|
|
84
|
+
initialValue: T,
|
|
85
|
+
callback?: (value: T) => void,
|
|
86
|
+
): [T, (value: T) => void] {
|
|
87
|
+
const sync = useTabSyncContext();
|
|
88
|
+
const [value, setValue] = useState<T>(() => sync.get<T>(key) ?? initialValue);
|
|
89
|
+
|
|
90
|
+
const onSync = useEffectEvent((synced: T) => {
|
|
91
|
+
setValue(synced);
|
|
92
|
+
callback?.(synced);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
return sync.on<T>(key, onSync);
|
|
97
|
+
}, [sync, key]);
|
|
98
|
+
|
|
99
|
+
const setAndSync = useEffectEvent((newValue: T) => {
|
|
100
|
+
setValue(newValue);
|
|
101
|
+
sync.set(key, newValue);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return [value, setAndSync];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read-only subscription to a synced key. Returns undefined until first set.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const theme = useTabSyncValue<string>('theme');
|
|
112
|
+
* <div className={theme === 'dark' ? 'dark' : 'light'} />
|
|
113
|
+
*/
|
|
114
|
+
export function useTabSyncValue<T>(key: string): T | undefined {
|
|
115
|
+
const sync = useTabSyncContext();
|
|
116
|
+
const [value, setValue] = useState<T | undefined>(() => sync.get<T>(key));
|
|
117
|
+
|
|
118
|
+
const onSync = useEffectEvent((synced: T) => {
|
|
119
|
+
setValue(synced);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
return sync.on<T>(key, onSync);
|
|
124
|
+
}, [sync, key]);
|
|
125
|
+
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Fire-and-forget listener for a synced key. No state, no return value.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* useTabSyncCallback<string>('theme', (theme) => {
|
|
134
|
+
* document.documentElement.setAttribute('data-theme', theme);
|
|
135
|
+
* });
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* useTabSyncCallback<Cart>('cart', (cart) => {
|
|
139
|
+
* analytics.track('cart_updated', { count: cart.items.length });
|
|
140
|
+
* });
|
|
141
|
+
*/
|
|
142
|
+
export function useTabSyncCallback<T>(key: string, callback: (value: T) => void): void {
|
|
143
|
+
const sync = useTabSyncContext();
|
|
144
|
+
|
|
145
|
+
const handler = useEffectEvent((value: T) => {
|
|
146
|
+
callback(value);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
return sync.on<T>(key, handler);
|
|
151
|
+
}, [sync, key]);
|
|
152
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ref,
|
|
3
|
+
onUnmounted,
|
|
4
|
+
inject,
|
|
5
|
+
readonly,
|
|
6
|
+
watch,
|
|
7
|
+
type Ref,
|
|
8
|
+
type InjectionKey,
|
|
9
|
+
type App,
|
|
10
|
+
} from 'vue';
|
|
11
|
+
import { TabSync } from '../TabSync';
|
|
12
|
+
|
|
13
|
+
// ─── Plugin ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const TabSyncKey: InjectionKey<TabSync> = Symbol('TabSync');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Vue 3 plugin for TabSync.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const app = createApp(App);
|
|
22
|
+
* app.use(createTabSyncPlugin('my-app'));
|
|
23
|
+
*/
|
|
24
|
+
export function createTabSyncPlugin(channel?: string) {
|
|
25
|
+
return {
|
|
26
|
+
install(app: App) {
|
|
27
|
+
const sync = new TabSync(channel);
|
|
28
|
+
app.provide(TabSyncKey, sync);
|
|
29
|
+
|
|
30
|
+
const originalUnmount = app.unmount.bind(app);
|
|
31
|
+
app.unmount = () => {
|
|
32
|
+
sync[Symbol.dispose]();
|
|
33
|
+
originalUnmount();
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Access the TabSync instance from provided context.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* const sync = useTabSyncContext();
|
|
44
|
+
* sync.set('theme', 'dark');
|
|
45
|
+
*/
|
|
46
|
+
export function useTabSyncContext(): TabSync {
|
|
47
|
+
const sync = inject(TabSyncKey);
|
|
48
|
+
if (!sync) {
|
|
49
|
+
throw new Error('useTabSync: TabSync not provided. Did you install the plugin?');
|
|
50
|
+
}
|
|
51
|
+
return sync;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Composables ─────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Two-way reactive state synced across tabs.
|
|
58
|
+
* Mutating the ref broadcasts the change; changes from other tabs update the ref.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* const theme = useTabSync('theme', 'light');
|
|
62
|
+
* theme.value = 'dark'; // syncs to all tabs
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* // With side effect callback
|
|
66
|
+
* const cart = useTabSync('cart', { items: [] }, (cart) => {
|
|
67
|
+
* document.title = `Cart (${cart.items.length})`;
|
|
68
|
+
* });
|
|
69
|
+
* cart.value = { items: [...cart.value.items, newItem] };
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* // Form draft synced across tabs
|
|
73
|
+
* const draft = useTabSync('email-draft', '');
|
|
74
|
+
* // <textarea v-model="draft" />
|
|
75
|
+
*/
|
|
76
|
+
export function useTabSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {
|
|
77
|
+
const sync = useTabSyncContext();
|
|
78
|
+
const value = ref<T>(sync.get<T>(key) ?? initialValue) as Ref<T>;
|
|
79
|
+
|
|
80
|
+
const unsub = sync.on<T>(key, (v) => {
|
|
81
|
+
value.value = v;
|
|
82
|
+
callback?.(v);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
watch(
|
|
86
|
+
value,
|
|
87
|
+
(newVal) => {
|
|
88
|
+
sync.set(key, newVal);
|
|
89
|
+
},
|
|
90
|
+
{ deep: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
onUnmounted(unsub);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read-only subscription to a synced key. Returns undefined until first set.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* const theme = useTabSyncValue<string>('theme');
|
|
102
|
+
* // <div :class="theme === 'dark' ? 'dark' : 'light'" />
|
|
103
|
+
*/
|
|
104
|
+
export function useTabSyncValue<T>(key: string): Ref<T | undefined> {
|
|
105
|
+
const sync = useTabSyncContext();
|
|
106
|
+
const value = ref<T | undefined>(sync.get<T>(key)) as Ref<T | undefined>;
|
|
107
|
+
|
|
108
|
+
const unsub = sync.on<T>(key, (v) => {
|
|
109
|
+
value.value = v;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
onUnmounted(unsub);
|
|
113
|
+
return readonly(value) as Ref<T | undefined>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fire-and-forget listener for a synced key. No ref, no return value.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* useTabSyncCallback<string>('theme', (theme) => {
|
|
121
|
+
* document.documentElement.setAttribute('data-theme', theme);
|
|
122
|
+
* });
|
|
123
|
+
*/
|
|
124
|
+
export function useTabSyncCallback<T>(key: string, callback: (value: T) => void): void {
|
|
125
|
+
const sync = useTabSyncContext();
|
|
126
|
+
const unsub = sync.on<T>(key, callback);
|
|
127
|
+
onUnmounted(unsub);
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
export { SharedWebSocket } from './SharedWebSocket';
|
|
3
3
|
export { withSocket } from './withSocket';
|
|
4
4
|
|
|
5
|
+
// Tab Sync (standalone — no WebSocket needed)
|
|
6
|
+
export { TabSync } from './TabSync';
|
|
7
|
+
|
|
5
8
|
// Internal components (for advanced usage)
|
|
6
9
|
export { MessageBus } from './MessageBus';
|
|
7
10
|
export { TabCoordinator } from './TabCoordinator';
|
package/src/sync.ts
ADDED