@gwakko/shared-websocket 0.10.2 → 0.11.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/dist/vue.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SharedWebSocket
3
- } from "./chunk-PKZXBX5I.js";
3
+ } from "./chunk-VUTUECT2.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 useAuth() {
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
  });
@@ -134,6 +151,7 @@ function usePush(event, config) {
134
151
  export {
135
152
  SharedWebSocketKey,
136
153
  createSharedWebSocketPlugin,
154
+ useAuth,
137
155
  useChannel,
138
156
  usePush,
139
157
  useSharedWebSocket,
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 } = useAuth();\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 useAuth(): {\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.10.2",
3
+ "version": "0.11.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",
@@ -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 useAuth hook
272
+ * const { authenticate } = useAuth();
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
- return {
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
- this.log.debug('[SharedWS] 📌 subscribe topic', topic);
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.log.debug('[SharedWS] 📌 unsubscribe topic', topic);
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
  }
@@ -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 } = useAuth();
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 } = useAuth();
98
+ * return isAuthenticated
99
+ * ? <button onClick={deauthenticate}>Logout</button>
100
+ * : <Link to="/login">Login</Link>;
101
+ * }
102
+ */
103
+ export function useAuth(): {
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
  }
@@ -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 } = useAuth();
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 useAuth(): {
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. */