@gwakko/shared-websocket 0.2.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 } 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 unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n value.value = data;\n }\n });\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 unsub = socket.on(event, (data: T) => {\n if (callback) {\n callback(data);\n } else {\n items.value = [...items.value, data];\n }\n });\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: T) => {\n callback(data);\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"],"mappings":";;;;;AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAIK;AAMA,IAAM,qBAAoD,uBAAO,iBAAiB;AAYlF,SAAS,4BAA4B,KAAa,SAAkC;AACzF,SAAO;AAAA,IACL,QAAQ,KAAU;AAChB,YAAM,SAAS,IAAI,gBAAgB,KAAK,OAAO;AAC/C,aAAO,QAAQ;AACf,UAAI,QAAQ,oBAAoB,MAAM;AAEtC,YAAM,kBAAkB,IAAI,QAAQ,KAAK,GAAG;AAC5C,UAAI,UAAU,MAAM;AAClB,eAAO,OAAO,OAAO,EAAE;AACvB,wBAAgB;AAAA,MAClB;AAAA,IACF;AAAA,EACF;AACF;AASO,SAAS,qBAAsC;AACpD,QAAM,SAAS,OAAO,kBAAkB;AACxC,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,MAAM,+EAA+E;AAAA,EACjG;AACA,SAAO;AACT;AAoBO,SAAS,eAAkB,OAAe,UAAkD;AACjG,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAmB,MAAS;AAE1C,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,YAAM,QAAQ;AAAA,IAChB;AAAA,EACF,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AAyBO,SAAS,gBAAmB,OAAe,UAAwC;AACxF,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAS,CAAC,CAAC;AAEzB,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,YAAM,QAAQ,CAAC,GAAG,MAAM,OAAO,IAAI;AAAA,IACrC;AAAA,EACF,CAAC;AAED,cAAY,KAAK;AACjB,SAAO,SAAS,KAAK;AACvB;AAmBO,SAAS,cAAiB,KAAa,cAAiB,UAAuC;AACpG,QAAM,SAAS,mBAAmB;AAClC,QAAM,QAAQ,IAAO,OAAO,QAAW,GAAG,KAAK,YAAY;AAE3D,QAAM,QAAQ,OAAO,OAAU,KAAK,CAAC,MAAM;AACzC,UAAM,QAAQ;AACd,eAAW,CAAC;AAAA,EACd,CAAC;AAED;AAAA,IACE;AAAA,IACA,CAAC,WAAW;AACV,aAAO,KAAK,KAAK,MAAM;AAAA,IACzB;AAAA,IACA,EAAE,MAAM,KAAK;AAAA,EACf;AAEA,cAAY,KAAK;AACjB,SAAO;AACT;AAUO,SAAS,kBAAqB,OAAe,UAAmC;AACrF,QAAM,SAAS,mBAAmB;AAElC,QAAM,QAAQ,OAAO,GAAG,OAAO,CAAC,SAAY;AAC1C,aAAS,IAAI;AAAA,EACf,CAAC;AAED,cAAY,KAAK;AACnB;AAQO,SAAS,kBAGd;AACA,QAAM,SAAS,mBAAmB;AAClC,QAAM,YAAY,IAAI,OAAO,SAAS;AACtC,QAAM,UAAU,IAAa,OAAO,OAAO;AAE3C,QAAM,QAAQ,YAAY,MAAM;AAC9B,cAAU,QAAQ,OAAO;AACzB,YAAQ,QAAQ,OAAO;AAAA,EACzB,GAAG,GAAI;AAEP,cAAY,MAAM,cAAc,KAAK,CAAC;AAEtC,SAAO;AAAA,IACL,WAAW,SAAS,SAAS;AAAA,IAC7B,SAAS,SAAS,OAAO;AAAA,EAC3B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/adapters/vue.ts"],"sourcesContent":["import {\n ref,\n onUnmounted,\n inject,\n readonly,\n watch,\n type Ref,\n type InjectionKey,\n type App,\n} from 'vue';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole, SocketLifecycleHandlers } from '../types';\n\n// ─── Plugin ──────────────────────────────────────────────\n\nexport const SharedWebSocketKey: InjectionKey<SharedWebSocket> = Symbol('SharedWebSocket');\n\n/**\n * Vue 3 plugin for SharedWebSocket.\n *\n * @example\n * const app = createApp(App);\n * app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }));\n */\nexport function createSharedWebSocketPlugin(url: string, options?: SharedWebSocketOptions) {\n return {\n install(app: App) {\n const socket = new SharedWebSocket(url, options);\n socket.connect();\n app.provide(SharedWebSocketKey, socket);\n\n const originalUnmount = app.unmount.bind(app);\n app.unmount = () => {\n socket[Symbol.dispose]();\n originalUnmount();\n };\n },\n };\n}\n\n/**\n * Access the SharedWebSocket instance from provided context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const socket = inject(SharedWebSocketKey);\n if (!socket) {\n throw new Error('useSharedWebSocket: SharedWebSocket not provided. Did you install the plugin?');\n }\n return socket;\n}\n\n// ─── Composables ─────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns reactive ref with latest value.\n * - With callback: calls your handler on each event.\n *\n * @example\n * // Reactive state\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): Ref<T | undefined> {\n const socket = useSharedWebSocket();\n const value = ref<T | undefined>(undefined) as Ref<T | undefined>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n value.value = typed;\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(value) as Ref<T | undefined>;\n}\n\n/**\n * Accumulate WebSocket events.\n * - Without callback: returns reactive array.\n * - With callback: calls your handler — manage your own state.\n *\n * @example\n * // Default accumulation\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom — keep last 50\n * const messages = ref<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * messages.value = [msg, ...messages.value].slice(0, 50);\n * });\n *\n * @example\n * // Custom — filter by type\n * const errors = ref<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') errors.value = [...errors.value, entry];\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): Ref<T[]> {\n const socket = useSharedWebSocket();\n const items = ref<T[]>([]) as Ref<T[]>;\n\n const handler = (data: unknown) => {\n const typed = data as T;\n if (callback) {\n callback(typed);\n } else {\n items.value = [...items.value, typed];\n }\n };\n const unsub = socket.on(event, handler);\n\n onUnmounted(unsub);\n return readonly(items) as Ref<T[]>;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: reactive ref synced across tabs.\n * - With callback: called when any tab updates this key — side effects.\n *\n * @example\n * // Reactive two-way sync\n * const cart = useSocketSync<Cart>('cart', { items: [] });\n * cart.value = { items: [1, 2, 3] }; // syncs to all tabs\n *\n * @example\n * // With side effect callback\n * const cart = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated');\n * });\n */\nexport function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): Ref<T> {\n const socket = useSharedWebSocket();\n const value = ref<T>(socket.getSync<T>(key) ?? initialValue) as Ref<T>;\n\n const unsub = socket.onSync<T>(key, (v) => {\n value.value = v;\n callback?.(v);\n });\n\n watch(\n value,\n (newVal) => {\n socket.sync(key, newVal);\n },\n { deep: true },\n );\n\n onUnmounted(unsub);\n return value;\n}\n\n/**\n * Fire-and-forget event handler — no state, no ref.\n *\n * @example\n * useSocketCallback<Notification>('notification', (n) => {\n * showToast(n.title);\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const unsub = socket.on(event, (data: unknown) => {\n callback(data as T);\n });\n\n onUnmounted(unsub);\n}\n\n/**\n * Reactive connection status.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: Ref<boolean>;\n tabRole: Ref<TabRole>;\n} {\n const socket = useSharedWebSocket();\n const connected = ref(socket.connected);\n const tabRole = ref<TabRole>(socket.tabRole);\n\n const timer = setInterval(() => {\n connected.value = socket.connected;\n tabRole.value = socket.tabRole;\n }, 1000);\n\n onUnmounted(() => clearInterval(timer));\n\n return {\n connected: readonly(connected) as Ref<boolean>,\n tabRole: readonly(tabRole) as Ref<TabRole>,\n };\n}\n\n/**\n * Lifecycle hooks — react to connection state changes.\n *\n * @example\n * useSocketLifecycle({\n * onConnect: () => console.log('Connected!'),\n * onDisconnect: () => showOfflineBanner(),\n * onReconnecting: () => showSpinner(),\n * onLeaderChange: (isLeader) => console.log('Leader:', isLeader),\n * onError: (err) => reportError(err),\n * });\n */\nexport function useSocketLifecycle(handlers: SocketLifecycleHandlers): void {\n const socket = useSharedWebSocket();\n const unsubs: (() => void)[] = [];\n\n if (handlers.onConnect) unsubs.push(socket.onConnect(handlers.onConnect));\n if (handlers.onDisconnect) unsubs.push(socket.onDisconnect(handlers.onDisconnect));\n if (handlers.onReconnecting) unsubs.push(socket.onReconnecting(handlers.onReconnecting));\n if (handlers.onLeaderChange) unsubs.push(socket.onLeaderChange(handlers.onLeaderChange));\n if (handlers.onError) unsubs.push(socket.onError(handlers.onError));\n\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"],"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.2.1",
3
+ "version": "0.6.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",
@@ -9,6 +9,10 @@ interface SharedSocketOptions {
9
9
  heartbeatInterval?: number;
10
10
  sendBuffer?: number;
11
11
  auth?: () => string | Promise<string>;
12
+ authToken?: string;
13
+ authParam?: string;
14
+ /** Heartbeat payload (default: { type: "ping" }). */
15
+ pingPayload?: unknown;
12
16
  }
