@gwakko/shared-websocket 0.3.0 → 0.6.2

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.
@@ -11,6 +11,8 @@ interface SharedSocketOptions {
11
11
  auth?: () => string | Promise<string>;
12
12
  authToken?: string;
13
13
  authParam?: string;
14
+ /** Heartbeat payload (default: { type: "ping" }). */
15
+ pingPayload?: unknown;
14
16
  }
15
17
 
16
18
  export class SharedSocket implements Disposable {
@@ -24,10 +26,11 @@ export class SharedSocket implements Disposable {
24
26
  private onMessageFns = new Set<EventHandler>();
25
27
  private onStateChangeFns = new Set<(state: SocketState) => void>();
26
28
 
27
- private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam'>> & {
29
+ private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload'>> & {
28
30
  auth?: () => string | Promise<string>;
29
31
  authToken?: string;
30
32
  authParam: string;
33
+ pingPayload: unknown;
31
34
  };
32
35
 
33
36
  constructor(
@@ -43,6 +46,7 @@ export class SharedSocket implements Disposable {
43
46
  auth: options.auth,
44
47
  authToken: options.authToken,
45
48
  authParam: options.authParam ?? 'token',
49
+ pingPayload: options.pingPayload ?? { type: 'ping' },
46
50
  };
47
51
  }
48
52
 
@@ -152,7 +156,7 @@ export class SharedSocket implements Disposable {
152
156
  this.stopHeartbeat();
153
157
  this.heartbeatTimer = setInterval(() => {
154
158
  if (this.ws?.readyState === WebSocket.OPEN) {
155
- this.ws.send(JSON.stringify({ type: 'ping' }));
159
+ this.ws.send(JSON.stringify(this.opts.pingPayload));
156
160
  }
157
161
  }, this.opts.heartbeatInterval);
158
162
  }
@@ -5,7 +5,23 @@ import { TabCoordinator } from './TabCoordinator';
5
5
  import { SharedSocket } from './SharedSocket';
6
6
  import { WorkerSocket } from './WorkerSocket';
7
7
  import { SubscriptionManager } from './SubscriptionManager';
8
- import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';
8
+ import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler, Channel, EventProtocol, EventMap, Logger, Middleware } from './types';
9
+
10
+ const DEFAULT_PROTOCOL: EventProtocol = {
11
+ eventField: 'event',
12
+ dataField: 'data',
13
+ channelJoin: '$channel:join',
14
+ channelLeave: '$channel:leave',
15
+ ping: { type: 'ping' },
16
+ defaultEvent: 'message',
17
+ };
18
+
19
+ const NOOP_LOGGER: Logger = {
20
+ debug() {},
21
+ info() {},
22
+ warn() {},
23
+ error() {},
24
+ };
9
25
 
10
26
  /** Common interface for both SharedSocket and WorkerSocket. */
