@gwakko/shared-websocket 0.10.2 → 0.11.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/README.md +18 -5
- package/dist/SharedWebSocket.d.ts +43 -2
- package/dist/adapters/react.d.ts +34 -2
- package/dist/adapters/vue.d.ts +27 -2
- package/dist/{chunk-MJXKQYRZ.cjs → chunk-3DDE3RGB.cjs} +133 -12
- package/dist/chunk-3DDE3RGB.cjs.map +1 -0
- package/dist/{chunk-PKZXBX5I.js → chunk-CKFXM6UP.js} +128 -7
- package/dist/chunk-CKFXM6UP.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +1 -1
- package/dist/react.cjs +32 -10
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +30 -8
- package/dist/react.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/vue.cjs +26 -8
- package/dist/vue.cjs.map +1 -1
- package/dist/vue.js +24 -6
- package/dist/vue.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedWebSocket.ts +144 -6
- package/src/adapters/react.ts +60 -6
- package/src/adapters/vue.ts +45 -4
- package/src/types.ts +8 -0
- package/dist/chunk-MJXKQYRZ.cjs.map +0 -1
- package/dist/chunk-PKZXBX5I.js.map +0 -1
package/dist/vue.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SharedWebSocket
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-CKFXM6UP.js";
|
|
4
4
|
|
|
5
5
|
// src/adapters/vue.ts
|
|
6
6
|
import {
|
|
@@ -32,6 +32,19 @@ function useSharedWebSocket() {
|
|
|
32
32
|
}
|
|
33
33
|
return socket;
|
|
34
34
|
}
|
|
35
|
+
function useSocketAuth() {
|
|
36
|
+
const socket = useSharedWebSocket();
|
|
37
|
+
const isAuthenticated = ref(socket.isAuthenticated);
|
|
38
|
+
const unsub = socket.onAuthChange((authenticated) => {
|
|
39
|
+
isAuthenticated.value = authenticated;
|
|
40
|
+
});
|
|
41
|
+
onUnmounted(unsub);
|
|
42
|
+
return {
|
|
43
|
+
isAuthenticated: readonly(isAuthenticated),
|
|
44
|
+
authenticate: (token) => socket.authenticate(token),
|
|
45
|
+
deauthenticate: () => socket.deauthenticate()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
35
48
|
function useSocketEvent(event, callback) {
|
|
36
49
|
const socket = useSharedWebSocket();
|
|
37
50
|
const value = ref(void 0);
|
|
@@ -90,14 +103,17 @@ function useSocketStatus() {
|
|
|
90
103
|
const socket = useSharedWebSocket();
|
|
91
104
|
const connected = ref(socket.connected);
|
|
92
105
|
const tabRole = ref(socket.tabRole);
|
|
106
|
+
const isAuthenticated = ref(socket.isAuthenticated);
|
|
93
107
|
const timer = setInterval(() => {
|
|
94
108
|
connected.value = socket.connected;
|
|
95
109
|
tabRole.value = socket.tabRole;
|
|
110
|
+
isAuthenticated.value = socket.isAuthenticated;
|
|
96
111
|
}, 1e3);
|
|
97
112
|
onUnmounted(() => clearInterval(timer));
|
|
98
113
|
return {
|
|
99
114
|
connected: readonly(connected),
|
|
100
|
-
tabRole: readonly(tabRole)
|
|
115
|
+
tabRole: readonly(tabRole),
|
|
116
|
+
isAuthenticated: readonly(isAuthenticated)
|
|
101
117
|
};
|
|
102
118
|
}
|
|
103
119
|
function useSocketLifecycle(handlers) {
|
|
@@ -111,17 +127,18 @@ function useSocketLifecycle(handlers) {
|
|
|
111
127
|
if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
|
|
112
128
|
if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));
|
|
113
129
|
if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));
|
|
130
|
+
if (handlers.onAuthChange) unsubs.push(socket.onAuthChange(handlers.onAuthChange));
|
|
114
131
|
onUnmounted(() => unsubs.forEach((u) => u()));
|
|
115
132
|
}
|
|
116
|
-
function useChannel(name) {
|
|
133
|
+
function useChannel(name, options) {
|
|
117
134
|
const socket = useSharedWebSocket();
|
|
118
|
-
const channel = socket.channel(name);
|
|
135
|
+
const channel = socket.channel(name, options);
|
|
119
136
|
onUnmounted(() => channel.leave());
|
|
120
137
|
return channel;
|
|
121
138
|
}
|
|
122
|
-
function useTopics(topics) {
|
|
139
|
+
function useTopics(topics, options) {
|
|
123
140
|
const socket = useSharedWebSocket();
|
|
124
|
-
topics.forEach((t) => socket.subscribe(t));
|
|
141
|
+
topics.forEach((t) => socket.subscribe(t, options));
|
|
125
142
|
onUnmounted(() => {
|
|
126
143
|
topics.forEach((t) => socket.unsubscribe(t));
|
|
127
144
|
});
|
|
@@ -137,6 +154,7 @@ export {
|
|
|
137
154
|
useChannel,
|
|
138
155
|
usePush,
|
|
139
156
|
useSharedWebSocket,
|
|
157
|
+
useSocketAuth,
|
|
140
158
|
useSocketCallback,
|
|
141
159
|
useSocketEvent,
|
|
142
160
|
useSocketLifecycle,
|
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 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":[]}
|
|
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.11.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",
|
package/src/SharedWebSocket.ts
CHANGED
|
@@ -16,6 +16,9 @@ const DEFAULT_PROTOCOL: EventProtocol = {
|
|
|
16
16
|
defaultEvent: 'message',
|
|
17
17
|
topicSubscribe: '$topic:subscribe',
|
|
18
18
|
topicUnsubscribe: '$topic:unsubscribe',
|
|
19
|
+
authLogin: '$auth:login',
|
|
20
|
+
authLogout: '$auth:logout',
|
|
21
|
+
authRevoked: '$auth:revoked',
|
|
19
22
|
};
|
|
20
23
|
|
|
21
24
|
const NOOP_LOGGER: Logger = {
|
|
@@ -65,6 +68,9 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
65
68
|
private incomingMiddleware: Middleware[] = [];
|
|
66
69
|
private serializers = new Map<string, (data: unknown) => unknown>();
|
|
67
70
|
private deserializers = new Map<string, (data: unknown) => unknown>();
|
|
71
|
+
private _isAuthenticated = false;
|
|
72
|
+
private authChannels = new Map<string, Channel>();
|
|
73
|
+
private authTopics = new Set<string>();
|
|
68
74
|
|
|
69
75
|
constructor(
|
|
70
76
|
private readonly url: string,
|
|
@@ -117,7 +123,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
117
123
|
|
|
118
124
|
// Lifecycle events from bus (all tabs receive)
|
|
119
125
|
this.cleanups.push(
|
|
120
|
-
this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown }>('ws:lifecycle', (msg) => {
|
|
126
|
+
this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown; authenticated?: boolean }>('ws:lifecycle', (msg) => {
|
|
121
127
|
switch (msg.type) {
|
|
122
128
|
case 'connect':
|
|
123
129
|
this.subs.emit('$lifecycle:connect', undefined);
|
|
@@ -134,6 +140,15 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
134
140
|
case 'error':
|
|
135
141
|
this.subs.emit('$lifecycle:error', msg.error);
|
|
136
142
|
break;
|
|
143
|
+
case 'auth': {
|
|
144
|
+
this._isAuthenticated = !!msg.authenticated;
|
|
145
|
+
if (!msg.authenticated) {
|
|
146
|
+
this.authChannels.clear();
|
|
147
|
+
this.authTopics.clear();
|
|
148
|
+
}
|
|
149
|
+
this.subs.emit('$lifecycle:auth', msg.authenticated);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
137
152
|
}
|
|
138
153
|
}),
|
|
139
154
|
);
|
|
@@ -149,6 +164,22 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
149
164
|
this.cleanups.push(() => document.removeEventListener('visibilitychange', onVisibilityChange));
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
// Handle server-initiated auth revocation
|
|
168
|
+
this.cleanups.push(
|
|
169
|
+
this.subs.on(this.proto.authRevoked, () => {
|
|
170
|
+
if (this.coordinator.isLeader) {
|
|
171
|
+
for (const [, ch] of this.authChannels) ch.leave();
|
|
172
|
+
for (const topic of this.authTopics) this.unsubscribe(topic);
|
|
173
|
+
}
|
|
174
|
+
this.authChannels.clear();
|
|
175
|
+
this.authTopics.clear();
|
|
176
|
+
this._isAuthenticated = false;
|
|
177
|
+
this.syncStore.delete('$auth:token');
|
|
178
|
+
this.subs.emit('$lifecycle:auth', false);
|
|
179
|
+
this.log.warn('[SharedWS] auth revoked by server');
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
152
183
|
// Cleanup on tab close
|
|
153
184
|
if (typeof window !== 'undefined') {
|
|
154
185
|
const onBeforeUnload = () => this[Symbol.dispose]();
|
|
@@ -165,6 +196,11 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
165
196
|
return this.coordinator.isLeader ? 'leader' : 'follower';
|
|
166
197
|
}
|
|
167
198
|
|
|
199
|
+
/** Whether the user is authenticated via runtime auth. */
|
|
200
|
+
get isAuthenticated(): boolean {
|
|
201
|
+
return this._isAuthenticated;
|
|
202
|
+
}
|
|
203
|
+
|
|
168
204
|
/** Whether this tab is currently visible/focused. */
|
|
169
205
|
get isActive(): boolean {
|
|
170
206
|
return typeof document !== 'undefined' ? !document.hidden : true;
|
|
@@ -221,6 +257,64 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
221
257
|
return this.subs.on('$lifecycle:active', fn as EventHandler);
|
|
222
258
|
}
|
|
223
259
|
|
|
260
|
+
// ─── Authentication ──────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Authenticate on an existing connection. Sends auth event to server,
|
|
264
|
+
* syncs auth state across all tabs. Use for login after guest connection.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* const token = await loginApi(email, password);
|
|
268
|
+
* ws.authenticate(token);
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* // React — via useSocketAuth hook
|
|
272
|
+
* const { authenticate } = useSocketAuth();
|
|
273
|
+
* authenticate(token);
|
|
274
|
+
*/
|
|
275
|
+
authenticate(token: string): void {
|
|
276
|
+
this._isAuthenticated = true;
|
|
277
|
+
this.syncStore.set('$auth:token', token);
|
|
278
|
+
this.bus.broadcast('ws:sync', { key: '$auth:token', value: token });
|
|
279
|
+
this.send(this.proto.authLogin, { token });
|
|
280
|
+
this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: true });
|
|
281
|
+
this.log.info('[SharedWS] authenticated');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Deauthenticate — notifies server, auto-leaves all auth-required channels
|
|
286
|
+
* and topics, syncs state across tabs. Connection stays open for public events.
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ws.deauthenticate(); // connection stays open, auth subscriptions cleaned up
|
|
290
|
+
*/
|
|
291
|
+
deauthenticate(): void {
|
|
292
|
+
// Leave auth channels and unsubscribe auth topics
|
|
293
|
+
for (const [, ch] of this.authChannels) ch.leave();
|
|
294
|
+
this.authChannels.clear();
|
|
295
|
+
for (const topic of this.authTopics) this.unsubscribe(topic);
|
|
296
|
+
this.authTopics.clear();
|
|
297
|
+
|
|
298
|
+
this._isAuthenticated = false;
|
|
299
|
+
this.send(this.proto.authLogout, {});
|
|
300
|
+
this.syncStore.delete('$auth:token');
|
|
301
|
+
this.bus.broadcast('ws:sync', { key: '$auth:token', value: undefined });
|
|
302
|
+
this.bus.broadcast('ws:lifecycle', { type: 'auth', authenticated: false });
|
|
303
|
+
this.log.info('[SharedWS] deauthenticated');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Called when auth state changes (authenticate, deauthenticate, or server revocation).
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ws.onAuthChange((authenticated) => {
|
|
311
|
+
* if (!authenticated) router.push('/login');
|
|
312
|
+
* });
|
|
313
|
+
*/
|
|
314
|
+
onAuthChange(fn: (authenticated: boolean) => void): Unsubscribe {
|
|
315
|
+
return this.subs.on('$lifecycle:auth', fn as EventHandler);
|
|
316
|
+
}
|
|
317
|
+
|
|
224
318
|
// ─── Middleware ───────────────────────────────────────
|
|
225
319
|
|
|
226
320
|
/**
|
|
@@ -372,14 +466,15 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
372
466
|
* const notifications = ws.channel(`tenant:${tenantId}:notifications`);
|
|
373
467
|
* notifications.on('alert', (alert) => showToast(alert));
|
|
374
468
|
*/
|
|
375
|
-
channel(name: string): Channel {
|
|
469
|
+
channel(name: string, options?: { auth?: boolean }): Channel {
|
|
376
470
|
// Notify server about channel subscription
|
|
377
471
|
this.send(this.proto.channelJoin, { channel: name });
|
|
378
472
|
|
|
379
473
|
const self = this;
|
|
380
474
|
const unsubs: Unsubscribe[] = [];
|
|
475
|
+
const isAuth = options?.auth ?? false;
|
|
381
476
|
|
|
382
|
-
|
|
477
|
+
const ch: Channel = {
|
|
383
478
|
name,
|
|
384
479
|
on(event: string, handler: EventHandler): Unsubscribe {
|
|
385
480
|
const unsub = self.subs.on(`${name}:${event}`, handler);
|
|
@@ -401,8 +496,15 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
401
496
|
self.send(self.proto.channelLeave, { channel: name });
|
|
402
497
|
for (const unsub of unsubs) unsub();
|
|
403
498
|
unsubs.length = 0;
|
|
499
|
+
if (isAuth) self.authChannels.delete(name);
|
|
404
500
|
},
|
|
405
501
|
};
|
|
502
|
+
|
|
503
|
+
if (isAuth) {
|
|
504
|
+
this.authChannels.set(name, ch);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return ch;
|
|
406
508
|
}
|
|
407
509
|
|
|
408
510
|
// ─── Topics ──────────────────────────────────────────
|
|
@@ -416,9 +518,12 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
416
518
|
* ws.subscribe('notifications:payments');
|
|
417
519
|
* ws.subscribe(`user:${userId}:mentions`);
|
|
418
520
|
*/
|
|
419
|
-
subscribe(topic: string): void {
|
|
521
|
+
subscribe(topic: string, options?: { auth?: boolean }): void {
|
|
420
522
|
this.send(this.proto.topicSubscribe, { topic });
|
|
421
|
-
|
|
523
|
+
if (options?.auth) {
|
|
524
|
+
this.authTopics.add(topic);
|
|
525
|
+
}
|
|
526
|
+
this.log.debug('[SharedWS] subscribe topic', topic);
|
|
422
527
|
}
|
|
423
528
|
|
|
424
529
|
/**
|
|
@@ -427,7 +532,8 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
427
532
|
*/
|
|
428
533
|
unsubscribe(topic: string): void {
|
|
429
534
|
this.send(this.proto.topicUnsubscribe, { topic });
|
|
430
|
-
this.
|
|
535
|
+
this.authTopics.delete(topic);
|
|
536
|
+
this.log.debug('[SharedWS] unsubscribe topic', topic);
|
|
431
537
|
}
|
|
432
538
|
|
|
433
539
|
// ─── Push Notifications ─────────────────────────────
|
|
@@ -615,6 +721,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
615
721
|
switch (state) {
|
|
616
722
|
case 'connected':
|
|
617
723
|
this.bus.broadcast('ws:lifecycle', { type: 'connect' });
|
|
724
|
+
this.reAuthenticateOnReconnect();
|
|
618
725
|
break;
|
|
619
726
|
case 'closed':
|
|
620
727
|
this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
|
|
@@ -643,6 +750,35 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
643
750
|
this.socket.connect();
|
|
644
751
|
}
|
|
645
752
|
|
|
753
|
+
private reAuthenticateOnReconnect(): void {
|
|
754
|
+
if (!this._isAuthenticated || !this.socket) return;
|
|
755
|
+
|
|
756
|
+
const token = this.syncStore.get('$auth:token') as string | undefined;
|
|
757
|
+
if (token) {
|
|
758
|
+
this.socket.send({
|
|
759
|
+
[this.proto.eventField]: this.proto.authLogin,
|
|
760
|
+
[this.proto.dataField]: { token },
|
|
761
|
+
});
|
|
762
|
+
this.log.debug('[SharedWS] re-authenticated after reconnect');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Re-join auth channels
|
|
766
|
+
for (const name of this.authChannels.keys()) {
|
|
767
|
+
this.socket.send({
|
|
768
|
+
[this.proto.eventField]: this.proto.channelJoin,
|
|
769
|
+
[this.proto.dataField]: { channel: name },
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Re-subscribe auth topics
|
|
774
|
+
for (const topic of this.authTopics) {
|
|
775
|
+
this.socket.send({
|
|
776
|
+
[this.proto.eventField]: this.proto.topicSubscribe,
|
|
777
|
+
[this.proto.dataField]: { topic },
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
646
782
|
private handleLoseLeadership(): void {
|
|
647
783
|
if (this.socket) {
|
|
648
784
|
this.socket[Symbol.dispose]();
|
|
@@ -666,5 +802,7 @@ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Dis
|
|
|
666
802
|
this.subs[Symbol.dispose]();
|
|
667
803
|
this.bus[Symbol.dispose]();
|
|
668
804
|
this.syncStore.clear();
|
|
805
|
+
this.authChannels.clear();
|
|
806
|
+
this.authTopics.clear();
|
|
669
807
|
}
|
|
670
808
|
}
|
package/src/adapters/react.ts
CHANGED
|
@@ -78,6 +78,55 @@ export function useSharedWebSocket(): SharedWebSocket {
|
|
|
78
78
|
return ctx;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Reactive auth state with authenticate/deauthenticate actions.
|
|
83
|
+
* Syncs across all tabs via BroadcastChannel.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* function LoginPage() {
|
|
87
|
+
* const { authenticate } = useSocketAuth();
|
|
88
|
+
* const login = async (email: string, password: string) => {
|
|
89
|
+
* const { token } = await api.login(email, password);
|
|
90
|
+
* authenticate(token);
|
|
91
|
+
* };
|
|
92
|
+
* return <button onClick={() => login('user@test.com', 'pass')}>Login</button>;
|
|
93
|
+
* }
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* function Header() {
|
|
97
|
+
* const { isAuthenticated, deauthenticate } = useSocketAuth();
|
|
98
|
+
* return isAuthenticated
|
|
99
|
+
* ? <button onClick={deauthenticate}>Logout</button>
|
|
100
|
+
* : <Link to="/login">Login</Link>;
|
|
101
|
+
* }
|
|
102
|
+
*/
|
|
103
|
+
export function useSocketAuth(): {
|
|
104
|
+
isAuthenticated: boolean;
|
|
105
|
+
authenticate: (token: string) => void;
|
|
106
|
+
deauthenticate: () => void;
|
|
107
|
+
} {
|
|
108
|
+
const socket = useSharedWebSocket();
|
|
109
|
+
const [isAuthenticated, setIsAuthenticated] = useState(socket.isAuthenticated);
|
|
110
|
+
|
|
111
|
+
const onAuthChange = useEffectEvent((authenticated: boolean) => {
|
|
112
|
+
setIsAuthenticated(authenticated);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
return socket.onAuthChange(onAuthChange);
|
|
117
|
+
}, [socket]);
|
|
118
|
+
|
|
119
|
+
const authenticate = useEffectEvent((token: string) => {
|
|
120
|
+
socket.authenticate(token);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const deauthenticate = useEffectEvent(() => {
|
|
124
|
+
socket.deauthenticate();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { isAuthenticated, authenticate, deauthenticate };
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
// ─── Hooks ───────────────────────────────────────────────
|
|
82
131
|
|
|
83
132
|
/**
|
|
@@ -253,14 +302,17 @@ export function useSocketCallback<T>(event: string, callback: (data: T) => void)
|
|
|
253
302
|
export function useSocketStatus(): {
|
|
254
303
|
connected: boolean;
|
|
255
304
|
tabRole: TabRole;
|
|
305
|
+
isAuthenticated: boolean;
|
|
256
306
|
} {
|
|
257
307
|
const socket = useSharedWebSocket();
|
|
258
308
|
const [connected, setConnected] = useState(socket.connected);
|
|
259
309
|
const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);
|
|
310
|
+
const [isAuthenticated, setIsAuthenticated] = useState(socket.isAuthenticated);
|
|
260
311
|
|
|
261
312
|
const tick = useEffectEvent(() => {
|
|
262
313
|
setConnected(socket.connected);
|
|
263
314
|
setTabRole(socket.tabRole);
|
|
315
|
+
setIsAuthenticated(socket.isAuthenticated);
|
|
264
316
|
});
|
|
265
317
|
|
|
266
318
|
useEffect(() => {
|
|
@@ -268,7 +320,7 @@ export function useSocketStatus(): {
|
|
|
268
320
|
return () => clearInterval(interval);
|
|
269
321
|
}, [socket]);
|
|
270
322
|
|
|
271
|
-
return { connected, tabRole };
|
|
323
|
+
return { connected, tabRole, isAuthenticated };
|
|
272
324
|
}
|
|
273
325
|
|
|
274
326
|
/**
|
|
@@ -294,6 +346,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
294
346
|
const onActive = useEffectEvent(() => handlers.onActive?.());
|
|
295
347
|
const onInactive = useEffectEvent(() => handlers.onInactive?.());
|
|
296
348
|
const onVisibilityChange = useEffectEvent((isActive: boolean) => handlers.onVisibilityChange?.(isActive));
|
|
349
|
+
const onAuthChange = useEffectEvent((authenticated: boolean) => handlers.onAuthChange?.(authenticated));
|
|
297
350
|
|
|
298
351
|
useEffect(() => {
|
|
299
352
|
const unsubs = [
|
|
@@ -305,6 +358,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
305
358
|
socket.onActive(onActive),
|
|
306
359
|
socket.onInactive(onInactive),
|
|
307
360
|
socket.onVisibilityChange(onVisibilityChange),
|
|
361
|
+
socket.onAuthChange(onAuthChange),
|
|
308
362
|
];
|
|
309
363
|
return () => unsubs.forEach((u) => u());
|
|
310
364
|
}, [socket]);
|
|
@@ -323,12 +377,12 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
323
377
|
* const notifications = useChannel(`tenant:${tenantId}:notifications`);
|
|
324
378
|
* useSocketCallback(`tenant:${tenantId}:notifications:alert`, showToast);
|
|
325
379
|
*/
|
|
326
|
-
export function useChannel(name: string) {
|
|
380
|
+
export function useChannel(name: string, options?: { auth?: boolean }) {
|
|
327
381
|
const socket = useSharedWebSocket();
|
|
328
|
-
const channelRef = useRef(socket.channel(name));
|
|
382
|
+
const channelRef = useRef(socket.channel(name, options));
|
|
329
383
|
|
|
330
384
|
useEffect(() => {
|
|
331
|
-
channelRef.current = socket.channel(name);
|
|
385
|
+
channelRef.current = socket.channel(name, options);
|
|
332
386
|
return () => channelRef.current.leave();
|
|
333
387
|
}, [socket, name]);
|
|
334
388
|
|
|
@@ -342,11 +396,11 @@ export function useChannel(name: string) {
|
|
|
342
396
|
* useTopics(['notifications:orders', 'notifications:payments']);
|
|
343
397
|
* useTopics([`user:${userId}:mentions`]);
|
|
344
398
|
*/
|
|
345
|
-
export function useTopics(topics: string[]): void {
|
|
399
|
+
export function useTopics(topics: string[], options?: { auth?: boolean }): void {
|
|
346
400
|
const socket = useSharedWebSocket();
|
|
347
401
|
|
|
348
402
|
useEffect(() => {
|
|
349
|
-
topics.forEach((t) => socket.subscribe(t));
|
|
403
|
+
topics.forEach((t) => socket.subscribe(t, options));
|
|
350
404
|
return () => topics.forEach((t) => socket.unsubscribe(t));
|
|
351
405
|
}, [socket, topics.join(',')]);
|
|
352
406
|
}
|
package/src/adapters/vue.ts
CHANGED
|
@@ -56,6 +56,42 @@ export function useSharedWebSocket(): SharedWebSocket {
|
|
|
56
56
|
return socket;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Reactive auth state with authenticate/deauthenticate actions.
|
|
61
|
+
* Syncs across all tabs.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const { isAuthenticated, authenticate, deauthenticate } = useSocketAuth();
|
|
65
|
+
*
|
|
66
|
+
* async function login(email: string, password: string) {
|
|
67
|
+
* const { token } = await api.login(email, password);
|
|
68
|
+
* authenticate(token);
|
|
69
|
+
* }
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* // In template: <button v-if="isAuthenticated" @click="deauthenticate">Logout</button>
|
|
73
|
+
*/
|
|
74
|
+
export function useSocketAuth(): {
|
|
75
|
+
isAuthenticated: Ref<boolean>;
|
|
76
|
+
authenticate: (token: string) => void;
|
|
77
|
+
deauthenticate: () => void;
|
|
78
|
+
} {
|
|
79
|
+
const socket = useSharedWebSocket();
|
|
80
|
+
const isAuthenticated = ref(socket.isAuthenticated);
|
|
81
|
+
|
|
82
|
+
const unsub = socket.onAuthChange((authenticated: boolean) => {
|
|
83
|
+
isAuthenticated.value = authenticated;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
onUnmounted(unsub);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,
|
|
90
|
+
authenticate: (token: string) => socket.authenticate(token),
|
|
91
|
+
deauthenticate: () => socket.deauthenticate(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
59
95
|
// ─── Composables ─────────────────────────────────────────
|
|
60
96
|
|
|
61
97
|
/**
|
|
@@ -198,14 +234,17 @@ export function useSocketCallback<T>(event: string, callback: (data: T) => void)
|
|
|
198
234
|
export function useSocketStatus(): {
|
|
199
235
|
connected: Ref<boolean>;
|
|
200
236
|
tabRole: Ref<TabRole>;
|
|
237
|
+
isAuthenticated: Ref<boolean>;
|
|
201
238
|
} {
|
|
202
239
|
const socket = useSharedWebSocket();
|
|
203
240
|
const connected = ref(socket.connected);
|
|
204
241
|
const tabRole = ref<TabRole>(socket.tabRole);
|
|
242
|
+
const isAuthenticated = ref(socket.isAuthenticated);
|
|
205
243
|
|
|
206
244
|
const timer = setInterval(() => {
|
|
207
245
|
connected.value = socket.connected;
|
|
208
246
|
tabRole.value = socket.tabRole;
|
|
247
|
+
isAuthenticated.value = socket.isAuthenticated;
|
|
209
248
|
}, 1000);
|
|
210
249
|
|
|
211
250
|
onUnmounted(() => clearInterval(timer));
|
|
@@ -213,6 +252,7 @@ export function useSocketStatus(): {
|
|
|
213
252
|
return {
|
|
214
253
|
connected: readonly(connected) as Ref<boolean>,
|
|
215
254
|
tabRole: readonly(tabRole) as Ref<TabRole>,
|
|
255
|
+
isAuthenticated: readonly(isAuthenticated) as Ref<boolean>,
|
|
216
256
|
};
|
|
217
257
|
}
|
|
218
258
|
|
|
@@ -240,6 +280,7 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
240
280
|
if (handlers.onActive) unsubs.push(socket.onActive(handlers.onActive));
|
|
241
281
|
if (handlers.onInactive) unsubs.push(socket.onInactive(handlers.onInactive));
|
|
242
282
|
if (handlers.onVisibilityChange) unsubs.push(socket.onVisibilityChange(handlers.onVisibilityChange));
|
|
283
|
+
if (handlers.onAuthChange) unsubs.push(socket.onAuthChange(handlers.onAuthChange));
|
|
243
284
|
|
|
244
285
|
onUnmounted(() => unsubs.forEach((u) => u()));
|
|
245
286
|
}
|
|
@@ -252,9 +293,9 @@ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
|
|
|
252
293
|
* // Listen via useSocketEvent('chat:room_123:message')
|
|
253
294
|
* // Send via chat.send('message', { text: 'Hello' })
|
|
254
295
|
*/
|
|
255
|
-
export function useChannel(name: string) {
|
|
296
|
+
export function useChannel(name: string, options?: { auth?: boolean }) {
|
|
256
297
|
const socket = useSharedWebSocket();
|
|
257
|
-
const channel = socket.channel(name);
|
|
298
|
+
const channel = socket.channel(name, options);
|
|
258
299
|
|
|
259
300
|
onUnmounted(() => channel.leave());
|
|
260
301
|
|
|
@@ -267,10 +308,10 @@ export function useChannel(name: string) {
|
|
|
267
308
|
* @example
|
|
268
309
|
* useTopics(['notifications:orders', 'notifications:payments']);
|
|
269
310
|
*/
|
|
270
|
-
export function useTopics(topics: string[]): void {
|
|
311
|
+
export function useTopics(topics: string[], options?: { auth?: boolean }): void {
|
|
271
312
|
const socket = useSharedWebSocket();
|
|
272
313
|
|
|
273
|
-
topics.forEach((t) => socket.subscribe(t));
|
|
314
|
+
topics.forEach((t) => socket.subscribe(t, options));
|
|
274
315
|
|
|
275
316
|
onUnmounted(() => {
|
|
276
317
|
topics.forEach((t) => socket.unsubscribe(t));
|
package/src/types.ts
CHANGED
|
@@ -109,6 +109,12 @@ export interface EventProtocol {
|
|
|
109
109
|
topicSubscribe: string;
|
|
110
110
|
/** Event name for topic unsubscribe (default: "$topic:unsubscribe"). */
|
|
111
111
|
topicUnsubscribe: string;
|
|
112
|
+
/** Event name sent when authenticating at runtime (default: "$auth:login"). */
|
|
113
|
+
authLogin: string;
|
|
114
|
+
/** Event name sent when deauthenticating (default: "$auth:logout"). */
|
|
115
|
+
authLogout: string;
|
|
116
|
+
/** Event name server sends to revoke auth (default: "$auth:revoked"). */
|
|
117
|
+
authRevoked: string;
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
/** Push notification options. */
|
|
@@ -136,6 +142,8 @@ export interface SocketLifecycleHandlers {
|
|
|
136
142
|
onInactive?: () => void;
|
|
137
143
|
/** Called on any visibility change. */
|
|
138
144
|
onVisibilityChange?: (isActive: boolean) => void;
|
|
145
|
+
/** Called when auth state changes (authenticate/deauthenticate/server revocation). */
|
|
146
|
+
onAuthChange?: (authenticated: boolean) => void;
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
/** Scoped channel handle for private/topic-based subscriptions. */
|