@gwakko/shared-websocket 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -308,17 +308,45 @@ Callback receives `{ ws, signal }` — destructure what you need. Signal aborts
308
308
  | `connected` | `boolean` | Connection status |
309
309
  | `tabRole` | `'leader' \| 'follower'` | Current tab's role |
310
310
 
311
- ### React Hooks (React 19, uses `useEffectEvent` for stable refs)
311
+ ### React Hooks (React 19, `useEffectEvent` for stable refs)
312
312
 
313
- | Hook | Returns | Description |
314
- |------|---------|-------------|
315
- | `useSharedWebSocket()` | `SharedWebSocket` | Access instance from context |
316
- | `useSocketEvent<T>(event)` | `T \| undefined` | Latest event value |
317
- | `useSocketStream<T>(event)` | `T[]` | Accumulated events |
318
- | `useSocketSync<T>(key, init)` | `[T, setter]` | Cross-tab synced state |
319
- | `useSocketStatus()` | `{ connected, tabRole }` | Connection status |
313
+ All hooks use context internally no need to pass `ws`. Every hook accepts an **optional callback** for custom handling.
320
314
 
321
- All hooks use context internally no need to pass `ws` as argument.
315
+ | Hook | Without callback | With callback |
316
+ |------|-----------------|---------------|
317
+ | `useSharedWebSocket()` | `SharedWebSocket` | — |
318
+ | `useSocketEvent<T>(event, cb?)` | Returns `T \| undefined` | `cb(data)` on each event |
319
+ | `useSocketStream<T>(event, cb?)` | Returns `T[]` (accumulated) | `cb(data)` — manage your own state |
320
+ | `useSocketSync<T>(key, init, cb?)` | Returns `[T, setter]` | `cb(value)` — side effects on sync |
321
+ | `useSocketCallback<T>(event, cb)` | — | Fire-and-forget (no state) |
322
+ | `useSocketStatus()` | `{ connected, tabRole }` | — |
323
+
324
+ ```tsx
325
+ // Without callback — reactive state
326
+ const order = useSocketEvent<Order>('order.created');
327
+
328
+ // With callback — custom logic, stable ref
329
+ useSocketEvent<Order>('order.created', (order) => {
330
+ playSound('new-order');
331
+ analytics.track('order_received', order);
332
+ });
333
+
334
+ // Stream with limit
335
+ const [msgs, setMsgs] = useState<Message[]>([]);
336
+ useSocketStream<Message>('chat.message', (msg) => {
337
+ setMsgs(prev => [msg, ...prev].slice(0, 50));
338
+ });
339
+
340
+ // Sync with side effect
341
+ const [cart, setCart] = useSocketSync('cart', { items: [] }, (cart) => {
342
+ document.title = `Cart (${cart.items.length})`;
343
+ });
344
+
345
+ // Fire-and-forget
346
+ useSocketCallback<Notification>('notification', (n) => {
347
+ if (ws.tabRole === 'leader') new Notification(n.title);
348
+ });
349
+ ```
322
350
 
323
351
  ### Vue Composables
324
352
 