11
27
  interface SocketAdapter {
@@ -21,11 +37,18 @@ interface SocketAdapter {
21
37
  /**
22
38
  * SharedWebSocket — shares ONE WebSocket connection across browser tabs.
23
39
  *
24
- * One tab becomes the "leader" and holds the WebSocket.
25
- * Other tabs are "followers" receiving data via BroadcastChannel.
26
- * If the leader closes, a new leader is elected automatically.
40
+ * @typeParam TEvents - Event map for type-safe subscriptions.
41
+ *
42
+ * @example
43
+ * // Typed events
44
+ * type Events = {
45
+ * 'chat.message': { text: string; userId: string };
46
+ * 'order.created': { id: string; total: number };
47
+ * };
48
+ * const ws = new SharedWebSocket<Events>(url);
49
+ * ws.on('chat.message', (msg) => msg.text); // ← msg: { text, userId }
27
50
  */
28
- export class SharedWebSocket implements Disposable {
51
+ export class SharedWebSocket<TEvents extends EventMap = EventMap> implements Disposable {
29
52
  private bus: MessageBus;
30
53
  private coordinator: TabCoordinator;
31
54
  private socket: SocketAdapter | null = null;
@@ -34,12 +57,19 @@ export class SharedWebSocket implements Disposable {
34
57
  private tabId: string;
35
58
  private cleanups: Unsubscribe[] = [];
36
59
  private disposed = false;
60
+ private readonly proto: EventProtocol;
61
+ private readonly log: Logger;
62
+ private outgoingMiddleware: Middleware[] = [];
63
+ private incomingMiddleware: Middleware[] = [];
37
64
 
38
65
  constructor(
39
66
  private readonly url: string,
40
- private readonly options: SharedWebSocketOptions = {},
67
+ private readonly options: SharedWebSocketOptions<TEvents> = {} as SharedWebSocketOptions<TEvents>,
41
68
  ) {
69
+ this.proto = { ...DEFAULT_PROTOCOL, ...options.events };
70
+ this.log = options.debug ? (options.logger ?? console) : NOOP_LOGGER;
42
71
  this.tabId = generateId();
72
+ this.log.debug('[SharedWS] init', { tabId: this.tabId, url });
43
73
  this.bus = new MessageBus('shared-ws', this.tabId);
44
74
  this.coordinator = new TabCoordinator(this.bus, this.tabId, {
45
75
  electionTimeout: options.electionTimeout,
@@ -58,7 +88,7 @@ export class SharedWebSocket implements Disposable {
58
88
  this.cleanups.push(
59
89
  this.bus.subscribe<{ event: string; data: unknown }>('ws:send', (msg) => {
60
90
  if (this.coordinator.isLeader && this.socket) {
61
- this.socket.send({ event: msg.event, data: msg.data });
91
+ this.socket.send({ [this.proto.eventField]: msg.event, [this.proto.dataField]: msg.data });
62
92
  }
63
93
  }),
64
94
  );
@@ -72,8 +102,37 @@ export class SharedWebSocket implements Disposable {
72
102
  );
73
103
 
74
104
  // Leader lifecycle
75
- this.coordinator.onBecomeLeader(() => this.onBecomeLeader());
76
- this.coordinator.onLoseLeadership(() => this.onLoseLeadership());
105
+ this.coordinator.onBecomeLeader(() => {
106
+ this.handleBecomeLeader();
107
+ this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: true });
108
+ });
109
+ this.coordinator.onLoseLeadership(() => {
110
+ this.handleLoseLeadership();
111
+ this.bus.broadcast('ws:lifecycle', { type: 'leader', isLeader: false });
112
+ });
113
+
114
+ // Lifecycle events from bus (all tabs receive)
115
+ this.cleanups.push(
116
+ this.bus.subscribe<{ type: string; isLeader?: boolean; error?: unknown }>('ws:lifecycle', (msg) => {
117
+ switch (msg.type) {
118
+ case 'connect':
119
+ this.subs.emit('$lifecycle:connect', undefined);
120
+ break;
121
+ case 'disconnect':
122
+ this.subs.emit('$lifecycle:disconnect', undefined);
123
+ break;
124
+ case 'reconnecting':
125
+ this.subs.emit('$lifecycle:reconnecting', undefined);
126
+ break;
127
+ case 'leader':
128
+ this.subs.emit('$lifecycle:leader', msg.isLeader);
129
+ break;
130
+ case 'error':
131
+ this.subs.emit('$lifecycle:error', msg.error);
132
+ break;
133
+ }
134
+ }),
135
+ );
77
136
 
78
137
  // Cleanup on tab close
79
138
  if (typeof window !== 'undefined') {
@@ -96,12 +155,74 @@ export class SharedWebSocket implements Disposable {
96
155
  await this.coordinator.elect();
97
156
  }
98
157
 
99
- /** Subscribe to server events (works in ALL tabs). */
100
- on(event: string, handler: EventHandler): Unsubscribe {
158
+ // ─── Lifecycle Hooks ─────────────────────────────────
159
+
160
+ /** Called when WebSocket connection opens (broadcast to all tabs). */
161
+ onConnect(fn: () => void): Unsubscribe {
162
+ return this.subs.on('$lifecycle:connect', fn);
163
+ }
164
+
165
+ /** Called when WebSocket connection closes (broadcast to all tabs). */
166
+ onDisconnect(fn: () => void): Unsubscribe {
167
+ return this.subs.on('$lifecycle:disconnect', fn);
168
+ }
169
+
170
+ /** Called when WebSocket starts reconnecting (broadcast to all tabs). */
171
+ onReconnecting(fn: () => void): Unsubscribe {
172
+ return this.subs.on('$lifecycle:reconnecting', fn);
173
+ }
174
+
175
+ /** Called when this tab becomes leader or loses leadership. */
176
+ onLeaderChange(fn: (isLeader: boolean) => void): Unsubscribe {
177
+ return this.subs.on('$lifecycle:leader', fn as EventHandler);
178
+ }
179
+
180
+ /** Called on WebSocket or network error (broadcast to all tabs). */
181
+ onError(fn: (error: unknown) => void): Unsubscribe {
182
+ return this.subs.on('$lifecycle:error', fn as EventHandler);
183
+ }
184
+
185
+ // ─── Middleware ───────────────────────────────────────
186
+
187
+ /**
188
+ * Add middleware to transform messages before send or after receive.
189
+ * Return null from middleware to drop the message.
190
+ *
191
+ * @example
192
+ * // Add timestamp to every outgoing message
193
+ * ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
194
+ *
195
+ * @example
196
+ * // Decrypt incoming messages
197
+ * ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
198
+ *
199
+ * @example
200
+ * // Drop messages from blocked users
201
+ * ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
202
+ */
203
+ use(direction: 'outgoing' | 'incoming', fn: Middleware): this {
204
+ if (direction === 'outgoing') {
205
+ this.outgoingMiddleware.push(fn);
206
+ } else {
207
+ this.incomingMiddleware.push(fn);
208
+ }
209
+ return this;
210
+ }
211
+
212
+ // ─── Event Subscription ──────────────────────────────
213
+
214
+ /** Subscribe to server events (works in ALL tabs). Type-safe with EventMap. */
215
+ on<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
216
+ on(event: string, handler: EventHandler<unknown>): Unsubscribe;
217
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
218
+ on(event: string, handler: (data: any) => void): Unsubscribe {
101
219
  return this.subs.on(event, handler);
102
220
  }
103
221
 
104
- once(event: string, handler: EventHandler): Unsubscribe {
222
+ once<K extends string & keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): Unsubscribe;
223
+ once(event: string, handler: EventHandler<unknown>): Unsubscribe;
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
+ once(event: string, handler: (data: any) => void): Unsubscribe {
105
226
  return this.subs.once(event, handler);
106
227
  }
107
228
 
@@ -109,15 +230,31 @@ export class SharedWebSocket implements Disposable {
109
230
  this.subs.off(event, handler);
110
231
  }
111
232
 
112
- /** Async generator for consuming events. */
233
+ /** Async generator for consuming events. Type-safe with EventMap. */
234
+ stream<K extends string & keyof TEvents>(event: K, signal?: AbortSignal): AsyncGenerator<TEvents[K]>;
235
+ stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
113
236
  stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
114
237
  return this.subs.stream(event, signal);
115
238
  }
116
239
 
117
- /** Send message to server (auto-routed through leader). */
240
+ /** Send message to server (auto-routed through leader). Type-safe with EventMap. */
241
+ send<K extends string & keyof TEvents>(event: K, data: TEvents[K]): void;
242
+ send(event: string, data: unknown): void;
118
243
  send(event: string, data: unknown): void {
244
+ let payload: unknown = { [this.proto.eventField]: event, [this.proto.dataField]: data };
245
+
246
+ for (const mw of this.outgoingMiddleware) {
247
+ payload = mw(payload);
248
+ if (payload === null) {
249
+ this.log.debug('[SharedWS] ✗ outgoing dropped by middleware', event);
250
+ return;
251
+ }
252
+ }
253
+
254
+ this.log.debug('[SharedWS] → send', event, data);
255
+
119
256
  if (this.coordinator.isLeader && this.socket) {
120
- this.socket.send({ event, data });
257
+ this.socket.send(payload);
121
258
  } else {
122
259
  this.bus.publish('ws:send', { event, data });
123
260
  }
@@ -142,6 +279,54 @@ export class SharedWebSocket implements Disposable {
142
279
  return this.subs.on(`sync:${key}`, fn as EventHandler);
143
280
  }
144
281
 
282
+ /**
283
+ * Subscribe to a private/scoped channel. Returns a channel handle with
284
+ * scoped on/send/stream methods. Sends join on subscribe, leave on unsubscribe.
285
+ *
286
+ * @example
287
+ * const chat = ws.channel('chat:room_123');
288
+ * chat.on('message', (msg) => render(msg));
289
+ * chat.send('message', { text: 'Hello' });
290
+ * chat.leave(); // sends leave + unsubscribes
291
+ *
292
+ * @example
293
+ * // Private notifications for tenant
294
+ * const notifications = ws.channel(`tenant:${tenantId}:notifications`);
295
+ * notifications.on('alert', (alert) => showToast(alert));
296
+ */
297
+ channel(name: string): Channel {
298
+ // Notify server about channel subscription
299
+ this.send(this.proto.channelJoin, { channel: name });
300
+
301
+ const self = this;
302
+ const unsubs: Unsubscribe[] = [];
303
+
304
+ return {
305
+ name,
306
+ on(event: string, handler: EventHandler): Unsubscribe {
307
+ const unsub = self.subs.on(`${name}:${event}`, handler);
308
+ unsubs.push(unsub);
309
+ return unsub;
310
+ },
311
+ once(event: string, handler: EventHandler): Unsubscribe {
312
+ const unsub = self.subs.once(`${name}:${event}`, handler);
313
+ unsubs.push(unsub);
314
+ return unsub;
315
+ },
316
+ send(event: string, data: unknown): void {
317
+ self.send(`${name}:${event}`, data);
318
+ },
319
+ stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown> {
320
+ return self.subs.stream(`${name}:${event}`, signal);
321
+ },
322
+ leave(): void {
323
+ self.send(self.proto.channelLeave, { channel: name });
324
+ for (const unsub of unsubs) unsub();
325
+ unsubs.length = 0;
326
+ },
327
+ };
328
+ }
329
+
145
330
  disconnect(): void {
146
331
  this[Symbol.dispose]();
147
332
  }
@@ -153,6 +338,7 @@ export class SharedWebSocket implements Disposable {
153
338
  reconnectMaxDelay: this.options.reconnectMaxDelay,
154
339
  heartbeatInterval: this.options.heartbeatInterval,
155
340
  sendBuffer: this.options.sendBuffer,
341
+ pingPayload: this.proto.ping,
156
342
  };
157
343
 
158
344
  if (this.options.useWorker) {
@@ -172,24 +358,50 @@ export class SharedWebSocket implements Disposable {
172
358
  });
173
359
  }
174
360
 
175
- private onBecomeLeader(): void {
361
+ private handleBecomeLeader(): void {
362
+ this.log.info('[SharedWS] 👑 became leader');
176
363
  this.socket = this.createSocket();
177
364
 
178
- this.socket.onMessage((data: any) => {
179
- const event = data?.event ?? 'message';
180
- const payload = data?.data ?? data;
181
- // Broadcast to ALL tabs (including self)
365
+ this.socket.onMessage((raw: unknown) => {
366
+ let data: unknown = raw;
367
+ for (const mw of this.incomingMiddleware) {
368
+ data = mw(data);
369
+ if (data === null) {
370
+ this.log.debug('[SharedWS] ✗ incoming dropped by middleware');
371
+ return;
372
+ }
373
+ }
374
+
375
+ const msg = data as Record<string, unknown> | null | undefined;
376
+ const event = (msg?.[this.proto.eventField] as string) ?? this.proto.defaultEvent;
377
+ const payload = msg?.[this.proto.dataField] ?? data;
378
+ this.log.debug('[SharedWS] ← recv', event, payload);
182
379
  this.bus.broadcast('ws:message', { event, data: payload });
183
380
  });
184
381
 
185
- // Handle send requests from followers (request/response pattern)
382
+ this.socket.onStateChange((state: string) => {
383
+ this.log.info('[SharedWS]', state === 'connected' ? '✓ connected' : state === 'reconnecting' ? '🔄 reconnecting' : `state: ${state}`);
384
+ switch (state) {
385
+ case 'connected':
386
+ this.bus.broadcast('ws:lifecycle', { type: 'connect' });
387
+ break;
388
+ case 'closed':
389
+ this.bus.broadcast('ws:lifecycle', { type: 'disconnect' });
390
+ break;
391
+ case 'reconnecting':
392
+ this.bus.broadcast('ws:lifecycle', { type: 'reconnecting' });
393
+ break;
394
+ }
395
+ });
396
+
186
397
  this.cleanups.push(
187
398
  this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {
188
399
  return new Promise((resolve) => {
189
- const unsub = this.socket!.onMessage((response: any) => {
190
- if (response?.event === req.event || response?.requestId) {
400
+ const unsub = this.socket!.onMessage((response: unknown) => {
401
+ const res = response as Record<string, unknown> | undefined;
402
+ if (res?.[this.proto.eventField] === req.event || res?.requestId) {
191
403
  unsub();
192
- resolve(response?.data ?? response);
404
+ resolve(res?.[this.proto.dataField] ?? response);
193
405
  }
194
406
  });
195
407
  this.socket!.send({ event: req.event, data: req.data });
@@ -200,7 +412,7 @@ export class SharedWebSocket implements Disposable {
200
412
  this.socket.connect();
201
413
  }
202
414
 
203
- private onLoseLeadership(): void {
415
+ private handleLoseLeadership(): void {
204
416
  if (this.socket) {
205
417
  this.socket[Symbol.dispose]();
206
418
  this.socket = null;
@@ -2,13 +2,14 @@ import {
2
2
  createContext,
3
3
  useContext,
4
4
  useEffect,
5
+ useRef,
5
6
  useState,
6
7
  useEffectEvent,
7
8
  type ReactNode,
8
9
  createElement,
9
10
  } from 'react';
10
11
  import { SharedWebSocket } from '../SharedWebSocket';
11
- import type { SharedWebSocketOptions, TabRole } from '../types';
12
+ import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers, EventHandler } from '../types';
12
13
 
13
14
  // ─── Context ─────────────────────────────────────────────
14
15
 
@@ -115,7 +116,7 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
115
116
  });
116
117
 
117
118
  useEffect(() => {
118
- const unsub = socket.on(event, onEvent);
119
+ const unsub = socket.on(event, onEvent as EventHandler);
119
120
  return unsub;
120
121
  }, [socket, event]);
121
122
 
@@ -159,7 +160,7 @@ export function useSocketStream<T>(event: string, callback?: (data: T) => void):
159
160
 
160
161
  useEffect(() => {
161
162
  if (!callback) setItems([]);
162
- const unsub = socket.on(event, onEvent);
163
+ const unsub = socket.on(event, onEvent as EventHandler);
163
164
  return unsub;
164
165
  }, [socket, event]);
165
166
 
@@ -237,7 +238,7 @@ export function useSocketCallback<T>(event: string, callback: (data: T) => void)
237
238
  });
238
239
 
239
240
  useEffect(() => {
240
- const unsub = socket.on(event, handler);
241
+ const unsub = socket.on(event, handler as EventHandler);
241
242
  return unsub;
242
243
  }, [socket, event]);
243
244
  }
@@ -269,3 +270,61 @@ export function useSocketStatus(): {
269
270
 
270
271
  return { connected, tabRole };
271
272
  }
273
+
274
+ /**
275
+ * Lifecycle hooks — react to connection state changes.
276
+ *
277
+ * @example
278
+ * useSocketLifecycle({
279
+ * onConnect: () => console.log('Connected!'),
280
+ * onDisconnect: () => console.log('Disconnected'),
281
+ * onReconnecting: () => showSpinner(),
282
+ * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
283
+ * onError: (err) => reportError(err),
284
+ * });
285
+ */
286
+ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
287
+ const socket = useSharedWebSocket();
288
+
289
+ const onConnect = useEffectEvent(() => handlers.onConnect?.());
290
+ const onDisconnect = useEffectEvent(() => handlers.onDisconnect?.());
291
+ const onReconnecting = useEffectEvent(() => handlers.onReconnecting?.());
292
+ const onLeaderChange = useEffectEvent((isLeader: boolean) => handlers.onLeaderChange?.(isLeader));
293
+ const onError = useEffectEvent((error: unknown) => handlers.onError?.(error));
294
+
295
+ useEffect(() => {
296
+ const unsubs = [
297
+ socket.onConnect(onConnect),
298
+ socket.onDisconnect(onDisconnect),
299
+ socket.onReconnecting(onReconnecting),
300
+ socket.onLeaderChange(onLeaderChange),
301
+ socket.onError(onError),
302
+ ];
303
+ return () => unsubs.forEach((u) => u());
304
+ }, [socket]);
305
+ }
306
+
307
+ /**
308
+ * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
309
+ *
310
+ * @example
311
+ * const chat = useChannel('chat:room_123');
312
+ * const message = useSocketEvent('chat:room_123:message');
313
+ * chat.send('message', { text: 'Hello' });
314
+ *
315
+ * @example
316
+ * // Tenant notifications
317
+ * const notifications = useChannel(`tenant:${tenantId}:notifications`);
318
+ * useSocketCallback(`tenant:${tenantId}:notifications:alert`, showToast);
319
+ */
320
+ export function useChannel(name: string) {
321
+ const socket = useSharedWebSocket();
322
+ const channelRef = useRef(socket.channel(name));
323
+
324
+ useEffect(() => {
325
+ channelRef.current = socket.channel(name);
326
+ return () => channelRef.current.leave();
327
+ }, [socket, name]);
328
+
329
+ return channelRef.current;
330
+ }
@@ -9,7 +9,7 @@ import {
9
9
  type App,
10
10
  } from 'vue';
11
11
  import { SharedWebSocket } from '../SharedWebSocket';
12
- import type { SharedWebSocketOptions, TabRole } from '../types';
12
+ import type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';
13
13
 
14
14
  // ─── Plugin ──────────────────────────────────────────────
15
15
 
@@ -78,13 +78,15 @@ export function useSocketEvent<T>(event: string, callback?: (data: T) => void):
78
78
  const socket = useSharedWebSocket();
79
79
  const value = ref<T | undefined>(undefined) as Ref<T | undefined>;
80
80
 
81
- const unsub = socket.on(event, (data: T) => {
81
+ const handler = (data: unknown) => {
82
+ const typed = data as T;
82
83
  if (callback) {
83
- callback(data);
84
+ callback(typed);
84
85
  } else {
85
- value.value = data;
86
+ value.value = typed;
86
87
  }
87
- });
88
+ };
89
+ const unsub = socket.on(event, handler);
88
90
 
89
91
  onUnmounted(unsub);
90
92
  return readonly(value) as Ref<T | undefined>;
@@ -117,13 +119,15 @@ export function useSocketStream<T>(event: string, callback?: (data: T) => void):
117
119
  const socket = useSharedWebSocket();
118
120
  const items = ref<T[]>([]) as Ref<T[]>;
119
121
 
120
- const unsub = socket.on(event, (data: T) => {
122
+ const handler = (data: unknown) => {
123
+ const typed = data as T;
121
124
  if (callback) {
122
- callback(data);
125
+ callback(typed);
123
126
  } else {
124
- items.value = [...items.value, data];
127
+ items.value = [...items.value, typed];
125
128
  }
126
- });
129
+ };
130
+ const unsub = socket.on(event, handler);
127
131
 
128
132
  onUnmounted(unsub);
129
133
  return readonly(items) as Ref<T[]>;
@@ -178,8 +182,8 @@ export function useSocketSync<T>(key: string, initialValue: T, callback?: (value
178
182
  export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
179
183
  const socket = useSharedWebSocket();
180
184
 
181
- const unsub = socket.on(event, (data: T) => {
182
- callback(data);
185
+ const unsub = socket.on(event, (data: unknown) => {
186
+ callback(data as T);
183
187
  });
184
188
 
185
189
  onUnmounted(unsub);
@@ -211,3 +215,45 @@ export function useSocketStatus(): {
211
215
  tabRole: readonly(tabRole) as Ref<TabRole>,
212
216
  };
213
217
  }
218
+
219
+ /**
220
+ * Lifecycle hooks — react to connection state changes.
221
+ *
222
+ * @example
223
+ * useSocketLifecycle({
224
+ * onConnect: () => console.log('Connected!'),
225
+ * onDisconnect: () => showOfflineBanner(),
226
+ * onReconnecting: () => showSpinner(),
227
+ * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),
228
+ * onError: (err) => reportError(err),
229
+ * });
230
+ */
231
+ export function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {
232
+ const socket = useSharedWebSocket();
233
+ const unsubs: (() => void)[] = [];
234
+
235
+ if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));
236
+ if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));
237
+ if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));
238
+ if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));
239
+ if (handlers.onError) unsubs.push(socket.onError(handlers.onError));
240
+
241
+ onUnmounted(() => unsubs.forEach((u) => u()));
242
+ }
243
+
244
+ /**
245
+ * Subscribe to a private channel. Auto-joins on mount, leaves on unmount.
246
+ *
247
+ * @example
248
+ * const chat = useChannel('chat:room_123');
249
+ * // Listen via useSocketEvent('chat:room_123:message')
250
+ * // Send via chat.send('message', { text: 'Hello' })
251
+ */
252
+ export function useChannel(name: string) {
253
+ const socket = useSharedWebSocket();
254
+ const channel = socket.channel(name);
255
+
256
+ onUnmounted(() => channel.leave());
257
+
258
+ return channel;
259
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,27 @@
1
+ // Core
1
2
  export { SharedWebSocket } from './SharedWebSocket';
2
- export { withSocket, type WithSocketCallback, type WithSocketOptions, type SocketScope } from './withSocket';
3
+ export { withSocket } from './withSocket';
4
+
5
+ // Internal components (for advanced usage)
3
6
  export { MessageBus } from './MessageBus';
4
7
  export { TabCoordinator } from './TabCoordinator';
5
8
  export { SharedSocket } from './SharedSocket';
6
9
  export { WorkerSocket } from './WorkerSocket';
7
10
  export { SubscriptionManager } from './SubscriptionManager';
8
- export type { SharedWebSocketOptions, SocketState, TabRole, Unsubscribe, EventHandler } from './types';
11
+
12
+ // Types
13
+ export type { WithSocketCallback, WithSocketOptions, SocketScope } from './withSocket';
14
+ export type {
15
+ SharedWebSocketOptions,
16
+ SocketState,
17
+ TabRole,
18
+ Unsubscribe,
19
+ EventHandler,
20
+ Channel,
21
+ EventProtocol,
22
+ BusMessage,
23
+ SocketLifecycleHandlers,
24
+ EventMap,
25
+ Logger,
26
+ Middleware,
27
+ } from './types';