13
17
 
14
18
  export class SharedSocket implements Disposable {
@@ -22,7 +26,12 @@ export class SharedSocket implements Disposable {
22
26
  private onMessageFns = new Set<EventHandler>();
23
27
  private onStateChangeFns = new Set<(state: SocketState) => void>();
24
28
 
25
- private readonly opts: Required<Omit<SharedSocketOptions, 'auth'>> & { auth?: () => string | Promise<string> };
29
+ private readonly opts: Required<Omit<SharedSocketOptions, 'auth' | 'authToken' | 'authParam' | 'pingPayload'>> & {
30
+ auth?: () => string | Promise<string>;
31
+ authToken?: string;
32
+ authParam: string;
33
+ pingPayload: unknown;
34
+ };
26
35
 
27
36
  constructor(
28
37
  private url: string,
@@ -35,6 +44,9 @@ export class SharedSocket implements Disposable {
35
44
  heartbeatInterval: options.heartbeatInterval ?? 30_000,
36
45
  sendBuffer: options.sendBuffer ?? 100,
37
46
  auth: options.auth,
47
+ authToken: options.authToken,
48
+ authParam: options.authParam ?? 'token',
49
+ pingPayload: options.pingPayload ?? { type: 'ping' },
38
50
  };
39
51
  }
40
52
 
@@ -47,13 +59,7 @@ export class SharedSocket implements Disposable {
47
59
 
48
60
  this.setState('connecting');
49
61
 
50
- let connectUrl = this.url;
51
- if (this.opts.auth) {
52
- const token = await this.opts.auth();
53
- const sep = connectUrl.includes('?') ? '&' : '?';
54
- connectUrl = `${connectUrl}${sep}token=${encodeURIComponent(token)}`;
55
- }
56
-
62
+ const connectUrl = await this.buildUrl();
57
63
  this.ws = new WebSocket(connectUrl, this.opts.protocols);
58
64
 
59
65
  this.ws.onopen = () => {
@@ -150,7 +156,7 @@ export class SharedSocket implements Disposable {
150
156
  this.stopHeartbeat();
151
157
  this.heartbeatTimer = setInterval(() => {
152
158
  if (this.ws?.readyState === WebSocket.OPEN) {
153
- this.ws.send(JSON.stringify({ type: 'ping' }));
159
+ this.ws.send(JSON.stringify(this.opts.pingPayload));
154
160
  }
155
161
  }, this.opts.heartbeatInterval);
156
162
  }
@@ -169,6 +175,26 @@ export class SharedSocket implements Disposable {
169
175
  }
170
176
  }
171
177
 
178
+ private async buildUrl(): Promise<string> {
179
+ // Resolve token: callback > static > none
180
+ let token: string | undefined;
181
+ if (this.opts.auth) {
182
+ token = await this.opts.auth();
183
+ } else if (this.opts.authToken) {
184
+ token = this.opts.authToken;
185
+ }
186
+
187
+ if (!token) return this.url;
188
+
189
+ // WebSocket URLs (ws://, wss://) are not fully supported by URL API.
190
+ // Convert to http(s) for parsing, then back to ws(s).
191
+ const httpUrl = this.url.replace(/^ws(s?):\/\//, 'http$1://');
192
+ const parsed = new URL(httpUrl);
193
+ parsed.searchParams.set(this.opts.authParam, token);
194
+
195
+ return parsed.toString().replace(/^http(s?):\/\//, 'ws$1://');
196
+ }
197
+
172
198
  private setState(state: SocketState): void {
173
199
  this._state = state;
174
200
  for (const fn of this.onStateChangeFns) fn(state);
@@ -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) {
@@ -167,27 +353,55 @@ export class SharedWebSocket implements Disposable {
167
353
  return new SharedSocket(this.url, {
168
354
  ...socketOptions,
169
355
  auth: this.options.auth,
356
+ authToken: this.options.authToken,
357
+ authParam: this.options.authParam,
170
358
  });
171
359
  }
172
360
 
173
- private onBecomeLeader(): void {
361
+ private handleBecomeLeader(): void {
362
+ this.log.info('[SharedWS] 👑 became leader');
174
363
  this.socket = this.createSocket();
175
364
 
176
- this.socket.onMessage((data: any) => {
177
- const event = data?.event ?? 'message';
178
- const payload = data?.data ?? data;
179
- // 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);
180
379
  this.bus.broadcast('ws:message', { event, data: payload });
181
380
  });
182
381
 
183
- // 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
+
184
397
  this.cleanups.push(
185
398
  this.bus.respond<{ event: string; data: unknown }, unknown>('ws:request', async (req) => {
186
399
  return new Promise((resolve) => {
187
- const unsub = this.socket!.onMessage((response: any) => {
188
- 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) {
189
403
  unsub();
190
- resolve(response?.data ?? response);
404
+ resolve(res?.[this.proto.dataField] ?? response);
191
405
  }
192
406
  });
193
407
  this.socket!.send({ event: req.event, data: req.data });
@@ -198,7 +412,7 @@ export class SharedWebSocket implements Disposable {
198
412
  this.socket.connect();
199
413
  }
200
414
 
201
- private onLoseLeadership(): void {
415
+ private handleLoseLeadership(): void {
202
416
  if (this.socket) {
203
417
  this.socket[Symbol.dispose]();
204
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
+ }