@@ -42,30 +42,90 @@ export declare function SharedWebSocketProvider({ url, options, children }: Shar
42
42
  */
43
43
  export declare function useSharedWebSocket(): SharedWebSocket;
44
44
  /**
45
- * Subscribe to a WebSocket event. Returns the latest received value.
46
- * Uses useEffectEvent for a stable callback ref no stale closures.
45
+ * Subscribe to a WebSocket event.
46
+ * - Without callback: returns the latest received value (reactive state).
47
+ * - With callback: calls your handler on each event (stable ref via useEffectEvent).
47
48
  *
48
49
  * @example
50
+ * // Reactive state — returns latest value
49
51
  * const order = useSocketEvent<Order>('order.created');
52
+ *
53
+ * @example
54
+ * // Custom callback — full control, no state
55
+ * useSocketEvent<Order>('order.created', (order) => {
56
+ * playSound('new-order');
57
+ * analytics.track('order_received', order);
58
+ * });
59
+ *
60
+ * @example
61
+ * // Custom callback with transform — store in your own state
62
+ * const [orders, setOrders] = useState<Order[]>([]);
63
+ * useSocketEvent<Order>('order.created', (order) => {
64
+ * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50
65
+ * });
50
66
  */
51
- export declare function useSocketEvent<T>(event: string): T | undefined;
67
+ export declare function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined;
52
68
  /**
53
69
  * Accumulate WebSocket events into an array.
54
- * Uses useEffectEvent handler always sees latest state without re-subscribing.
70
+ * - Without callback: returns accumulated array (reactive state).
71
+ * - With callback: calls your handler on each event, you manage your own state.
55
72
  *
56
73
  * @example
74
+ * // Default — accumulates all events
57
75
  * const messages = useSocketStream<ChatMessage>('chat.message');
76
+ *
77
+ * @example
78
+ * // Custom callback — keep only last 50, transform, filter, etc.
79
+ * const [messages, setMessages] = useState<ChatMessage[]>([]);
80
+ * useSocketStream<ChatMessage>('chat.message', (msg) => {
81
+ * setMessages(prev => [msg, ...prev].slice(0, 50));
82
+ * });
83
+ *
84
+ * @example
85
+ * // Custom callback — filter by type
86
+ * const [errors, setErrors] = useState<LogEntry[]>([]);
87
+ * useSocketStream<LogEntry>('log.entry', (entry) => {
88
+ * if (entry.level === 'error') setErrors(prev => [...prev, entry]);
89
+ * });
58
90
  */
59
- export declare function useSocketStream<T>(event: string): T[];
91
+ export declare function useSocketStream<T>(event: string, callback?: (data: T) => void): T[];
60
92
  /**
61
93
  * Two-way state sync across browser tabs.
62
- * Uses useEffectEvent for stable sync callback.
94
+ * - Without callback: returns [value, setter] (like useState but synced).
95
+ * - With callback: calls your handler when any tab updates this key.
63
96
  *
64
97
  * @example
98
+ * // Default — reactive synced state
65
99
  * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });
66
- * // setCart in one tab → updates all tabs instantly
100
+ *
101
+ * @example
102
+ * // Custom callback — side effects on sync
103
+ * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] }, (cart) => {
104
+ * document.title = `Cart (${cart.items.length})`;
105
+ * analytics.track('cart_updated', { count: cart.items.length });
106
+ * });
107
+ */
108
+ export declare function useSocketSync<T>(key: string, initialValue: T, callback?: (value: T) => void): [T, (value: T) => void];
109
+ /**
110
+ * Subscribe to a WebSocket event with just a callback — no state, no return value.
111
+ * Fire-and-forget: side effects, logging, analytics, sounds, browser notifications.
112
+ * Stable ref via useEffectEvent — callback always sees latest closure values.
113
+ *
114
+ * @example
115
+ * useSocketCallback<Order>('order.created', (order) => {
116
+ * playSound('new-order');
117
+ * analytics.track('order_received', { id: order.id });
118
+ * });
119
+ *
120
+ * @example
121
+ * // Browser notification only from leader tab
122
+ * useSocketCallback<Notification>('notification', (notif) => {
123
+ * if (ws.tabRole === 'leader' && document.hidden) {
124
+ * new Notification(notif.title, { body: notif.body });
125
+ * }
126
+ * });
67
127
  */
68
- export declare function useSocketSync<T>(key: string, initialValue: T): [T, (value: T) => void];
128
+ export declare function useSocketCallback<T>(event: string, callback: (data: T) => void): void;
69
129
  /**
70
130
  * Reactive connection status.
71
131
  * Uses useEffectEvent to avoid re-creating interval on state change.
package/dist/react.cjs CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
2
2
 
3
3
  var _chunkSMH3X34Ncjs = require('./chunk-SMH3X34N.cjs');
4
4
 
@@ -32,38 +32,47 @@ function useSharedWebSocket() {
32
32
  }
33
33
  return ctx;
34
34
  }
35
- function useSocketEvent(event) {
35
+ function useSocketEvent(event, callback) {
36
36
  const socket = useSharedWebSocket();
37
37
  const [value, setValue] = _react.useState.call(void 0, void 0);
38
38
  const onEvent = _react.useEffectEvent.call(void 0, (data) => {
39
- setValue(data);
39
+ if (callback) {
40
+ callback(data);
41
+ } else {
42
+ setValue(data);
43
+ }
40
44
  });
41
45
  _react.useEffect.call(void 0, () => {
42
46
  const unsub = socket.on(event, onEvent);
43
47
  return unsub;
44
48
  }, [socket, event]);
45
- return value;
49
+ return callback ? void 0 : value;
46
50
  }
47
- function useSocketStream(event) {
51
+ function useSocketStream(event, callback) {
48
52
  const socket = useSharedWebSocket();
49
53
  const [items, setItems] = _react.useState.call(void 0, []);
50
54
  const onEvent = _react.useEffectEvent.call(void 0, (data) => {
51
- setItems((prev) => [...prev, data]);
55
+ if (callback) {
56
+ callback(data);
57
+ } else {
58
+ setItems((prev) => [...prev, data]);
59
+ }
52
60
  });
53
61
  _react.useEffect.call(void 0, () => {
54
- setItems([]);
62
+ if (!callback) setItems([]);
55
63
  const unsub = socket.on(event, onEvent);
56
64
  return unsub;
57
65
  }, [socket, event]);
58
- return items;
66
+ return callback ? [] : items;
59
67
  }
60
- function useSocketSync(key, initialValue) {
68
+ function useSocketSync(key, initialValue, callback) {
61
69
  const socket = useSharedWebSocket();
62
70
  const [value, setValue] = _react.useState.call(void 0, () => {
63
71
  return _nullishCoalesce(socket.getSync(key), () => ( initialValue));
64
72
  });
65
73
  const onSync = _react.useEffectEvent.call(void 0, (synced) => {
66
74
  setValue(synced);
75
+ _optionalChain([callback, 'optionalCall', _ => _(synced)]);
67
76
  });
68
77
  _react.useEffect.call(void 0, () => {
69
78
  const unsub = socket.onSync(key, onSync);
@@ -75,6 +84,16 @@ function useSocketSync(key, initialValue) {
75
84
  });
76
85
  return [value, setAndSync];
77
86
  }
87
+ function useSocketCallback(event, callback) {
88
+ const socket = useSharedWebSocket();
89
+ const handler = _react.useEffectEvent.call(void 0, (data) => {
90
+ callback(data);
91
+ });
92
+ _react.useEffect.call(void 0, () => {
93
+ const unsub = socket.on(event, handler);
94
+ return unsub;
95
+ }, [socket, event]);
96
+ }
78
97
  function useSocketStatus() {
79
98
  const socket = useSharedWebSocket();
80
99
  const [connected, setConnected] = _react.useState.call(void 0, socket.connected);
@@ -96,5 +115,6 @@ function useSocketStatus() {
96
115
 
97
116
 
98
117
 
99
- exports.SharedWebSocketProvider = SharedWebSocketProvider; exports.useSharedWebSocket = useSharedWebSocket; exports.useSocketEvent = useSocketEvent; exports.useSocketStatus = useSocketStatus; exports.useSocketStream = useSocketStream; exports.useSocketSync = useSocketSync;
118
+
119
+ exports.SharedWebSocketProvider = SharedWebSocketProvider; exports.useSharedWebSocket = useSharedWebSocket; exports.useSocketCallback = useSocketCallback; exports.useSocketEvent = useSocketEvent; exports.useSocketStatus = useSocketStatus; exports.useSocketStream = useSocketStream; exports.useSocketSync = useSocketSync;
100
120
  //# sourceMappingURL=react.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/react.cjs","../src/adapters/react.ts"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B;AACA;ACJA;AACE;AACA;AACA;AACA;AACA;AAEA;AAAA,8BACK;AAMP,IAAM,gBAAA,EAAkB,kCAAA,IAA0C,CAAA;AAkC3D,SAAS,uBAAA,CAAwB,EAAE,GAAA,EAAK,OAAA,EAAS,SAAS,CAAA,EAAiC;AAChG,EAAA,MAAM,CAAC,MAAM,EAAA,EAAI,6BAAA,CAAS,EAAA,GAAM;AAC9B,IAAA,MAAM,GAAA,EAAK,IAAI,sCAAA,CAAgB,GAAA,EAAK,OAAO,CAAA;AAC3C,IAAA,EAAA,CAAG,OAAA,CAAQ,CAAA;AACX,IAAA,OAAO,EAAA;AAAA,EACT,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,OAAO,CAAA,EAAA,GAAM;AACX,MAAA,MAAA,CAAO,MAAA,CAAO,OAAO,CAAA,CAAE,CAAA;AAAA,IACzB,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,kCAAA,eAAc,CAAgB,QAAA,EAAU,EAAE,KAAA,EAAO,OAAO,CAAA,EAAG,QAAQ,CAAA;AAC5E;AASO,SAAS,kBAAA,CAAA,EAAsC;AACpD,EAAA,MAAM,IAAA,EAAM,+BAAA,eAA0B,CAAA;AACtC,EAAA,GAAA,CAAI,CAAC,GAAA,EAAK;AACR,IAAA,MAAM,IAAI,KAAA,CAAM,kEAAkE,CAAA;AAAA,EACpF;AACA,EAAA,OAAO,GAAA;AACT;AAWO,SAAS,cAAA,CAAkB,KAAA,EAA8B;AAC9D,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,KAAwB,CAAS,CAAA;AAE3D,EAAA,MAAM,QAAA,EAAU,mCAAA,CAAgB,IAAA,EAAA,GAAY;AAC1C,IAAA,QAAA,CAAS,IAAI,CAAA;AAAA,EACf,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,OAAO,CAAA;AACtC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,KAAK,CAAC,CAAA;AAElB,EAAA,OAAO,KAAA;AACT;AASO,SAAS,eAAA,CAAmB,KAAA,EAAoB;AACrD,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,CAAe,CAAC,CAAA;AAE1C,EAAA,MAAM,QAAA,EAAU,mCAAA,CAAgB,IAAA,EAAA,GAAY;AAC1C,IAAA,QAAA,CAAS,CAAC,IAAA,EAAA,GAAS,CAAC,GAAG,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,EACpC,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,QAAA,CAAS,CAAC,CAAC,CAAA;AACX,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,OAAO,CAAA;AACtC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,KAAK,CAAC,CAAA;AAElB,EAAA,OAAO,KAAA;AACT;AAUO,SAAS,aAAA,CACd,GAAA,EACA,YAAA,EACyB;AACzB,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,CAAY,EAAA,GAAM;AAC1C,IAAA,wBAAO,MAAA,CAAO,OAAA,CAAW,GAAG,CAAA,UAAK,cAAA;AAAA,EACnC,CAAC,CAAA;AAED,EAAA,MAAM,OAAA,EAAS,mCAAA,CAAgB,MAAA,EAAA,GAAc;AAC3C,IAAA,QAAA,CAAS,MAAM,CAAA;AAAA,EACjB,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,MAAA,CAAU,GAAA,EAAK,MAAM,CAAA;AAC1C,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,GAAG,CAAC,CAAA;AAEhB,EAAA,MAAM,WAAA,EAAa,mCAAA,CAAgB,QAAA,EAAA,GAAgB;AACjD,IAAA,QAAA,CAAS,QAAQ,CAAA;AACjB,IAAA,MAAA,CAAO,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAAA,EAC3B,CAAC,CAAA;AAED,EAAA,OAAO,CAAC,KAAA,EAAO,UAAU,CAAA;AAC3B;AASO,SAAS,eAAA,CAAA,EAGd;AACA,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,EAAA,EAAI,6BAAA,MAAS,CAAO,SAAS,CAAA;AAC3D,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,EAAA,EAAI,6BAAA,MAAkB,CAAO,OAAO,CAAA;AAE9D,EAAA,MAAM,KAAA,EAAO,mCAAA,CAAe,EAAA,GAAM;AAChC,IAAA,YAAA,CAAa,MAAA,CAAO,SAAS,CAAA;AAC7B,IAAA,UAAA,CAAW,MAAA,CAAO,OAAO,CAAA;AAAA,EAC3B,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,SAAA,EAAW,WAAA,CAAY,IAAA,EAAM,GAAI,CAAA;AACvC,IAAA,OAAO,CAAA,EAAA,GAAM,aAAA,CAAc,QAAQ,CAAA;AAAA,EACrC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,SAAA,EAAW,QAAQ,CAAA;AAC9B;ADjGA;AACE;AACA;AACA;AACA;AACA;AACA;AACF,iRAAC","file":"/Users/gwakko/Projects/shared-websocket/dist/react.cjs","sourcesContent":[null,"import {\n createContext,\n useContext,\n useEffect,\n useState,\n useEffectEvent,\n type ReactNode,\n createElement,\n} from 'react';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Context ─────────────────────────────────────────────\n\nconst SharedWSContext = createContext<SharedWebSocket | null>(null);\n\n/**\n * Provider props — pass URL and options as props for flexibility.\n *\n * @example\n * <SharedWebSocketProvider url=\"wss://api.example.com/ws\" options={{ auth: getToken }}>\n * <App />\n * </SharedWebSocketProvider>\n */\nexport interface SharedWebSocketProviderProps {\n url: string;\n options?: SharedWebSocketOptions;\n children: ReactNode;\n}\n\n/**\n * Provider component — creates SharedWebSocket from props, auto-disposes on unmount.\n *\n * @example\n * function App() {\n * return (\n * <SharedWebSocketProvider\n * url=\"wss://api.example.com/ws\"\n * options={{\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }}\n * >\n * <Dashboard />\n * </SharedWebSocketProvider>\n * );\n * }\n */\nexport function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {\n const [socket] = useState(() => {\n const ws = new SharedWebSocket(url, options);\n ws.connect();\n return ws;\n });\n\n useEffect(() => {\n return () => {\n socket[Symbol.dispose]();\n };\n }, [socket]);\n\n return createElement(SharedWSContext.Provider, { value: socket }, children);\n}\n\n/**\n * Access the SharedWebSocket instance from context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const ctx = useContext(SharedWSContext);\n if (!ctx) {\n throw new Error('useSharedWebSocket must be used within <SharedWebSocketProvider>');\n }\n return ctx;\n}\n\n// ─── Hooks ───────────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event. Returns the latest received value.\n * Uses useEffectEvent for a stable callback ref — no stale closures.\n *\n * @example\n * const order = useSocketEvent<Order>('order.created');\n */\nexport function useSocketEvent<T>(event: string): T | undefined {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T | undefined>(undefined);\n\n const onEvent = useEffectEvent((data: T) => {\n setValue(data);\n });\n\n useEffect(() => {\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return value;\n}\n\n/**\n * Accumulate WebSocket events into an array.\n * Uses useEffectEvent — handler always sees latest state without re-subscribing.\n *\n * @example\n * const messages = useSocketStream<ChatMessage>('chat.message');\n */\nexport function useSocketStream<T>(event: string): T[] {\n const socket = useSharedWebSocket();\n const [items, setItems] = useState<T[]>([]);\n\n const onEvent = useEffectEvent((data: T) => {\n setItems((prev) => [...prev, data]);\n });\n\n useEffect(() => {\n setItems([]);\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return items;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * Uses useEffectEvent for stable sync callback.\n *\n * @example\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });\n * // setCart in one tab → updates all tabs instantly\n */\nexport function useSocketSync<T>(\n key: string,\n initialValue: T,\n): [T, (value: T) => void] {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T>(() => {\n return socket.getSync<T>(key) ?? initialValue;\n });\n\n const onSync = useEffectEvent((synced: T) => {\n setValue(synced);\n });\n\n useEffect(() => {\n const unsub = socket.onSync<T>(key, onSync);\n return unsub;\n }, [socket, key]);\n\n const setAndSync = useEffectEvent((newValue: T) => {\n setValue(newValue);\n socket.sync(key, newValue);\n });\n\n return [value, setAndSync];\n}\n\n/**\n * Reactive connection status.\n * Uses useEffectEvent to avoid re-creating interval on state change.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: boolean;\n tabRole: TabRole;\n} {\n const socket = useSharedWebSocket();\n const [connected, setConnected] = useState(socket.connected);\n const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);\n\n const tick = useEffectEvent(() => {\n setConnected(socket.connected);\n setTabRole(socket.tabRole);\n });\n\n useEffect(() => {\n const interval = setInterval(tick, 1000);\n return () => clearInterval(interval);\n }, [socket]);\n\n return { connected, tabRole };\n}\n"]}
1
+ {"version":3,"sources":["/Users/gwakko/Projects/shared-websocket/dist/react.cjs","../src/adapters/react.ts"],"names":[],"mappings":"AAAA;AACE;AACF,wDAA6B;AAC7B;AACA;ACJA;AACE;AACA;AACA;AACA;AACA;AAEA;AAAA,8BACK;AAMP,IAAM,gBAAA,EAAkB,kCAAA,IAA0C,CAAA;AAkC3D,SAAS,uBAAA,CAAwB,EAAE,GAAA,EAAK,OAAA,EAAS,SAAS,CAAA,EAAiC;AAChG,EAAA,MAAM,CAAC,MAAM,EAAA,EAAI,6BAAA,CAAS,EAAA,GAAM;AAC9B,IAAA,MAAM,GAAA,EAAK,IAAI,sCAAA,CAAgB,GAAA,EAAK,OAAO,CAAA;AAC3C,IAAA,EAAA,CAAG,OAAA,CAAQ,CAAA;AACX,IAAA,OAAO,EAAA;AAAA,EACT,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,OAAO,CAAA,EAAA,GAAM;AACX,MAAA,MAAA,CAAO,MAAA,CAAO,OAAO,CAAA,CAAE,CAAA;AAAA,IACzB,CAAA;AAAA,EACF,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,kCAAA,eAAc,CAAgB,QAAA,EAAU,EAAE,KAAA,EAAO,OAAO,CAAA,EAAG,QAAQ,CAAA;AAC5E;AASO,SAAS,kBAAA,CAAA,EAAsC;AACpD,EAAA,MAAM,IAAA,EAAM,+BAAA,eAA0B,CAAA;AACtC,EAAA,GAAA,CAAI,CAAC,GAAA,EAAK;AACR,IAAA,MAAM,IAAI,KAAA,CAAM,kEAAkE,CAAA;AAAA,EACpF;AACA,EAAA,OAAO,GAAA;AACT;AA2BO,SAAS,cAAA,CAAkB,KAAA,EAAe,QAAA,EAA6C;AAC5F,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,KAAwB,CAAS,CAAA;AAE3D,EAAA,MAAM,QAAA,EAAU,mCAAA,CAAgB,IAAA,EAAA,GAAY;AAC1C,IAAA,GAAA,CAAI,QAAA,EAAU;AACZ,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf,EAAA,KAAO;AACL,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf;AAAA,EACF,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,OAAO,CAAA;AACtC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,KAAK,CAAC,CAAA;AAElB,EAAA,OAAO,SAAA,EAAW,KAAA,EAAA,EAAY,KAAA;AAChC;AAyBO,SAAS,eAAA,CAAmB,KAAA,EAAe,QAAA,EAAmC;AACnF,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,CAAe,CAAC,CAAA;AAE1C,EAAA,MAAM,QAAA,EAAU,mCAAA,CAAgB,IAAA,EAAA,GAAY;AAC1C,IAAA,GAAA,CAAI,QAAA,EAAU;AACZ,MAAA,QAAA,CAAS,IAAI,CAAA;AAAA,IACf,EAAA,KAAO;AACL,MAAA,QAAA,CAAS,CAAC,IAAA,EAAA,GAAS,CAAC,GAAG,IAAA,EAAM,IAAI,CAAC,CAAA;AAAA,IACpC;AAAA,EACF,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,GAAA,CAAI,CAAC,QAAA,EAAU,QAAA,CAAS,CAAC,CAAC,CAAA;AAC1B,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,OAAO,CAAA;AACtC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,KAAK,CAAC,CAAA;AAElB,EAAA,OAAO,SAAA,EAAW,CAAC,EAAA,EAAI,KAAA;AACzB;AAkBO,SAAS,aAAA,CACd,GAAA,EACA,YAAA,EACA,QAAA,EACyB;AACzB,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,KAAA,EAAO,QAAQ,EAAA,EAAI,6BAAA,CAAY,EAAA,GAAM;AAC1C,IAAA,wBAAO,MAAA,CAAO,OAAA,CAAW,GAAG,CAAA,UAAK,cAAA;AAAA,EACnC,CAAC,CAAA;AAED,EAAA,MAAM,OAAA,EAAS,mCAAA,CAAgB,MAAA,EAAA,GAAc;AAC3C,IAAA,QAAA,CAAS,MAAM,CAAA;AACf,oBAAA,QAAA,wBAAA,CAAW,MAAM,GAAA;AAAA,EACnB,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,MAAA,CAAU,GAAA,EAAK,MAAM,CAAA;AAC1C,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,GAAG,CAAC,CAAA;AAEhB,EAAA,MAAM,WAAA,EAAa,mCAAA,CAAgB,QAAA,EAAA,GAAgB;AACjD,IAAA,QAAA,CAAS,QAAQ,CAAA;AACjB,IAAA,MAAA,CAAO,IAAA,CAAK,GAAA,EAAK,QAAQ,CAAA;AAAA,EAC3B,CAAC,CAAA;AAED,EAAA,OAAO,CAAC,KAAA,EAAO,UAAU,CAAA;AAC3B;AAqBO,SAAS,iBAAA,CAAqB,KAAA,EAAe,QAAA,EAAmC;AACrF,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAElC,EAAA,MAAM,QAAA,EAAU,mCAAA,CAAgB,IAAA,EAAA,GAAY;AAC1C,IAAA,QAAA,CAAS,IAAI,CAAA;AAAA,EACf,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,MAAA,EAAQ,MAAA,CAAO,EAAA,CAAG,KAAA,EAAO,OAAO,CAAA;AACtC,IAAA,OAAO,KAAA;AAAA,EACT,CAAA,EAAG,CAAC,MAAA,EAAQ,KAAK,CAAC,CAAA;AACpB;AASO,SAAS,eAAA,CAAA,EAGd;AACA,EAAA,MAAM,OAAA,EAAS,kBAAA,CAAmB,CAAA;AAClC,EAAA,MAAM,CAAC,SAAA,EAAW,YAAY,EAAA,EAAI,6BAAA,MAAS,CAAO,SAAS,CAAA;AAC3D,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,EAAA,EAAI,6BAAA,MAAkB,CAAO,OAAO,CAAA;AAE9D,EAAA,MAAM,KAAA,EAAO,mCAAA,CAAe,EAAA,GAAM;AAChC,IAAA,YAAA,CAAa,MAAA,CAAO,SAAS,CAAA;AAC7B,IAAA,UAAA,CAAW,MAAA,CAAO,OAAO,CAAA;AAAA,EAC3B,CAAC,CAAA;AAED,EAAA,8BAAA,CAAU,EAAA,GAAM;AACd,IAAA,MAAM,SAAA,EAAW,WAAA,CAAY,IAAA,EAAM,GAAI,CAAA;AACvC,IAAA,OAAO,CAAA,EAAA,GAAM,aAAA,CAAc,QAAQ,CAAA;AAAA,EACrC,CAAA,EAAG,CAAC,MAAM,CAAC,CAAA;AAEX,EAAA,OAAO,EAAE,SAAA,EAAW,QAAQ,CAAA;AAC9B;ADhKA;AACE;AACA;AACA;AACA;AACA;AACA;AACA;AACF,gUAAC","file":"/Users/gwakko/Projects/shared-websocket/dist/react.cjs","sourcesContent":[null,"import {\n createContext,\n useContext,\n useEffect,\n useState,\n useEffectEvent,\n type ReactNode,\n createElement,\n} from 'react';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Context ─────────────────────────────────────────────\n\nconst SharedWSContext = createContext<SharedWebSocket | null>(null);\n\n/**\n * Provider props — pass URL and options as props for flexibility.\n *\n * @example\n * <SharedWebSocketProvider url=\"wss://api.example.com/ws\" options={{ auth: getToken }}>\n * <App />\n * </SharedWebSocketProvider>\n */\nexport interface SharedWebSocketProviderProps {\n url: string;\n options?: SharedWebSocketOptions;\n children: ReactNode;\n}\n\n/**\n * Provider component — creates SharedWebSocket from props, auto-disposes on unmount.\n *\n * @example\n * function App() {\n * return (\n * <SharedWebSocketProvider\n * url=\"wss://api.example.com/ws\"\n * options={{\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }}\n * >\n * <Dashboard />\n * </SharedWebSocketProvider>\n * );\n * }\n */\nexport function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {\n const [socket] = useState(() => {\n const ws = new SharedWebSocket(url, options);\n ws.connect();\n return ws;\n });\n\n useEffect(() => {\n return () => {\n socket[Symbol.dispose]();\n };\n }, [socket]);\n\n return createElement(SharedWSContext.Provider, { value: socket }, children);\n}\n\n/**\n * Access the SharedWebSocket instance from context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const ctx = useContext(SharedWSContext);\n if (!ctx) {\n throw new Error('useSharedWebSocket must be used within <SharedWebSocketProvider>');\n }\n return ctx;\n}\n\n// ─── Hooks ───────────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns the latest received value (reactive state).\n * - With callback: calls your handler on each event (stable ref via useEffectEvent).\n *\n * @example\n * // Reactive state — returns latest value\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback — full control, no state\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n *\n * @example\n * // Custom callback with transform — store in your own state\n * const [orders, setOrders] = useState<Order[]>([]);\n * useSocketEvent<Order>('order.created', (order) => {\n * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T | undefined>(undefined);\n\n const onEvent = useEffectEvent((data: T) => {\n if (callback) {\n callback(data);\n } else {\n setValue(data);\n }\n });\n\n useEffect(() => {\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return callback ? undefined : value;\n}\n\n/**\n * Accumulate WebSocket events into an array.\n * - Without callback: returns accumulated array (reactive state).\n * - With callback: calls your handler on each event, you manage your own state.\n *\n * @example\n * // Default — accumulates all events\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom callback — keep only last 50, transform, filter, etc.\n * const [messages, setMessages] = useState<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * setMessages(prev => [msg, ...prev].slice(0, 50));\n * });\n *\n * @example\n * // Custom callback — filter by type\n * const [errors, setErrors] = useState<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') setErrors(prev => [...prev, entry]);\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): T[] {\n const socket = useSharedWebSocket();\n const [items, setItems] = useState<T[]>([]);\n\n const onEvent = useEffectEvent((data: T) => {\n if (callback) {\n callback(data);\n } else {\n setItems((prev) => [...prev, data]);\n }\n });\n\n useEffect(() => {\n if (!callback) setItems([]);\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return callback ? [] : items;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: returns [value, setter] (like useState but synced).\n * - With callback: calls your handler when any tab updates this key.\n *\n * @example\n * // Default — reactive synced state\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });\n *\n * @example\n * // Custom callback — side effects on sync\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated', { count: cart.items.length });\n * });\n */\nexport function useSocketSync<T>(\n key: string,\n initialValue: T,\n callback?: (value: T) => void,\n): [T, (value: T) => void] {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T>(() => {\n return socket.getSync<T>(key) ?? initialValue;\n });\n\n const onSync = useEffectEvent((synced: T) => {\n setValue(synced);\n callback?.(synced);\n });\n\n useEffect(() => {\n const unsub = socket.onSync<T>(key, onSync);\n return unsub;\n }, [socket, key]);\n\n const setAndSync = useEffectEvent((newValue: T) => {\n setValue(newValue);\n socket.sync(key, newValue);\n });\n\n return [value, setAndSync];\n}\n\n/**\n * Subscribe to a WebSocket event with just a callback — no state, no return value.\n * Fire-and-forget: side effects, logging, analytics, sounds, browser notifications.\n * Stable ref via useEffectEvent — callback always sees latest closure values.\n *\n * @example\n * useSocketCallback<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', { id: order.id });\n * });\n *\n * @example\n * // Browser notification only from leader tab\n * useSocketCallback<Notification>('notification', (notif) => {\n * if (ws.tabRole === 'leader' && document.hidden) {\n * new Notification(notif.title, { body: notif.body });\n * }\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const handler = useEffectEvent((data: T) => {\n callback(data);\n });\n\n useEffect(() => {\n const unsub = socket.on(event, handler);\n return unsub;\n }, [socket, event]);\n}\n\n/**\n * Reactive connection status.\n * Uses useEffectEvent to avoid re-creating interval on state change.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: boolean;\n tabRole: TabRole;\n} {\n const socket = useSharedWebSocket();\n const [connected, setConnected] = useState(socket.connected);\n const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);\n\n const tick = useEffectEvent(() => {\n setConnected(socket.connected);\n setTabRole(socket.tabRole);\n });\n\n useEffect(() => {\n const interval = setInterval(tick, 1000);\n return () => clearInterval(interval);\n }, [socket]);\n\n return { connected, tabRole };\n}\n"]}
package/dist/react.js CHANGED
@@ -32,38 +32,47 @@ function useSharedWebSocket() {
32
32
  }
33
33
  return ctx;
34
34
  }
35
- function useSocketEvent(event) {
35
+ function useSocketEvent(event, callback) {
36
36
  const socket = useSharedWebSocket();
37
37
  const [value, setValue] = useState(void 0);
38
38
  const onEvent = useEffectEvent((data) => {
39
- setValue(data);
39
+ if (callback) {
40
+ callback(data);
41
+ } else {
42
+ setValue(data);
43
+ }
40
44
  });
41
45
  useEffect(() => {
42
46
  const unsub = socket.on(event, onEvent);
43
47
  return unsub;
44
48
  }, [socket, event]);
45
- return value;
49
+ return callback ? void 0 : value;
46
50
  }
47
- function useSocketStream(event) {
51
+ function useSocketStream(event, callback) {
48
52
  const socket = useSharedWebSocket();
49
53
  const [items, setItems] = useState([]);
50
54
  const onEvent = useEffectEvent((data) => {
51
- setItems((prev) => [...prev, data]);
55
+ if (callback) {
56
+ callback(data);
57
+ } else {
58
+ setItems((prev) => [...prev, data]);
59
+ }
52
60
  });
53
61
  useEffect(() => {
54
- setItems([]);
62
+ if (!callback) setItems([]);
55
63
  const unsub = socket.on(event, onEvent);
56
64
  return unsub;
57
65
  }, [socket, event]);
58
- return items;
66
+ return callback ? [] : items;
59
67
  }
60
- function useSocketSync(key, initialValue) {
68
+ function useSocketSync(key, initialValue, callback) {
61
69
  const socket = useSharedWebSocket();
62
70
  const [value, setValue] = useState(() => {
63
71
  return socket.getSync(key) ?? initialValue;
64
72
  });
65
73
  const onSync = useEffectEvent((synced) => {
66
74
  setValue(synced);
75
+ callback?.(synced);
67
76
  });
68
77
  useEffect(() => {
69
78
  const unsub = socket.onSync(key, onSync);
@@ -75,6 +84,16 @@ function useSocketSync(key, initialValue) {
75
84
  });
76
85
  return [value, setAndSync];
77
86
  }
87
+ function useSocketCallback(event, callback) {
88
+ const socket = useSharedWebSocket();
89
+ const handler = useEffectEvent((data) => {
90
+ callback(data);
91
+ });
92
+ useEffect(() => {
93
+ const unsub = socket.on(event, handler);
94
+ return unsub;
95
+ }, [socket, event]);
96
+ }
78
97
  function useSocketStatus() {
79
98
  const socket = useSharedWebSocket();
80
99
  const [connected, setConnected] = useState(socket.connected);
@@ -92,6 +111,7 @@ function useSocketStatus() {
92
111
  export {
93
112
  SharedWebSocketProvider,
94
113
  useSharedWebSocket,
114
+ useSocketCallback,
95
115
  useSocketEvent,
96
116
  useSocketStatus,
97
117
  useSocketStream,
package/dist/react.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/adapters/react.ts"],"sourcesContent":["import {\n createContext,\n useContext,\n useEffect,\n useState,\n useEffectEvent,\n type ReactNode,\n createElement,\n} from 'react';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Context ─────────────────────────────────────────────\n\nconst SharedWSContext = createContext<SharedWebSocket | null>(null);\n\n/**\n * Provider props — pass URL and options as props for flexibility.\n *\n * @example\n * <SharedWebSocketProvider url=\"wss://api.example.com/ws\" options={{ auth: getToken }}>\n * <App />\n * </SharedWebSocketProvider>\n */\nexport interface SharedWebSocketProviderProps {\n url: string;\n options?: SharedWebSocketOptions;\n children: ReactNode;\n}\n\n/**\n * Provider component — creates SharedWebSocket from props, auto-disposes on unmount.\n *\n * @example\n * function App() {\n * return (\n * <SharedWebSocketProvider\n * url=\"wss://api.example.com/ws\"\n * options={{\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }}\n * >\n * <Dashboard />\n * </SharedWebSocketProvider>\n * );\n * }\n */\nexport function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {\n const [socket] = useState(() => {\n const ws = new SharedWebSocket(url, options);\n ws.connect();\n return ws;\n });\n\n useEffect(() => {\n return () => {\n socket[Symbol.dispose]();\n };\n }, [socket]);\n\n return createElement(SharedWSContext.Provider, { value: socket }, children);\n}\n\n/**\n * Access the SharedWebSocket instance from context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const ctx = useContext(SharedWSContext);\n if (!ctx) {\n throw new Error('useSharedWebSocket must be used within <SharedWebSocketProvider>');\n }\n return ctx;\n}\n\n// ─── Hooks ───────────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event. Returns the latest received value.\n * Uses useEffectEvent for a stable callback ref — no stale closures.\n *\n * @example\n * const order = useSocketEvent<Order>('order.created');\n */\nexport function useSocketEvent<T>(event: string): T | undefined {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T | undefined>(undefined);\n\n const onEvent = useEffectEvent((data: T) => {\n setValue(data);\n });\n\n useEffect(() => {\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return value;\n}\n\n/**\n * Accumulate WebSocket events into an array.\n * Uses useEffectEvent — handler always sees latest state without re-subscribing.\n *\n * @example\n * const messages = useSocketStream<ChatMessage>('chat.message');\n */\nexport function useSocketStream<T>(event: string): T[] {\n const socket = useSharedWebSocket();\n const [items, setItems] = useState<T[]>([]);\n\n const onEvent = useEffectEvent((data: T) => {\n setItems((prev) => [...prev, data]);\n });\n\n useEffect(() => {\n setItems([]);\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return items;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * Uses useEffectEvent for stable sync callback.\n *\n * @example\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });\n * // setCart in one tab → updates all tabs instantly\n */\nexport function useSocketSync<T>(\n key: string,\n initialValue: T,\n): [T, (value: T) => void] {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T>(() => {\n return socket.getSync<T>(key) ?? initialValue;\n });\n\n const onSync = useEffectEvent((synced: T) => {\n setValue(synced);\n });\n\n useEffect(() => {\n const unsub = socket.onSync<T>(key, onSync);\n return unsub;\n }, [socket, key]);\n\n const setAndSync = useEffectEvent((newValue: T) => {\n setValue(newValue);\n socket.sync(key, newValue);\n });\n\n return [value, setAndSync];\n}\n\n/**\n * Reactive connection status.\n * Uses useEffectEvent to avoid re-creating interval on state change.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: boolean;\n tabRole: TabRole;\n} {\n const socket = useSharedWebSocket();\n const [connected, setConnected] = useState(socket.connected);\n const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);\n\n const tick = useEffectEvent(() => {\n setConnected(socket.connected);\n setTabRole(socket.tabRole);\n });\n\n useEffect(() => {\n const interval = setInterval(tick, 1000);\n return () => clearInterval(interval);\n }, [socket]);\n\n return { connected, tabRole };\n}\n"],"mappings":";;;;;AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEA;AAAA,OACK;AAMP,IAAM,kBAAkB,cAAsC,IAAI;AAkC3D,SAAS,wBAAwB,EAAE,KAAK,SAAS,SAAS,GAAiC;AAChG,QAAM,CAAC,MAAM,IAAI,SAAS,MAAM;AAC9B,UAAM,KAAK,IAAI,gBAAgB,KAAK,OAAO;AAC3C,OAAG,QAAQ;AACX,WAAO;AAAA,EACT,CAAC;AAED,YAAU,MAAM;AACd,WAAO,MAAM;AACX,aAAO,OAAO,OAAO,EAAE;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO,cAAc,gBAAgB,UAAU,EAAE,OAAO,OAAO,GAAG,QAAQ;AAC5E;AASO,SAAS,qBAAsC;AACpD,QAAM,MAAM,WAAW,eAAe;AACtC,MAAI,CAAC,KAAK;AACR,UAAM,IAAI,MAAM,kEAAkE;AAAA,EACpF;AACA,SAAO;AACT;AAWO,SAAS,eAAkB,OAA8B;AAC9D,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,MAAS;AAE3D,QAAM,UAAU,eAAe,CAAC,SAAY;AAC1C,aAAS,IAAI;AAAA,EACf,CAAC;AAED,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,GAAG,OAAO,OAAO;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SAAO;AACT;AASO,SAAS,gBAAmB,OAAoB;AACrD,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAc,CAAC,CAAC;AAE1C,QAAM,UAAU,eAAe,CAAC,SAAY;AAC1C,aAAS,CAAC,SAAS,CAAC,GAAG,MAAM,IAAI,CAAC;AAAA,EACpC,CAAC;AAED,YAAU,MAAM;AACd,aAAS,CAAC,CAAC;AACX,UAAM,QAAQ,OAAO,GAAG,OAAO,OAAO;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SAAO;AACT;AAUO,SAAS,cACd,KACA,cACyB;AACzB,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAY,MAAM;AAC1C,WAAO,OAAO,QAAW,GAAG,KAAK;AAAA,EACnC,CAAC;AAED,QAAM,SAAS,eAAe,CAAC,WAAc;AAC3C,aAAS,MAAM;AAAA,EACjB,CAAC;AAED,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,OAAU,KAAK,MAAM;AAC1C,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,GAAG,CAAC;AAEhB,QAAM,aAAa,eAAe,CAAC,aAAgB;AACjD,aAAS,QAAQ;AACjB,WAAO,KAAK,KAAK,QAAQ;AAAA,EAC3B,CAAC;AAED,SAAO,CAAC,OAAO,UAAU;AAC3B;AASO,SAAS,kBAGd;AACA,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,OAAO,SAAS;AAC3D,QAAM,CAAC,SAAS,UAAU,IAAI,SAAkB,OAAO,OAAO;AAE9D,QAAM,OAAO,eAAe,MAAM;AAChC,iBAAa,OAAO,SAAS;AAC7B,eAAW,OAAO,OAAO;AAAA,EAC3B,CAAC;AAED,YAAU,MAAM;AACd,UAAM,WAAW,YAAY,MAAM,GAAI;AACvC,WAAO,MAAM,cAAc,QAAQ;AAAA,EACrC,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO,EAAE,WAAW,QAAQ;AAC9B;","names":[]}
1
+ {"version":3,"sources":["../src/adapters/react.ts"],"sourcesContent":["import {\n createContext,\n useContext,\n useEffect,\n useState,\n useEffectEvent,\n type ReactNode,\n createElement,\n} from 'react';\nimport { SharedWebSocket } from '../SharedWebSocket';\nimport type { SharedWebSocketOptions, TabRole } from '../types';\n\n// ─── Context ─────────────────────────────────────────────\n\nconst SharedWSContext = createContext<SharedWebSocket | null>(null);\n\n/**\n * Provider props — pass URL and options as props for flexibility.\n *\n * @example\n * <SharedWebSocketProvider url=\"wss://api.example.com/ws\" options={{ auth: getToken }}>\n * <App />\n * </SharedWebSocketProvider>\n */\nexport interface SharedWebSocketProviderProps {\n url: string;\n options?: SharedWebSocketOptions;\n children: ReactNode;\n}\n\n/**\n * Provider component — creates SharedWebSocket from props, auto-disposes on unmount.\n *\n * @example\n * function App() {\n * return (\n * <SharedWebSocketProvider\n * url=\"wss://api.example.com/ws\"\n * options={{\n * auth: () => localStorage.getItem('token')!,\n * useWorker: true,\n * }}\n * >\n * <Dashboard />\n * </SharedWebSocketProvider>\n * );\n * }\n */\nexport function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps) {\n const [socket] = useState(() => {\n const ws = new SharedWebSocket(url, options);\n ws.connect();\n return ws;\n });\n\n useEffect(() => {\n return () => {\n socket[Symbol.dispose]();\n };\n }, [socket]);\n\n return createElement(SharedWSContext.Provider, { value: socket }, children);\n}\n\n/**\n * Access the SharedWebSocket instance from context.\n *\n * @example\n * const ws = useSharedWebSocket();\n * ws.send('chat.message', { text: 'Hello' });\n */\nexport function useSharedWebSocket(): SharedWebSocket {\n const ctx = useContext(SharedWSContext);\n if (!ctx) {\n throw new Error('useSharedWebSocket must be used within <SharedWebSocketProvider>');\n }\n return ctx;\n}\n\n// ─── Hooks ───────────────────────────────────────────────\n\n/**\n * Subscribe to a WebSocket event.\n * - Without callback: returns the latest received value (reactive state).\n * - With callback: calls your handler on each event (stable ref via useEffectEvent).\n *\n * @example\n * // Reactive state — returns latest value\n * const order = useSocketEvent<Order>('order.created');\n *\n * @example\n * // Custom callback — full control, no state\n * useSocketEvent<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', order);\n * });\n *\n * @example\n * // Custom callback with transform — store in your own state\n * const [orders, setOrders] = useState<Order[]>([]);\n * useSocketEvent<Order>('order.created', (order) => {\n * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50\n * });\n */\nexport function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T | undefined>(undefined);\n\n const onEvent = useEffectEvent((data: T) => {\n if (callback) {\n callback(data);\n } else {\n setValue(data);\n }\n });\n\n useEffect(() => {\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return callback ? undefined : value;\n}\n\n/**\n * Accumulate WebSocket events into an array.\n * - Without callback: returns accumulated array (reactive state).\n * - With callback: calls your handler on each event, you manage your own state.\n *\n * @example\n * // Default — accumulates all events\n * const messages = useSocketStream<ChatMessage>('chat.message');\n *\n * @example\n * // Custom callback — keep only last 50, transform, filter, etc.\n * const [messages, setMessages] = useState<ChatMessage[]>([]);\n * useSocketStream<ChatMessage>('chat.message', (msg) => {\n * setMessages(prev => [msg, ...prev].slice(0, 50));\n * });\n *\n * @example\n * // Custom callback — filter by type\n * const [errors, setErrors] = useState<LogEntry[]>([]);\n * useSocketStream<LogEntry>('log.entry', (entry) => {\n * if (entry.level === 'error') setErrors(prev => [...prev, entry]);\n * });\n */\nexport function useSocketStream<T>(event: string, callback?: (data: T) => void): T[] {\n const socket = useSharedWebSocket();\n const [items, setItems] = useState<T[]>([]);\n\n const onEvent = useEffectEvent((data: T) => {\n if (callback) {\n callback(data);\n } else {\n setItems((prev) => [...prev, data]);\n }\n });\n\n useEffect(() => {\n if (!callback) setItems([]);\n const unsub = socket.on(event, onEvent);\n return unsub;\n }, [socket, event]);\n\n return callback ? [] : items;\n}\n\n/**\n * Two-way state sync across browser tabs.\n * - Without callback: returns [value, setter] (like useState but synced).\n * - With callback: calls your handler when any tab updates this key.\n *\n * @example\n * // Default — reactive synced state\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });\n *\n * @example\n * // Custom callback — side effects on sync\n * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] }, (cart) => {\n * document.title = `Cart (${cart.items.length})`;\n * analytics.track('cart_updated', { count: cart.items.length });\n * });\n */\nexport function useSocketSync<T>(\n key: string,\n initialValue: T,\n callback?: (value: T) => void,\n): [T, (value: T) => void] {\n const socket = useSharedWebSocket();\n const [value, setValue] = useState<T>(() => {\n return socket.getSync<T>(key) ?? initialValue;\n });\n\n const onSync = useEffectEvent((synced: T) => {\n setValue(synced);\n callback?.(synced);\n });\n\n useEffect(() => {\n const unsub = socket.onSync<T>(key, onSync);\n return unsub;\n }, [socket, key]);\n\n const setAndSync = useEffectEvent((newValue: T) => {\n setValue(newValue);\n socket.sync(key, newValue);\n });\n\n return [value, setAndSync];\n}\n\n/**\n * Subscribe to a WebSocket event with just a callback — no state, no return value.\n * Fire-and-forget: side effects, logging, analytics, sounds, browser notifications.\n * Stable ref via useEffectEvent — callback always sees latest closure values.\n *\n * @example\n * useSocketCallback<Order>('order.created', (order) => {\n * playSound('new-order');\n * analytics.track('order_received', { id: order.id });\n * });\n *\n * @example\n * // Browser notification only from leader tab\n * useSocketCallback<Notification>('notification', (notif) => {\n * if (ws.tabRole === 'leader' && document.hidden) {\n * new Notification(notif.title, { body: notif.body });\n * }\n * });\n */\nexport function useSocketCallback<T>(event: string, callback: (data: T) => void): void {\n const socket = useSharedWebSocket();\n\n const handler = useEffectEvent((data: T) => {\n callback(data);\n });\n\n useEffect(() => {\n const unsub = socket.on(event, handler);\n return unsub;\n }, [socket, event]);\n}\n\n/**\n * Reactive connection status.\n * Uses useEffectEvent to avoid re-creating interval on state change.\n *\n * @example\n * const { connected, tabRole } = useSocketStatus();\n */\nexport function useSocketStatus(): {\n connected: boolean;\n tabRole: TabRole;\n} {\n const socket = useSharedWebSocket();\n const [connected, setConnected] = useState(socket.connected);\n const [tabRole, setTabRole] = useState<TabRole>(socket.tabRole);\n\n const tick = useEffectEvent(() => {\n setConnected(socket.connected);\n setTabRole(socket.tabRole);\n });\n\n useEffect(() => {\n const interval = setInterval(tick, 1000);\n return () => clearInterval(interval);\n }, [socket]);\n\n return { connected, tabRole };\n}\n"],"mappings":";;;;;AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAEA;AAAA,OACK;AAMP,IAAM,kBAAkB,cAAsC,IAAI;AAkC3D,SAAS,wBAAwB,EAAE,KAAK,SAAS,SAAS,GAAiC;AAChG,QAAM,CAAC,MAAM,IAAI,SAAS,MAAM;AAC9B,UAAM,KAAK,IAAI,gBAAgB,KAAK,OAAO;AAC3C,OAAG,QAAQ;AACX,WAAO;AAAA,EACT,CAAC;AAED,YAAU,MAAM;AACd,WAAO,MAAM;AACX,aAAO,OAAO,OAAO,EAAE;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO,cAAc,gBAAgB,UAAU,EAAE,OAAO,OAAO,GAAG,QAAQ;AAC5E;AASO,SAAS,qBAAsC;AACpD,QAAM,MAAM,WAAW,eAAe;AACtC,MAAI,CAAC,KAAK;AACR,UAAM,IAAI,MAAM,kEAAkE;AAAA,EACpF;AACA,SAAO;AACT;AA2BO,SAAS,eAAkB,OAAe,UAA6C;AAC5F,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,MAAS;AAE3D,QAAM,UAAU,eAAe,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,eAAS,IAAI;AAAA,IACf;AAAA,EACF,CAAC;AAED,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,GAAG,OAAO,OAAO;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SAAO,WAAW,SAAY;AAChC;AAyBO,SAAS,gBAAmB,OAAe,UAAmC;AACnF,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAc,CAAC,CAAC;AAE1C,QAAM,UAAU,eAAe,CAAC,SAAY;AAC1C,QAAI,UAAU;AACZ,eAAS,IAAI;AAAA,IACf,OAAO;AACL,eAAS,CAAC,SAAS,CAAC,GAAG,MAAM,IAAI,CAAC;AAAA,IACpC;AAAA,EACF,CAAC;AAED,YAAU,MAAM;AACd,QAAI,CAAC,SAAU,UAAS,CAAC,CAAC;AAC1B,UAAM,QAAQ,OAAO,GAAG,OAAO,OAAO;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,SAAO,WAAW,CAAC,IAAI;AACzB;AAkBO,SAAS,cACd,KACA,cACA,UACyB;AACzB,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAY,MAAM;AAC1C,WAAO,OAAO,QAAW,GAAG,KAAK;AAAA,EACnC,CAAC;AAED,QAAM,SAAS,eAAe,CAAC,WAAc;AAC3C,aAAS,MAAM;AACf,eAAW,MAAM;AAAA,EACnB,CAAC;AAED,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,OAAU,KAAK,MAAM;AAC1C,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,GAAG,CAAC;AAEhB,QAAM,aAAa,eAAe,CAAC,aAAgB;AACjD,aAAS,QAAQ;AACjB,WAAO,KAAK,KAAK,QAAQ;AAAA,EAC3B,CAAC;AAED,SAAO,CAAC,OAAO,UAAU;AAC3B;AAqBO,SAAS,kBAAqB,OAAe,UAAmC;AACrF,QAAM,SAAS,mBAAmB;AAElC,QAAM,UAAU,eAAe,CAAC,SAAY;AAC1C,aAAS,IAAI;AAAA,EACf,CAAC;AAED,YAAU,MAAM;AACd,UAAM,QAAQ,OAAO,GAAG,OAAO,OAAO;AACtC,WAAO;AAAA,EACT,GAAG,CAAC,QAAQ,KAAK,CAAC;AACpB;AASO,SAAS,kBAGd;AACA,QAAM,SAAS,mBAAmB;AAClC,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,OAAO,SAAS;AAC3D,QAAM,CAAC,SAAS,UAAU,IAAI,SAAkB,OAAO,OAAO;AAE9D,QAAM,OAAO,eAAe,MAAM;AAChC,iBAAa,OAAO,SAAS;AAC7B,eAAW,OAAO,OAAO;AAAA,EAC3B,CAAC;AAED,YAAU,MAAM;AACd,UAAM,WAAW,YAAY,MAAM,GAAI;AACvC,WAAO,MAAM,cAAc,QAAQ;AAAA,EACrC,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO,EAAE,WAAW,QAAQ;AAC9B;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gwakko/shared-websocket",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -80,18 +80,38 @@ export function useSharedWebSocket(): SharedWebSocket {
80
80
  // ─── Hooks ───────────────────────────────────────────────
81
81
 
82
82
  /**
83
- * Subscribe to a WebSocket event. Returns the latest received value.
84
- * Uses useEffectEvent for a stable callback ref no stale closures.
83
+ * Subscribe to a WebSocket event.
84
+ * - Without callback: returns the latest received value (reactive state).
85
+ * - With callback: calls your handler on each event (stable ref via useEffectEvent).
85
86
  *
86
87
  * @example
88
+ * // Reactive state — returns latest value
87
89
  * const order = useSocketEvent<Order>('order.created');
90
+ *
91
+ * @example
92
+ * // Custom callback — full control, no state
93
+ * useSocketEvent<Order>('order.created', (order) => {
94
+ * playSound('new-order');
95
+ * analytics.track('order_received', order);
96
+ * });
97
+ *
98
+ * @example
99
+ * // Custom callback with transform — store in your own state
100
+ * const [orders, setOrders] = useState<Order[]>([]);
101
+ * useSocketEvent<Order>('order.created', (order) => {
102
+ * setOrders(prev => [order, ...prev].slice(0, 50)); // keep last 50
103
+ * });
88
104
  */
89
- export function useSocketEvent<T>(event: string): T | undefined {
105
+ export function useSocketEvent<T>(event: string, callback?: (data: T) => void): T | undefined {
90
106
  const socket = useSharedWebSocket();
91
107
  const [value, setValue] = useState<T | undefined>(undefined);
92
108
 
93
109
  const onEvent = useEffectEvent((data: T) => {
94
- setValue(data);
110
+ if (callback) {
111
+ callback(data);
112
+ } else {
113
+ setValue(data);
114
+ }
95
115
  });
96
116
 
97
117
  useEffect(() => {
@@ -99,44 +119,73 @@ export function useSocketEvent<T>(event: string): T | undefined {
99
119
  return unsub;
100
120
  }, [socket, event]);
101
121
 
102
- return value;
122
+ return callback ? undefined : value;
103
123
  }
104
124
 
105
125
  /**
106
126
  * Accumulate WebSocket events into an array.
107
- * Uses useEffectEvent handler always sees latest state without re-subscribing.
127
+ * - Without callback: returns accumulated array (reactive state).
128
+ * - With callback: calls your handler on each event, you manage your own state.
108
129
  *
109
130
  * @example
131
+ * // Default — accumulates all events
110
132
  * const messages = useSocketStream<ChatMessage>('chat.message');
133
+ *
134
+ * @example
135
+ * // Custom callback — keep only last 50, transform, filter, etc.
136
+ * const [messages, setMessages] = useState<ChatMessage[]>([]);
137
+ * useSocketStream<ChatMessage>('chat.message', (msg) => {
138
+ * setMessages(prev => [msg, ...prev].slice(0, 50));
139
+ * });
140
+ *
141
+ * @example
142
+ * // Custom callback — filter by type
143
+ * const [errors, setErrors] = useState<LogEntry[]>([]);
144
+ * useSocketStream<LogEntry>('log.entry', (entry) => {
145
+ * if (entry.level === 'error') setErrors(prev => [...prev, entry]);
146
+ * });
111
147
  */
112
- export function useSocketStream<T>(event: string): T[] {
148
+ export function useSocketStream<T>(event: string, callback?: (data: T) => void): T[] {
113
149
  const socket = useSharedWebSocket();
114
150
  const [items, setItems] = useState<T[]>([]);
115
151
 
116
152
  const onEvent = useEffectEvent((data: T) => {
117
- setItems((prev) => [...prev, data]);
153
+ if (callback) {
154
+ callback(data);
155
+ } else {
156
+ setItems((prev) => [...prev, data]);
157
+ }
118
158
  });
119
159
 
120
160
  useEffect(() => {
121
- setItems([]);
161
+ if (!callback) setItems([]);
122
162
  const unsub = socket.on(event, onEvent);
123
163
  return unsub;
124
164
  }, [socket, event]);
125
165
 
126
- return items;
166
+ return callback ? [] : items;
127
167
  }
128
168
 
129
169
  /**
130
170
  * Two-way state sync across browser tabs.
131
- * Uses useEffectEvent for stable sync callback.
171
+ * - Without callback: returns [value, setter] (like useState but synced).
172
+ * - With callback: calls your handler when any tab updates this key.
132
173
  *
133
174
  * @example
175
+ * // Default — reactive synced state
134
176
  * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });
135
- * // setCart in one tab → updates all tabs instantly
177
+ *
178
+ * @example
179
+ * // Custom callback — side effects on sync
180
+ * const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] }, (cart) => {
181
+ * document.title = `Cart (${cart.items.length})`;
182
+ * analytics.track('cart_updated', { count: cart.items.length });
183
+ * });
136
184
  */
137
185
  export function useSocketSync<T>(
138
186
  key: string,
139
187
  initialValue: T,
188
+ callback?: (value: T) => void,
140
189
  ): [T, (value: T) => void] {
141
190
  const socket = useSharedWebSocket();
142
191
  const [value, setValue] = useState<T>(() => {
@@ -145,6 +194,7 @@ export function useSocketSync<T>(
145
194
 
146
195
  const onSync = useEffectEvent((synced: T) => {
147
196
  setValue(synced);
197
+ callback?.(synced);
148
198
  });
149
199
 
150
200
  useEffect(() => {
@@ -160,6 +210,38 @@ export function useSocketSync<T>(
160
210
  return [value, setAndSync];
161
211
  }
162
212
 
213
+ /**
214
+ * Subscribe to a WebSocket event with just a callback — no state, no return value.
215
+ * Fire-and-forget: side effects, logging, analytics, sounds, browser notifications.
216
+ * Stable ref via useEffectEvent — callback always sees latest closure values.
217
+ *
218
+ * @example
219
+ * useSocketCallback<Order>('order.created', (order) => {
220
+ * playSound('new-order');
221
+ * analytics.track('order_received', { id: order.id });
222
+ * });
223
+ *
224
+ * @example
225
+ * // Browser notification only from leader tab
226
+ * useSocketCallback<Notification>('notification', (notif) => {
227
+ * if (ws.tabRole === 'leader' && document.hidden) {
228
+ * new Notification(notif.title, { body: notif.body });
229
+ * }
230
+ * });
231
+ */
232
+ export function useSocketCallback<T>(event: string, callback: (data: T) => void): void {
233
+ const socket = useSharedWebSocket();
234
+
235
+ const handler = useEffectEvent((data: T) => {
236
+ callback(data);
237
+ });
238
+
239
+ useEffect(() => {
240
+ const unsub = socket.on(event, handler);
241
+ return unsub;
242
+ }, [socket, event]);
243
+ }
244
+
163
245
  /**
164
246
  * Reactive connection status.
165
247
  * Uses useEffectEvent to avoid re-creating interval on state change.