@gwakko/shared-websocket 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,52 +1,10 @@
1
1
  # Shared WebSocket
2
2
 
3
- Share ONE WebSocket connection across multiple browser tabs. Zero dependencies. React and Vue adapters included.
3
+ Share **one** WebSocket connection across all browser tabs. Leader election via BroadcastChannel. React 19 hooks and Vue 3 composables included. Zero dependencies.
4
4
 
5
- ## Table of Contents
5
+ [![npm](https://img.shields.io/npm/v/@gwakko/shared-websocket)](https://www.npmjs.com/package/@gwakko/shared-websocket)
6
6
 
7
- - [Problem](#problem)
8
- - [Solution](#solution)
9
- - [Installation](#installation)
10
- - [Usage — Vanilla TypeScript](#usage--vanilla-typescript)
11
- - [Scoped Lifecycle — withSocket()](#scoped-lifecycle--withsocket)
12
- - [Usage — React](#usage--react)
13
- - [Usage — Vue 3](#usage--vue-3)
14
- - [API Reference](#api-reference)
15
- - [Options](#options)
16
- - [Authentication](#authentication)
17
- - [React Hooks](#react-hooks-react-19-useeffectevent-for-stable-refs)
18
- - [Vue Composables](#vue-composables)
19
- - [How It Works](#how-it-works)
20
- - [When to Use `useWorker: true`](#when-to-use-useworker-true)
21
- - [Typed Events](#typed-events)
22
- - [Type narrowing](#type-narrowing-for-untyped-events)
23
- - [Runtime validation with Zod](#runtime-validation-with-zod)
24
- - [Middleware](#middleware)
25
- - [Debug Mode & Custom Logger](#debug-mode--custom-logger)
26
- - [Custom Serialization](#custom-serialization)
27
- - [Custom Event Protocol](#custom-event-protocol)
28
- - [Advanced Examples](#advanced-examples)
29
- - [Stream](#stream--consume-events-as-async-iterator)
30
- - [Request](#request--requestresponse-through-server)
31
- - [Protocols](#protocols--websocket-subprotocols)
32
- - [Worker URL](#worker-url--custom-worker-file)
33
- - [Lifecycle Hooks](#lifecycle-hooks)
34
- - [Private Channels](#private-channels--chat-rooms-tenant-notifications)
35
- - [Server-side channel handling](#server-side-channel-handling)
36
- - [Topics](#topics--server-side-filtered-subscriptions)
37
- - [Push Notifications](#push-notifications)
38
- - [Server-Side Implementation Guide](#server-side-implementation-guide)
39
- - [Exported Types](#exported-types)
40
- - [Browser Support](#browser-support)
41
- - [License](#license)
42
-
43
- ## Problem
44
-
45
- 5 tabs open = 5 WebSocket connections = 5x server resources for the same user.
46
-
47
- ## Solution
48
-
49
- One tab becomes the **leader** (holds the WebSocket). Other tabs are **followers** (receive data via BroadcastChannel). If the leader closes — automatic election picks a new leader. Zero downtime.
7
+ ## How It Works
50
8
 
51
9
  ```
52
10
  Tab 1 (Leader) Tab 2 (Follower) Tab 3 (Follower)
@@ -58,144 +16,57 @@ Tab 1 (Leader) Tab 2 (Follower) Tab 3 (Follower)
58
16
  └────── BroadcastChannel ──────────────────────┘
59
17
  ```
60
18
 
61
- ## Installation
62
-
63
- ### From npm
64
-
65
- ```bash
66
- npm install @gwakko/shared-websocket
67
- ```
68
-
69
- ### From GitHub (latest source)
70
-
71
- ```bash
72
- npm install github:Gwakko/shared-websocket
73
- ```
74
-
75
- ### Manual (copy into your project)
76
-
77
- ```bash
78
- # Clone and copy src/ into your project
79
- git clone https://github.com/Gwakko/shared-websocket.git
80
- cp -r shared-websocket/src ./your-project/shared-websocket
81
- ```
19
+ One tab becomes the **leader** (holds the WebSocket). Others are **followers** (receive data via BroadcastChannel). Leader closes → automatic election → new leader connects. Zero downtime.
82
20
 
83
- ### Build from source
21
+ ## Installation
84
22
 
85
23
  ```bash
86
- git clone https://github.com/Gwakko/shared-websocket.git
87
- cd shared-websocket
88
- npm install
89
- npm run build # outputs ESM + CJS + types to dist/
90
- ```
91
-
92
- ## Usage — Vanilla TypeScript
93
-
94
- ```typescript
95
- import { SharedWebSocket } from '@gwakko/shared-websocket';
96
-
97
- const ws = new SharedWebSocket('wss://api.example.com/ws', {
98
- auth: () => localStorage.getItem('token')!, // or authToken: 'static-token'
99
- authParam: 'token', // default — query param name (?token=xxx)
100
- useWorker: true, // optional — offload WebSocket to Web Worker
101
- });
102
-
103
- await ws.connect();
104
-
105
- // Subscribe to events (works in ALL tabs)
106
- ws.on('order.created', (order) => {
107
- console.log('New order:', order);
108
- });
109
-
110
- // Send message (auto-routed through leader tab)
111
- ws.send('chat.message', { text: 'Hello!' });
112
-
113
- // Generator streaming
114
- for await (const msg of ws.stream('chat.messages')) {
115
- console.log(msg);
116
- }
117
-
118
- // Sync state across tabs (no server roundtrip)
119
- ws.sync('cart', { items: [1, 2, 3] });
120
- ws.onSync('cart', (cart) => console.log('Cart updated:', cart));
121
-
122
- // Cleanup
123
- ws.disconnect();
24
+ npm install @gwakko/shared-websocket # npm
25
+ npm install github:Gwakko/shared-websocket # from GitHub
124
26
  ```
125
27
 
126
- ### Scoped Lifecycle — `withSocket()`
28
+ ## Quick Start
127
29
 
128
- Auto-creates, connects, and disposes. Guarantees cleanup even on errors.
30
+ ### Vanilla TypeScript
129
31
 
130
32
  ```typescript
131
33
  import { withSocket } from '@gwakko/shared-websocket';
132
34
 
133
- // Basic
134
- await withSocket('wss://api.example.com/ws', async ({ ws }) => {
135
- ws.on('order.created', (order) => console.log(order));
136
- await longRunningWork();
137
- }); // auto-disposed here
138
-
139
- // With auth
140
35
  await withSocket('wss://api.example.com/ws', {
141
36
  auth: () => localStorage.getItem('token')!,
37
+ useWorker: true, // optional: offload to Web Worker
142
38
  }, async ({ ws, signal }) => {
143
- for await (const msg of ws.stream('chat.messages', signal)) {
144
- renderMessage(msg);
145
- }
146
- });
147
-
148
- // Tab-to-tab sync via BroadcastChannel (no server roundtrip)
149
- await withSocket('wss://api.example.com/ws', async ({ ws }) => {
150
- // Send state to ALL tabs instantly
151
- ws.sync('cart', { items: [1, 2, 3] });
152
- ws.sync('theme', 'dark');
153
- ws.sync('locale', 'en');
154
-
155
- // Read synced state from other tabs
156
- const cart = ws.getSync<Cart>('cart'); // { items: [1, 2, 3] }
157
-
158
- // React to changes from other tabs
159
- ws.onSync('cart', (cart) => {
160
- updateCartBadge(cart.items.length);
161
- });
162
-
163
- ws.onSync('theme', (theme) => {
164
- document.documentElement.setAttribute('data-theme', theme);
165
- });
166
- });
167
39
 
168
- // Combine: server events + tab sync
169
- await withSocket('wss://api.example.com/ws', {
170
- auth: () => localStorage.getItem('token')!,
171
- }, async ({ ws, signal }) => {
172
- // Server events → update state → sync to all tabs
173
- ws.on('order.status', (order) => {
174
- ws.sync('activeOrder', order); // all tabs see the update
175
- });
40
+ // Listen to events (works in ALL tabs)
41
+ ws.on('chat.message', (msg) => renderMessage(msg));
176
42
 
177
- // One tab adds to cart → all tabs update
178
- ws.onSync('cart', (cart) => renderCart(cart));
43
+ // Send (auto-routed through leader)
44
+ ws.send('chat.message', { text: 'Hello!' });
179
45
 
180
- // Stream server messages with auto-cleanup
181
- for await (const msg of ws.stream('chat.messages', signal)) {
182
- renderMessage(msg);
46
+ // Stream
47
+ for await (const tick of ws.stream('trading.tick', signal)) {
48
+ updateChart(tick);
183
49
  }
184
- });
185
50
 
186
- // With external cancellation (AbortController)
187
- const controller = new AbortController();
188
- setTimeout(() => controller.abort(), 30_000);
51
+ // Sync state across tabs (no server)
52
+ ws.sync('cart', { items: [1, 2, 3] });
53
+ ws.onSync('cart', (cart) => updateBadge(cart.items.length));
189
54
 
190
- await withSocket('wss://api.example.com/ws', {
191
- signal: controller.signal,
192
- }, async ({ ws, signal }) => {
193
- ws.on('notifications', showToast);
194
- await new Promise((_, reject) => signal.addEventListener('abort', reject));
55
+ // Private channel
56
+ const chat = ws.channel('chat:room_42');
57
+ chat.on('message', (msg) => render(msg));
58
+ chat.send('message', { text: 'Hi room!' });
59
+
60
+ // Push notifications
61
+ ws.push('notification', {
62
+ render: (n) => toast(n.title), // sonner/react-hot-toast
63
+ title: (n) => n.title, // + browser Notification
64
+ target: 'active', // active | leader | all
65
+ });
195
66
  });
196
67
  ```
197
68
 
198
- ## Usage — React
69
+ ### React 19
199
70
 
200
71
  ```tsx
201
72
  import {
@@ -204,18 +75,19 @@ import {
204
75
  useSocketEvent,
205
76
  useSocketStream,
206
77
  useSocketSync,
78
+ useSocketCallback,
207
79
  useSocketStatus,
80
+ useSocketLifecycle,
81
+ useChannel,
82
+ useTopics,
83
+ usePush,
208
84
  } from '@gwakko/shared-websocket/react';
209
85
 
210
- // Provider accepts url and options as props
211
86
  function App() {
212
87
  return (
213
88
  <SharedWebSocketProvider
214
89
  url="wss://api.example.com/ws"
215
- options={{
216
- auth: () => localStorage.getItem('token')!,
217
- useWorker: true,
218
- }}
90
+ options={{ auth: () => getToken(), useWorker: true }}
219
91
  >
220
92
  <Dashboard />
221
93
  </SharedWebSocketProvider>
@@ -224,1647 +96,144 @@ function App() {
224
96
 
225
97
  function Dashboard() {
226
98
  const ws = useSharedWebSocket();
227
-
228
- // Latest event value (reactive) — no need to pass ws, uses context
229
99
  const order = useSocketEvent<Order>('order.created');
100
+ const [cart, setCart] = useSocketSync('cart', { items: [] });
101
+ const { connected, tabRole } = useSocketStatus();
230
102
 
231
- // Accumulated stream
232
- const messages = useSocketStream<Message>('chat.message');
103
+ // Callback variant
104
+ useSocketEvent<Order>('order.created', (order) => {
105
+ playSound('new-order');
106
+ });
233
107
 
234
- // Synced across tabs (no server roundtrip)
235
- const [cart, setCart] = useSocketSync('cart', { items: [] });
108
+ // Lifecycle
109
+ useSocketLifecycle({
110
+ onConnect: () => toast.success('Connected'),
111
+ onActive: () => refreshData(),
112
+ });
236
113
 
237
- // Connection status
238
- const { connected, tabRole } = useSocketStatus();
114
+ // Channel
115
+ const chat = useChannel(`chat:${roomId}`);
239
116
 
240
- return (
241
- <div>
242
- <p>Status: {connected ? 'Online' : 'Offline'} ({tabRole})</p>
243
- {order && <p>Latest order: #{order.id}</p>}
244
- <button onClick={() => ws.send('ping', {})}>Ping</button>
245
- <button onClick={() => setCart({ items: [...cart.items, Date.now()] })}>
246
- Add to cart ({cart.items.length})
247
- </button>
248
- </div>
249
- );
117
+ // Topics
118
+ useTopics(['notifications:orders']);
119
+
120
+ // Push
121
+ usePush('notification', {
122
+ render: (n) => toast(n.title),
123
+ target: 'active',
124
+ });
125
+
126
+ return <div>{connected ? 'Online' : 'Offline'} ({tabRole})</div>;
250
127
  }
251
128
  ```
252
129
 
253
- ## Usage — Vue 3
130
+ ### Vue 3
254
131
 
255
- ```vue
256
- <!-- main.ts -->
257
- <script setup>
258
- import { createApp } from 'vue';
259
- import { createSharedWebSocketPlugin } from '@gwakko/shared-websocket/vue';
260
- import App from './App.vue';
261
-
262
- const app = createApp(App);
132
+ ```typescript
133
+ // main.ts
263
134
  app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
264
- auth: () => localStorage.getItem('token')!,
135
+ auth: () => getToken(),
265
136
  useWorker: true,
266
137
  }));
267
- app.mount('#app');
268
- </script>
269
138
  ```
270
139
 
271
140
  ```vue
272
- <!-- Dashboard.vue -->
273
141
  <script setup lang="ts">
274
142
  import {
275
143
  useSharedWebSocket,
276
144
  useSocketEvent,
277
- useSocketStream,
278
145
  useSocketSync,
279
- useSocketStatus,
146
+ useSocketLifecycle,
147
+ useChannel,
148
+ useTopics,
149
+ usePush,
280
150
  } from '@gwakko/shared-websocket/vue';
281
151
 
282
152
  const ws = useSharedWebSocket();
283
-
284
153
  const order = useSocketEvent<Order>('order.created');
285
- const messages = useSocketStream<Message>('chat.message');
286
154
  const cart = useSocketSync('cart', { items: [] });
287
- const { connected, tabRole } = useSocketStatus();
288
-
289
- function addToCart() {
290
- cart.value = { items: [...cart.value.items, Date.now()] };
291
- }
292
- </script>
293
-
294
- <template>
295
- <p>Status: {{ connected ? 'Online' : 'Offline' }} ({{ tabRole }})</p>
296
- <p v-if="order">Latest order: #{{ order.id }}</p>
297
- <button @click="ws.send('ping', {})">Ping</button>
298
- <button @click="addToCart">Add to cart ({{ cart.items.length }})</button>
299
- </template>
300
- ```
301
-
302
- ## API Reference
303
-
304
- ### SharedWebSocket
305
-
306
- | Method | Description |
307
- |--------|-------------|
308
- | `connect()` | Start leader election and connect |
309
- | `on(event, handler)` | Subscribe to server events (all tabs) |
310
- | `once(event, handler)` | Subscribe once |
311
- | `off(event, handler?)` | Unsubscribe |
312
- | `stream(event, signal?)` | AsyncGenerator for consuming events |
313
- | `send(event, data)` | Send to server (routed through leader) |
314
- | `request(event, data, timeout?)` | Request/response via server |
315
- | `sync(key, value)` | Sync state across tabs |
316
- | `getSync(key)` | Get synced value |
317
- | `onSync(key, fn)` | Listen for sync changes |
318
- | `disconnect()` | Close connection and cleanup |
319
- | `[Symbol.dispose]()` | Cleanup (also called by `disconnect`) |
320
-
321
- ### withSocket()
322
-
323
- | Signature | Description |
324
- |-----------|-------------|
325
- | `withSocket(url, callback)` | Scoped lifecycle, auto-dispose |
326
- | `withSocket(url, options, callback)` | With auth, signal, etc. |
327
-
328
- Callback receives `{ ws, signal }` — destructure what you need. Signal aborts when scope exits.
329
-
330
- ### Options
331
-
332
- | Option | Type | Default | Description |
333
- |--------|------|---------|-------------|
334
- | `protocols` | `string[]` | `[]` | WebSocket subprotocols |
335
- | `reconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
336
- | `reconnectMaxDelay` | `number` | `30000` | Max reconnect backoff (ms) |
337
- | `heartbeatInterval` | `number` | `30000` | Ping interval (ms) |
338
- | `sendBuffer` | `number` | `100` | Max buffered messages during reconnect |
339
- | `auth` | `() => string` | — | Token provider callback (called on each connect) |
340
- | `authToken` | `string` | — | Static token (alternative to `auth` callback) |
341
- | `authParam` | `string` | `"token"` | Query parameter name for token |
342
- | **`useWorker`** | **`boolean`** | **`false`** | **Run WebSocket in Web Worker** |
343
- | `workerUrl` | `string \| URL` | — | Custom worker URL (if useWorker) |
344
- | `electionTimeout` | `number` | `200` | Leader election timeout (ms) |
345
- | `leaderHeartbeat` | `number` | `2000` | Leader heartbeat interval (ms) |
346
- | `leaderTimeout` | `number` | `5000` | Leader absence timeout (ms) |
347
- | `serialize` | `(data) => string \| ArrayBuffer` | `JSON.stringify` | Custom outgoing serializer |
348
- | `deserialize` | `(raw) => unknown` | `JSON.parse` | Custom incoming deserializer |
349
-
350
- ### Authentication
351
-
352
- Three ways to pass a token. Token is appended as a query parameter (default `?token=xxx`):
353
-
354
- ```typescript
355
- // 1. Callback — fresh token on every connect/reconnect
356
- { auth: () => localStorage.getItem('token')! }
357
- // → wss://api.example.com/ws?token=eyJhb...
358
-
359
- // 2. Static token — simple, no callback
360
- { authToken: 'eyJhbGciOiJIUzI1NiIs...' }
361
- // → wss://api.example.com/ws?token=eyJhb...
362
-
363
- // 3. Custom parameter name
364
- { auth: () => getToken(), authParam: 'access_token' }
365
- // → wss://api.example.com/ws?access_token=eyJhb...
366
-
367
- // 4. No auth
368
- {} // connects without token
369
- ```
370
-
371
- Priority: `auth` callback > `authToken` static > no token.
372
- Default parameter name: `"token"`. Override with `authParam`.
373
-
374
- URL with existing query parameters is safe — token is appended without breaking anything (uses `URL` + `searchParams.set()`):
375
- ```typescript
376
- // URL already has params — works fine
377
- new SharedWebSocket('wss://api.example.com/ws?room=general&lang=en', {
378
- auth: () => getToken(),
379
- })
380
- // → wss://api.example.com/ws?room=general&lang=en&token=eyJhb...
381
- ```
382
-
383
- ### Properties
384
-
385
- | Property | Type | Description |
386
- |----------|------|-------------|
387
- | `connected` | `boolean` | Connection status |
388
- | `tabRole` | `'leader' \| 'follower'` | Current tab's role |
389
- | `isActive` | `boolean` | Whether this tab is visible/focused |
390
-
391
- ### React Hooks (React 19, `useEffectEvent` for stable refs)
392
-
393
- All hooks use context internally — no need to pass `ws`. Every hook accepts an **optional callback** for custom handling.
394
-
395
- | Hook | Without callback | With callback |
396
- |------|-----------------|---------------|
397
- | `useSharedWebSocket()` | `SharedWebSocket` | — |
398
- | `useSocketEvent<T>(event, cb?)` | Returns `T \| undefined` | `cb(data)` on each event |
399
- | `useSocketStream<T>(event, cb?)` | Returns `T[]` (accumulated) | `cb(data)` — manage your own state |
400
- | `useSocketSync<T>(key, init, cb?)` | Returns `[T, setter]` | `cb(value)` — side effects on sync |
401
- | `useSocketCallback<T>(event, cb)` | — | Fire-and-forget (no state) |
402
- | `useSocketStatus()` | `{ connected, tabRole }` | — |
403
- | `useSocketLifecycle(handlers)` | — | onConnect, onDisconnect, onReconnecting, onLeaderChange, onError |
404
- | `useChannel(name)` | `Channel` handle | Auto-join/leave on mount/unmount |
405
-
406
- ```tsx
407
- // Without callback — reactive state
408
- const order = useSocketEvent<Order>('order.created');
409
155
 
410
- // With callback — custom logic, stable ref
411
- useSocketEvent<Order>('order.created', (order) => {
412
- playSound('new-order');
413
- analytics.track('order_received', order);
414
- });
415
-
416
- // Stream with limit
417
- const [msgs, setMsgs] = useState<Message[]>([]);
418
- useSocketStream<Message>('chat.message', (msg) => {
419
- setMsgs(prev => [msg, ...prev].slice(0, 50));
420
- });
421
-
422
- // Sync with side effect
423
- const [cart, setCart] = useSocketSync('cart', { items: [] }, (cart) => {
424
- document.title = `Cart (${cart.items.length})`;
425
- });
426
-
427
- // Fire-and-forget
428
- useSocketCallback<Notification>('notification', (n) => {
429
- if (ws.tabRole === 'leader') new Notification(n.title);
430
- });
431
- ```
432
-
433
- ### Vue Composables
434
-
435
- All composables accept an **optional callback** — same pattern as React hooks.
436
-
437
- | Composable | Without callback | With callback |
438
- |-----------|-----------------|---------------|
439
- | `useSharedWebSocket()` | `SharedWebSocket` | — |
440
- | `useSocketEvent<T>(event, cb?)` | `Ref<T>` | `cb(data)` on each event |
441
- | `useSocketStream<T>(event, cb?)` | `Ref<T[]>` | `cb(data)` — manage your own ref |
442
- | `useSocketSync<T>(key, init, cb?)` | `Ref<T>` (two-way) | `cb(value)` — side effects on sync |
443
- | `useSocketCallback<T>(event, cb)` | — | Fire-and-forget |
444
- | `useSocketStatus()` | `{ connected, tabRole }` | — |
445
-
446
- ## How It Works
447
-
448
- 1. **Leader Election** — new tab broadcasts election request via BroadcastChannel. If no rejection in 200ms → becomes leader. Leader sends heartbeat every 2s. No heartbeat for 5s → new election.
449
-
450
- 2. **Message Flow** — follower calls `send()` → message goes to BroadcastChannel → leader picks it up → forwards to WebSocket → server response → leader broadcasts to all tabs.
451
-
452
- 3. **Failover** — leader tab closes → `beforeunload` fires `abdicate` → followers detect missing heartbeat → election → new leader connects WebSocket → zero data loss (buffered messages replayed).
453
-
454
- 4. **Resource Safety** — `withSocket()` for scoped lifecycle, `Symbol.dispose` support. All timers, listeners, and channels properly cleaned up.
455
-
456
- 5. **Worker Mode** (optional) — `useWorker: true` runs WebSocket inside a Web Worker. JSON parsing, heartbeat timers, and reconnection logic run off main thread. UI stays responsive even at high message rates.
457
-
458
- ## When to Use `useWorker: true`
459
-
460
- | Scenario | useWorker | Why |
461
- |----------|-----------|-----|
462
- | Chat (10-50 msgs/sec) | `false` | Low overhead, not worth Worker complexity |
463
- | Simple notifications | `false` | Few messages, main thread handles fine |
464
- | Live trading feed (100+ msgs/sec) | **`true`** | JSON parsing 100+ msgs/sec blocks rendering |
465
- | Real-time dashboard (50+ metrics/sec) | **`true`** | Continuous data stream, UI must stay smooth |
466
- | Heavy payload (>100KB per message) | **`true`** | Parsing large JSON blocks main thread |
467
- | Complex UI (React with 10k+ rows) | **`true`** | Main thread already busy, any extra work causes jank |
468
- | Mobile / low-end devices | **`true`** | Less CPU available, offloading helps |
469
- | Simple landing page | `false` | Minimal UI, no rendering pressure |
470
- | SSR / Node.js | `false` | Workers are browser-only |
471
- | Debugging | `false` | Worker DevTools is less convenient |
472
-
473
- **Rule of thumb:** If your app drops frames when WebSocket messages arrive — add `useWorker: true`.
474
-
475
- ```typescript
476
- // Without worker (default) — WebSocket in main thread
477
- const ws = new SharedWebSocket(url);
478
-
479
- // With worker — WebSocket in Web Worker
480
- const ws = new SharedWebSocket(url, { useWorker: true });
481
-
482
- // API is identical — only internal transport changes
483
- ```
484
-
485
- ## Typed Events
486
-
487
- Define your event map for full type safety across on/send/stream:
488
-
489
- ```typescript
490
- type Events = {
491
- 'chat.message': { text: string; userId: string; timestamp: number };
492
- 'chat.typing': { userId: string };
493
- 'order.created': { id: string; total: number; items: string[] };
494
- 'notification': { title: string; body: string; type: 'info' | 'error' };
495
- };
496
-
497
- const ws = new SharedWebSocket<Events>('wss://api.example.com/ws');
498
-
499
- // ✅ Type-safe — msg is { text, userId, timestamp }
500
- ws.on('chat.message', (msg) => {
501
- console.log(msg.text); // string
502
- console.log(msg.userId); // string
503
- });
504
-
505
- // ✅ Type-safe send
506
- ws.send('chat.message', { text: 'hi', userId: '1', timestamp: Date.now() });
507
-
508
- // ❌ TypeScript error — wrong payload type
509
- ws.send('chat.message', { wrong: 'field' });
510
-
511
- // ✅ Type-safe stream
512
- for await (const order of ws.stream('order.created')) {
513
- console.log(order.id); // string
514
- console.log(order.total); // number
515
- }
516
-
517
- // Still works with untyped events
518
- ws.on('any.custom.event', (data) => { /* data: any */ });
519
- ```
520
-
521
- ```tsx
522
- // React — pass type to hooks
523
- const msg = useSocketEvent<Events['chat.message']>('chat.message');
524
- // msg: { text, userId, timestamp } | undefined
525
- ```
526
-
527
- ### Type narrowing for untyped events
528
-
529
- When working without EventMap, data is `unknown`. Use narrowing:
530
-
531
- ```typescript
532
- // Type guard
533
- function isChatMessage(data: unknown): data is { text: string; userId: string } {
534
- return typeof data === 'object' && data !== null && 'text' in data && 'userId' in data;
535
- }
536
-
537
- // Vanilla
538
- ws.on('chat.message', (data) => {
539
- if (isChatMessage(data)) {
540
- console.log(data.text); // ← now typed as string
541
- }
542
- });
543
- ```
544
-
545
- ```tsx
546
- // React
547
- useSocketEvent('chat.message', (data) => {
548
- if (isChatMessage(data)) renderMessage(data);
549
- });
550
- ```
551
-
552
- ```vue
553
- <!-- Vue -->
554
- <script setup>
555
- useSocketEvent('chat.message', (data) => {
556
- if (isChatMessage(data)) renderMessage(data);
557
- });
558
- </script>
559
- ```
560
-
561
- ### Runtime validation with Zod
562
-
563
- ```typescript
564
- import { z } from 'zod';
565
-
566
- const ChatMessageSchema = z.object({
567
- text: z.string(),
568
- userId: z.string(),
569
- timestamp: z.number(),
570
- });
571
-
572
- type ChatMessage = z.infer<typeof ChatMessageSchema>;
573
-
574
- // Validate on receive — drop invalid messages via middleware
575
- ws.use('incoming', (raw) => {
576
- const msg = raw as Record<string, unknown>;
577
- const data = msg?.data;
578
- const result = ChatMessageSchema.safeParse(data);
579
- if (!result.success) {
580
- console.warn('Invalid message:', result.error.issues);
581
- return null; // drop
582
- }
583
- return raw; // pass through
584
- });
585
-
586
- // Or validate in handler
587
- ws.on('chat.message', (data) => {
588
- const result = ChatMessageSchema.safeParse(data);
589
- if (!result.success) return;
590
-
591
- const msg: ChatMessage = result.data;
592
- console.log(msg.text); // fully typed and validated
156
+ useSocketLifecycle({
157
+ onConnect: () => toast.success('Connected'),
158
+ onActive: () => refreshData(),
593
159
  });
594
160
 
595
- // Zod middleware factory (reusable)
596
- function zodValidate<T>(schema: z.ZodType<T>): Middleware {
597
- return (raw) => {
598
- const msg = raw as Record<string, unknown>;
599
- const result = schema.safeParse(msg?.data ?? msg);
600
- return result.success ? raw : null;
601
- };
602
- }
603
-
604
- ws.use('incoming', zodValidate(ChatMessageSchema));
605
- ws.use('incoming', zodValidate(OrderSchema));
606
- ```
607
-
608
- ```tsx
609
- // React — Zod validated hook
610
- function useSafeSocketEvent<T>(event: string, schema: z.ZodType<T>): T | undefined {
611
- const [value, setValue] = useState<T>();
612
-
613
- useSocketEvent(event, (data) => {
614
- const result = schema.safeParse(data);
615
- if (result.success) setValue(result.data);
616
- });
617
-
618
- return value;
619
- }
620
-
621
- // Usage
622
- const msg = useSafeSocketEvent('chat.message', ChatMessageSchema);
623
- // msg: ChatMessage | undefined — guaranteed valid
624
- ```
625
-
626
- ```vue
627
- <!-- Vue — Zod validated composable -->
628
- <script setup lang="ts">
629
- import { z } from 'zod';
161
+ const chat = useChannel(`chat:${roomId}`);
162
+ useTopics(['notifications:orders']);
630
163
 
631
- const ChatMessageSchema = z.object({
632
- text: z.string(),
633
- userId: z.string(),
164
+ usePush('notification', {
165
+ render: (n) => toast(n.title),
634
166
  });
635
-
636
- // Composable with validation
637
- function useSafeSocketEvent<T>(event: string, schema: z.ZodType<T>) {
638
- const value = ref<T>();
639
- useSocketEvent(event, (data) => {
640
- const result = schema.safeParse(data);
641
- if (result.success) value.value = result.data as T;
642
- });
643
- return readonly(value);
644
- }
645
-
646
- const msg = useSafeSocketEvent('chat.message', ChatMessageSchema);
647
- // msg.value: ChatMessage | undefined — guaranteed valid
648
167
  </script>
649
168
  ```
650
169
 
651
- ## Middleware
652
-
653
- Transform or inspect messages before send / after receive:
654
-
655
- ```typescript
656
- const ws = new SharedWebSocket(url);
657
-
658
- // Add timestamp to every outgoing message
659
- ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
660
-
661
- // Decrypt incoming messages
662
- ws.use('incoming', (msg) => ({ ...msg, data: decrypt(msg.data) }));
663
-
664
- // Drop messages from blocked users (return null to drop)
665
- ws.use('incoming', (msg) => blockedUsers.has(msg.userId) ? null : msg);
666
-
667
- // Log everything
668
- ws.use('incoming', (msg) => { console.log('← recv', msg); return msg; });
669
- ws.use('outgoing', (msg) => { console.log('→ send', msg); return msg; });
670
-
671
- // Chain multiple — executed in order
672
- ws.use('outgoing', addTimestamp)
673
- .use('outgoing', addRequestId)
674
- .use('incoming', decryptPayload)
675
- .use('incoming', validateSchema);
676
- ```
677
-
678
- ```tsx
679
- // React — configure middleware in Provider
680
- function App() {
681
- const wsRef = useRef<SharedWebSocket>();
682
-
683
- return (
684
- <SharedWebSocketProvider
685
- url="wss://api.example.com/ws"
686
- options={{ debug: true }}
687
- ref={(provider) => {
688
- // Access ws instance after mount to add middleware
689
- }}
690
- >
691
- <SetupMiddleware />
692
- <Dashboard />
693
- </SharedWebSocketProvider>
694
- );
695
- }
170
+ ## Features
696
171
 
697
- // Or setup middleware in a component
698
- function SetupMiddleware() {
699
- const ws = useSharedWebSocket();
172
+ | Feature | Description |
173
+ |---------|-------------|
174
+ | **Leader Election** | One tab holds WebSocket, others receive via BroadcastChannel |
175
+ | **Auto Failover** | Leader closes → new election → reconnect in ~5s |
176
+ | **Typed Events** | `SharedWebSocket<EventMap>` — type-safe on/send/stream |
177
+ | **Channels** | `ws.channel('room')` — scoped events, auto join/leave |
178
+ | **Topics** | `ws.subscribe('topic')` — server-side filtered subscriptions |
179
+ | **Tab Sync** | `ws.sync(key, value)` — state across tabs, no server |
180
+ | **Push Notifications** | `ws.push()` — render (sonner) + browser Notification API |
181
+ | **Middleware** | `ws.use('incoming'/'outgoing', fn)` — transform, filter, log |
182
+ | **Worker Mode** | `useWorker: true` — WebSocket off main thread |
183
+ | **Custom Serialization** | `serialize`/`deserialize` — JSON, MessagePack, Protobuf |
184
+ | **Per-Event Serializers** | `ws.serializer(event, fn)` — binary for specific events |
185
+ | **Lifecycle Hooks** | onConnect, onDisconnect, onActive, onInactive, onLeaderChange |
186
+ | **Debug/Logger** | `debug: true` + injectable logger (pino, Sentry) |
187
+ | **Event Protocol** | Configurable field names (Socket.IO, Phoenix, Laravel Echo) |
188
+ | **Auth** | `auth` callback, `authToken` string, custom `authParam` |
189
+ | **Zero Dependencies** | Pure browser APIs |
700
190
 
701
- useEffect(() => {
702
- ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
703
- ws.use('incoming', zodValidate(MessageSchema));
704
- }, [ws]);
191
+ ## Processing Pipeline
705
192
 
706
- return null;
707
- }
708
193
  ```
194
+ Outgoing: ws.send(event, data)
195
+ → per-event serializer (if registered)
196
+ → outgoing middleware (transform/inspect/drop)
197
+ → global serialize (JSON/msgpack — configurable)
198
+ → WebSocket.send() (or Worker → WebSocket.send)
709
199
 
710
- ```vue
711
- <!-- Vueconfigure middleware after plugin install -->
712
- <script setup>
713
- // In any component
714
- const ws = useSharedWebSocket();
715
- ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
716
- ws.use('incoming', zodValidate(MessageSchema));
717
- </script>
200
+ Incoming: WebSocket.onmessage
201
+ global deserialize (JSON/msgpack configurable)
202
+ incoming middleware (transform/inspect/drop)
203
+ per-event deserializer (if registered)
204
+ emit to handlers (all tabs via BroadcastChannel)
718
205
  ```
719
206
 
720
- ## Debug Mode & Custom Logger
721
-
722
- ```typescript
723
- // Debug mode — logs all events to console
724
- new SharedWebSocket(url, { debug: true });
725
- // [SharedWS] init { tabId: "abc-123", url: "wss://..." }
726
- // [SharedWS] 👑 became leader
727
- // [SharedWS] ✓ connected
728
- // [SharedWS] → send chat.message { text: "hi" }
729
- // [SharedWS] ← recv chat.message { text: "hello" }
730
- // [SharedWS] 🔄 reconnecting
731
-
732
- // Custom logger (pino, winston, bunyan, etc.)
733
- import pino from 'pino';
734
- new SharedWebSocket(url, {
735
- debug: true,
736
- logger: pino({ name: 'ws' }),
737
- });
738
-
739
- // Sentry integration — errors + breadcrumbs
740
- import * as Sentry from '@sentry/browser';
741
- new SharedWebSocket(url, {
742
- debug: true,
743
- logger: {
744
- debug: (msg, ...args) => Sentry.addBreadcrumb({
745
- category: 'websocket',
746
- message: msg,
747
- data: args[0] as Record<string, unknown>,
748
- level: 'debug',
749
- }),
750
- info: (msg, ...args) => Sentry.addBreadcrumb({
751
- category: 'websocket',
752
- message: msg,
753
- level: 'info',
754
- }),
755
- warn: (msg, ...args) => Sentry.addBreadcrumb({
756
- category: 'websocket',
757
- message: msg,
758
- level: 'warning',
759
- }),
760
- error: (msg, ...args) => {
761
- Sentry.captureException(args[0] instanceof Error ? args[0] : new Error(msg));
762
- },
763
- },
764
- });
765
-
766
- // Logger interface — implement debug/info/warn/error
767
- import type { Logger } from '@gwakko/shared-websocket';
768
- const myLogger: Logger = { debug() {}, info() {}, warn() {}, error() {} };
769
- ```
207
+ ## Options
770
208
 
771
- ```tsx
772
- // React — debug + Sentry in Provider
773
- <SharedWebSocketProvider
774
- url="wss://api.example.com/ws"
775
- options={{
776
- debug: process.env.NODE_ENV === 'development',
777
- logger: sentryLogger, // your Sentry logger object
778
- }}
779
- >
780
- <App />
781
- </SharedWebSocketProvider>
782
- ```
783
-
784
- ```vue
785
- <!-- Vuedebug + Sentry in plugin -->
786
- <script>
787
- // main.ts
788
- app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
789
- debug: import.meta.env.DEV,
790
- logger: sentryLogger,
791
- }));
792
- </script>
793
- ```
794
-
795
- ## Custom Serialization
796
-
797
- By default all messages are serialized/deserialized as JSON. Override for binary formats:
798
-
799
- ```typescript
800
- // Default — JSON (no config needed)
801
- new SharedWebSocket(url);
802
- // send: JSON.stringify(data) → string
803
- // recv: JSON.parse(raw) → object
804
- ```
805
-
806
- ```typescript
807
- // MessagePack — compact binary format
808
- import { encode, decode } from '@msgpack/msgpack';
809
-
810
- new SharedWebSocket(url, {
811
- serialize: (data) => encode(data), // returns ArrayBuffer
812
- deserialize: (raw) => decode(raw), // accepts ArrayBuffer
813
- });
814
- ```
815
-
816
- ```typescript
817
- // Protobuf
818
- import { MyMessage } from './proto/messages';
819
-
820
- new SharedWebSocket(url, {
821
- serialize: (data) => MyMessage.encode(data as MyMessage).finish(),
822
- deserialize: (raw) => MyMessage.decode(new Uint8Array(raw as ArrayBuffer)),
823
- });
824
- ```
825
-
826
- ```typescript
827
- // CBOR
828
- import { encode, decode } from 'cbor-x';
829
-
830
- new SharedWebSocket(url, {
831
- serialize: (data) => encode(data),
832
- deserialize: (raw) => decode(raw as ArrayBuffer),
833
- });
834
- ```
835
-
836
- ```typescript
837
- // Plain text (no serialization)
838
- new SharedWebSocket(url, {
839
- serialize: (data) => String(data),
840
- deserialize: (raw) => raw, // pass through as-is
841
- });
842
- ```
843
-
844
- ```tsx
845
- // React — pass in Provider options
846
- <SharedWebSocketProvider
847
- url="wss://api.example.com/ws"
848
- options={{
849
- serialize: (data) => encode(data),
850
- deserialize: (raw) => decode(raw),
851
- }}
852
- >
853
- <App />
854
- </SharedWebSocketProvider>
855
- ```
856
-
857
- ```vue
858
- <!-- Vue — pass in plugin options -->
859
- <script>
860
- import { encode, decode } from '@msgpack/msgpack';
861
-
862
- app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
863
- serialize: (data) => encode(data),
864
- deserialize: (raw) => decode(raw),
865
- }));
866
- </script>
867
- ```
868
-
869
- Serialization applies to **all** WebSocket I/O: send, receive, heartbeat, and buffer flush.
870
-
871
- | Option | Default | Type | Description |
872
- |--------|---------|------|-------------|
873
- | `serialize` | `JSON.stringify` | `(data) => string \| ArrayBuffer \| Blob` | Outgoing message encoding |
874
- | `deserialize` | `JSON.parse` | `(raw) => unknown` | Incoming message decoding |
875
-
876
- > **Note:** `serialize`/`deserialize` options apply to the main-thread `SharedSocket`. For Worker mode, use a custom worker file (see below).
877
-
878
- ### Custom Worker with Binary Serialization
879
-
880
- Functions can't be passed to Workers via `postMessage`. To use custom serialization in Worker mode, copy the template worker and edit the `serialize`/`deserialize` functions:
881
-
882
- **1. Copy the template:**
883
- ```bash
884
- cp node_modules/@gwakko/shared-websocket/src/worker/socket.worker.template.txt ./src/workers/my-socket.worker.ts
885
- ```
886
-
887
- **2. Edit `serialize`/`deserialize` at the top:**
888
- ```typescript
889
- // my-socket.worker.ts
890
- import { encode, decode } from '@msgpack/msgpack';
891
-
892
- function serialize(data: unknown): string | ArrayBuffer {
893
- return encode(data); // ← your format
894
- }
895
-
896
- function deserialize(raw: string | ArrayBuffer): unknown {
897
- return decode(raw as ArrayBuffer); // ← your format
898
- }
899
-
900
- // ... rest of worker code unchanged
901
- ```
902
-
903
- **3. Use your worker:**
904
- ```typescript
905
- // Vite
906
- new SharedWebSocket(url, {
907
- useWorker: true,
908
- workerUrl: new URL('./workers/my-socket.worker.ts', import.meta.url),
909
- });
910
-
911
- // Webpack 5
912
- new SharedWebSocket(url, {
913
- useWorker: true,
914
- workerUrl: new URL('./workers/my-socket.worker.ts', import.meta.url),
915
- });
916
-
917
- // Static file (pre-built)
918
- new SharedWebSocket(url, {
919
- useWorker: true,
920
- workerUrl: '/workers/my-socket.worker.js',
921
- });
922
- ```
923
-
924
- The template file is at `src/worker/socket.worker.template.txt` — fully commented with MessagePack, Protobuf, and CBOR examples.
925
-
926
- ## Custom Event Protocol
927
-
928
- Override event/field names when your server uses different conventions.
929
-
930
- ```typescript
931
- // Default: { event: 'chat.message', data: { text: 'hi' } }
932
- new SharedWebSocket(url);
933
-
934
- // Socket.IO style: { type: 'chat.message', payload: { text: 'hi' } }
935
- new SharedWebSocket(url, {
936
- events: {
937
- eventField: 'type', // message field for event name
938
- dataField: 'payload', // message field for payload
939
- },
940
- });
941
-
942
- // Phoenix/Elixir style: join/leave events + custom ping
943
- new SharedWebSocket(url, {
944
- events: {
945
- channelJoin: 'phx_join',
946
- channelLeave: 'phx_leave',
947
- ping: { event: 'heartbeat', payload: {} },
948
- },
949
- });
950
-
951
- // Laravel Echo / Pusher style
952
- new SharedWebSocket(url, {
953
- events: {
954
- eventField: 'event',
955
- dataField: 'data',
956
- channelJoin: 'pusher:subscribe',
957
- channelLeave: 'pusher:unsubscribe',
958
- ping: { event: 'pusher:ping', data: {} },
959
- },
960
- });
961
-
962
- // Action Cable (Rails) style
963
- new SharedWebSocket(url, {
964
- events: {
965
- eventField: 'type',
966
- dataField: 'message',
967
- channelJoin: 'subscribe',
968
- channelLeave: 'unsubscribe',
969
- ping: { type: 'ping' },
970
- defaultEvent: 'message',
971
- },
972
- });
973
- ```
974
-
975
- All fields in `events` are optional — override only what differs from defaults.
976
-
977
- | Field | Default | Description |
978
- |-------|---------|-------------|
979
- | `eventField` | `"event"` | Message field name for event type |
980
- | `dataField` | `"data"` | Message field name for payload |
981
- | `channelJoin` | `"$channel:join"` | Event sent when joining a channel |
982
- | `channelLeave` | `"$channel:leave"` | Event sent when leaving a channel |
983
- | `ping` | `{ type: "ping" }` | Heartbeat payload |
984
- | `defaultEvent` | `"message"` | Fallback event when message has no event field |
985
-
986
- ## Advanced Examples
987
-
988
- ### Stream — consume events as async iterator
989
-
990
- ```typescript
991
- // Vanilla
992
- await withSocket(url, async ({ ws, signal }) => {
993
- for await (const tick of ws.stream('trading.tick', signal)) {
994
- updateChart(tick); // yields one event at a time
995
- }
996
- // auto-cleanup: unsubscribes when signal aborts or loop breaks
997
- });
998
- ```
999
-
1000
- ```tsx
1001
- // React — stream into state with limit
1002
- const [logs, setLogs] = useState<LogEntry[]>([]);
1003
- useSocketStream<LogEntry>('server.log', (entry) => {
1004
- setLogs(prev => [...prev, entry].slice(-500));
1005
- });
1006
- ```
1007
-
1008
- ```vue
1009
- <!-- Vue — stream into ref -->
1010
- <script setup>
1011
- const logs = ref<LogEntry[]>([]);
1012
- useSocketStream<LogEntry>('server.log', (entry) => {
1013
- logs.value = [...logs.value, entry].slice(-500);
1014
- });
1015
- </script>
1016
- ```
1017
-
1018
- ### Request — request/response through server
1019
-
1020
- ```typescript
1021
- // Vanilla — request user profile via server
1022
- await withSocket(url, async ({ ws }) => {
1023
- const user = await ws.request<User>('user.profile', { id: 123 }, 5000);
1024
- console.log(user.name); // response from server, 5s timeout
1025
- });
1026
- ```
1027
-
1028
- ```tsx
1029
- // React
1030
- function UserProfile({ userId }: { userId: string }) {
1031
- const ws = useSharedWebSocket();
1032
- const [user, setUser] = useState<User | null>(null);
1033
-
1034
- useEffect(() => {
1035
- ws.request<User>('user.profile', { id: userId }).then(setUser);
1036
- }, [userId]);
1037
-
1038
- return user ? <div>{user.name}</div> : <div>Loading...</div>;
1039
- }
1040
- ```
1041
-
1042
- ### Protocols — WebSocket subprotocols
1043
-
1044
- ```typescript
1045
- // Pass subprotocols for server-side protocol negotiation
1046
- new SharedWebSocket('wss://api.example.com/ws', {
1047
- protocols: ['graphql-ws', 'graphql-transport-ws'],
1048
- });
1049
-
1050
- // Common protocols:
1051
- // 'graphql-ws' — GraphQL over WebSocket
1052
- // 'mqtt' — MQTT over WebSocket
1053
- // 'wamp.2.json' — WAMP v2
1054
- ```
1055
-
1056
- ### Worker URL — custom worker file
1057
-
1058
- ```typescript
1059
- // Default: inline blob worker (no extra files needed)
1060
- new SharedWebSocket(url, { useWorker: true });
1061
-
1062
- // Custom worker file (for CSP restrictions or custom logic):
1063
- new SharedWebSocket(url, {
1064
- useWorker: true,
1065
- workerUrl: '/workers/socket.worker.js', // your own worker file
1066
- });
1067
-
1068
- // Or as URL object:
1069
- new SharedWebSocket(url, {
1070
- useWorker: true,
1071
- workerUrl: new URL('./socket.worker.ts', import.meta.url), // Vite handles this
1072
- });
1073
- ```
1074
-
1075
- ### Lifecycle Hooks
1076
-
1077
- ```typescript
1078
- // Vanilla
1079
- await withSocket(url, async ({ ws }) => {
1080
- // Connection lifecycle
1081
- ws.onConnect(() => console.log('Connected!'));
1082
- ws.onDisconnect(() => showOfflineBanner());
1083
- ws.onReconnecting(() => showSpinner());
1084
- ws.onError((err) => reportToSentry(err));
1085
-
1086
- // Tab role
1087
- ws.onLeaderChange((isLeader) => console.log('Leader:', isLeader));
1088
-
1089
- // Tab visibility
1090
- ws.onActive(() => {
1091
- console.log('Tab is now active');
1092
- markNotificationsAsRead();
1093
- });
1094
- ws.onInactive(() => {
1095
- console.log('Tab went to background');
1096
- pauseAnimations();
1097
- });
1098
- ws.onVisibilityChange((isActive) => {
1099
- console.log('Visibility:', isActive ? 'visible' : 'hidden');
1100
- });
1101
-
1102
- // Check current state
1103
- console.log('Is active:', ws.isActive);
1104
- console.log('Tab role:', ws.tabRole);
1105
- });
1106
- ```
1107
-
1108
- ```tsx
1109
- // React
1110
- useSocketLifecycle({
1111
- onConnect: () => toast.success('Connected'),
1112
- onDisconnect: () => toast.error('Connection lost'),
1113
- onReconnecting: () => toast.loading('Reconnecting...'),
1114
- onLeaderChange: (isLeader) => {
1115
- if (isLeader) console.log('This tab is now the leader');
1116
- },
1117
- onError: (err) => Sentry.captureException(err),
1118
-
1119
- // Tab visibility
1120
- onActive: () => {
1121
- markNotificationsAsRead();
1122
- refreshData();
1123
- },
1124
- onInactive: () => {
1125
- pausePolling();
1126
- },
1127
- });
1128
- ```
1129
-
1130
- ```vue
1131
- <!-- Vue -->
1132
- <script setup>
1133
- useSocketLifecycle({
1134
- onConnect: () => toast.success('Connected'),
1135
- onDisconnect: () => toast.error('Connection lost'),
1136
- onReconnecting: () => toast.loading('Reconnecting...'),
1137
- onError: (err) => reportError(err),
1138
- onActive: () => refreshData(),
1139
- onInactive: () => pausePolling(),
1140
- });
1141
- </script>
1142
- ```
1143
-
1144
- Available lifecycle hooks:
1145
-
1146
- | Hook | When | Use case |
1147
- |------|------|----------|
1148
- | `onConnect` | WebSocket connected | Hide offline banner, sync state |
1149
- | `onDisconnect` | WebSocket closed | Show offline banner |
1150
- | `onReconnecting` | Reconnecting started | Show spinner |
1151
- | `onError` | Error occurred | Report to Sentry |
1152
- | `onLeaderChange` | Tab became/lost leader | Log, adjust behavior |
1153
- | `onActive` | Tab became visible | Mark read, refresh data, resume |
1154
- | `onInactive` | Tab went to background | Pause polling, animations |
1155
- | `onVisibilityChange` | Any visibility change | Generic handler |
1156
-
1157
- ### Private Channels — chat rooms, tenant notifications
1158
-
1159
- The `channel()` method creates a scoped handle. Events are prefixed with the channel name. Server receives `$channel:join` / `$channel:leave` events.
1160
-
1161
- ```typescript
1162
- // Vanilla — private chat room
1163
- await withSocket(url, { auth: () => getToken() }, async ({ ws }) => {
1164
- const chat = ws.channel('chat:room_42');
1165
-
1166
- chat.on('message', (msg) => renderMessage(msg));
1167
- chat.on('typing', (user) => showTyping(user));
1168
- chat.send('message', { text: 'Hello room!' });
1169
-
1170
- // When done:
1171
- chat.leave(); // sends $channel:leave to server, unsubscribes all
1172
- });
1173
-
1174
- // Tenant-scoped notifications
1175
- await withSocket(url, { auth: () => getToken() }, async ({ ws }) => {
1176
- const notifs = ws.channel(`tenant:${tenantId}:notifications`);
1177
- notifs.on('alert', (alert) => showToast(alert));
1178
- notifs.on('update', (update) => refreshDashboard(update));
1179
-
1180
- // User's private channel
1181
- const user = ws.channel(`user:${userId}`);
1182
- user.on('message', (dm) => showDirectMessage(dm));
1183
- user.on('mention', (mention) => highlightMention(mention));
1184
- });
1185
- ```
1186
-
1187
- ```tsx
1188
- // React — auto join/leave on mount/unmount
1189
- function ChatRoom({ roomId }: { roomId: string }) {
1190
- const chat = useChannel(`chat:${roomId}`);
1191
-
1192
- // Events are prefixed: 'chat:room_42:message'
1193
- const message = useSocketEvent<Message>(`chat:${roomId}:message`);
1194
- const typing = useSocketEvent<User>(`chat:${roomId}:typing`);
1195
-
1196
- function send(text: string) {
1197
- chat.send('message', { text });
1198
- }
1199
-
1200
- return (/* ... */);
1201
- }
1202
- // When ChatRoom unmounts → chat.leave() called automatically
1203
-
1204
- // Tenant notifications
1205
- function TenantAlerts({ tenantId }: { tenantId: string }) {
1206
- const channel = useChannel(`tenant:${tenantId}:notifications`);
1207
-
1208
- useSocketCallback(`tenant:${tenantId}:notifications:alert`, (alert) => {
1209
- showToast(alert);
1210
- });
1211
-
1212
- return null;
1213
- }
1214
- ```
1215
-
1216
- ```vue
1217
- <!-- Vue — private channel -->
1218
- <script setup>
1219
- const props = defineProps<{ roomId: string }>();
1220
-
1221
- const chat = useChannel(`chat:${props.roomId}`);
1222
- const message = useSocketEvent<Message>(`chat:${props.roomId}:message`);
1223
-
1224
- function send(text: string) {
1225
- chat.send('message', { text });
1226
- }
1227
- // Auto-leave on unmount
1228
- </script>
1229
- ```
1230
-
1231
- ### Server-side channel handling
1232
-
1233
- ```typescript
1234
- // Node.js — handle channel join/leave
1235
- wss.on('connection', (ws) => {
1236
- const channels = new Set<string>();
1237
-
1238
- ws.on('message', (raw) => {
1239
- const msg = JSON.parse(raw.toString());
1240
-
1241
- if (msg.event === '$channel:join') {
1242
- channels.add(msg.data.channel);
1243
- console.log(`Client joined ${msg.data.channel}`);
1244
- return;
1245
- }
1246
-
1247
- if (msg.event === '$channel:leave') {
1248
- channels.delete(msg.data.channel);
1249
- return;
1250
- }
1251
-
1252
- // Route channel messages
1253
- // msg.event = 'chat:room_42:message'
1254
- // Extract channel: 'chat:room_42'
1255
- });
1256
- });
1257
- ```
1258
-
1259
- ## Topics — Server-Side Filtered Subscriptions
1260
-
1261
- Subscribe to specific topics so the server only sends relevant events:
1262
-
1263
- ```typescript
1264
- // Vanilla
1265
- ws.subscribe('notifications:orders');
1266
- ws.subscribe('notifications:payments');
1267
- ws.subscribe(`user:${userId}:mentions`);
1268
-
1269
- // Later — unsubscribe
1270
- ws.unsubscribe('notifications:orders');
1271
- ```
1272
-
1273
- ```tsx
1274
- // React — auto-subscribe on mount, unsubscribe on unmount
1275
- function OrdersDashboard() {
1276
- useTopics(['notifications:orders', 'notifications:payments']);
1277
-
1278
- const order = useSocketEvent('notifications:orders:new');
1279
- return order ? <div>New order #{order.id}</div> : null;
1280
- }
1281
-
1282
- // Dynamic topics
1283
- function UserMentions({ userId }: { userId: string }) {
1284
- useTopics([`user:${userId}:mentions`]);
1285
- useSocketCallback(`user:${userId}:mentions:mention`, showMentionToast);
1286
- return null;
1287
- }
1288
- ```
1289
-
1290
- ```vue
1291
- <!-- Vue — same pattern -->
1292
- <script setup>
1293
- const props = defineProps<{ userId: string }>();
1294
- useTopics([`user:${props.userId}:mentions`]);
1295
- useSocketEvent(`user:${props.userId}:mentions:mention`, showToast);
1296
- </script>
1297
- ```
1298
-
1299
- Server receives `$topic:subscribe` / `$topic:unsubscribe` events (configurable via `events.topicSubscribe`).
1300
-
1301
- ## Push Notifications
1302
-
1303
- Two modes: **custom render** (sonner, react-hot-toast, your UI) and/or **browser Notification API**.
1304
-
1305
- `target` controls which tab(s) show the notification:
1306
-
1307
- | Target | Behavior | Default for |
1308
- |--------|----------|-------------|
1309
- | `'active'` | Only the currently visible/focused tab | render (toasts) |
1310
- | `'leader'` | Only the leader tab | browser Notification |
1311
- | `'all'` | Every tab (critical alerts) | — |
1312
-
1313
- ### Custom Render — you control the display
1314
-
1315
- ```typescript
1316
- // Vanilla — sonner toast (default: target 'active' — visible tab only)
1317
- import { toast } from 'sonner';
1318
-
1319
- ws.push('notification', {
1320
- render: (n) => toast(n.title, { description: n.body }),
1321
- // target: 'active' — implicit default
1322
- });
1323
-
1324
- ws.push('order.created', {
1325
- render: (order) => toast.success(`New Order #${order.id}`, {
1326
- description: `$${order.total} from ${order.customer}`,
1327
- action: { label: 'View', onClick: () => navigate(`/orders/${order.id}`) },
1328
- }),
1329
- });
1330
- ```
1331
-
1332
- ```tsx
1333
- // React — sonner
1334
- import { toast } from 'sonner';
1335
-
1336
- function NotificationSetup() {
1337
- usePush('notification', {
1338
- render: (n) => toast(n.title, { description: n.body }),
1339
- });
1340
-
1341
- usePush('order.created', {
1342
- render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
1343
- });
1344
-
1345
- return null;
1346
- }
1347
-
1348
- // React — react-hot-toast
1349
- import hotToast from 'react-hot-toast';
1350
-
1351
- function NotificationSetup() {
1352
- usePush('notification', {
1353
- render: (n) => hotToast(n.title),
1354
- });
1355
- return null;
1356
- }
1357
- ```
1358
-
1359
- ```vue
1360
- <!-- Vue — sonner-vue -->
1361
- <script setup>
1362
- import { toast } from 'sonner-vue';
1363
-
1364
- usePush('notification', {
1365
- render: (n) => toast(n.title, { description: n.body }),
1366
- });
1367
-
1368
- usePush('order.created', {
1369
- render: (order) => toast.success(`Order #${order.id} — $${order.total}`),
1370
- });
1371
- </script>
1372
- ```
1373
-
1374
- ### Browser Notification API — native OS notifications
1375
-
1376
- ```typescript
1377
- // Vanilla — browser native (default: target 'leader' — one notification, not N)
1378
- ws.push('notification', {
1379
- title: (n) => n.title,
1380
- body: (n) => n.body,
1381
- icon: '/icons/bell.png',
1382
- tag: (n) => `notif-${n.id}`, // deduplication
1383
- onClick: (n) => window.open(n.url),
1384
- // target: 'leader' — implicit default for native notifications
1385
- });
1386
- ```
1387
-
1388
- ```tsx
1389
- // React
1390
- usePush('order.created', {
1391
- title: (order) => `New Order #${order.id}`,
1392
- body: (order) => `$${order.total}`,
1393
- icon: '/icons/order.png',
1394
- onClick: (order) => navigate(`/orders/${order.id}`),
1395
- });
1396
- ```
1397
-
1398
- ```vue
1399
- <!-- Vue -->
1400
- <script setup>
1401
- usePush('order.created', {
1402
- title: (order) => `New Order #${order.id}`,
1403
- body: (order) => `$${order.total}`,
1404
- });
1405
- </script>
1406
- ```
1407
-
1408
- ### Critical alerts — show in ALL tabs
1409
-
1410
- ```typescript
1411
- // Vanilla — payment failed: show toast in EVERY tab
1412
- ws.push('payment.failed', {
1413
- render: (err) => toast.error(`Payment failed: ${err.message}`),
1414
- target: 'all', // every tab sees it
1415
- });
1416
- ```
1417
-
1418
- ```tsx
1419
- // React
1420
- usePush('payment.failed', {
1421
- render: (err) => toast.error(`Payment failed: ${err.message}`),
1422
- target: 'all',
1423
- });
1424
- ```
1425
-
1426
- ```vue
1427
- <!-- Vue -->
1428
- <script setup>
1429
- usePush('payment.failed', {
1430
- render: (err) => toast.error(`Payment failed: ${err.message}`),
1431
- target: 'all',
1432
- });
1433
- </script>
1434
- ```
1435
-
1436
- ### Both — toast in UI + browser notification
1437
-
1438
- ```typescript
1439
- // Active tab gets sonner toast, leader sends native notification
1440
- ws.push('order.created', {
1441
- render: (order) => toast.success(`Order #${order.id}`), // in-app toast
1442
- title: (order) => `New Order #${order.id}`, // + native notification
1443
- body: (order) => `$${order.total}`,
1444
- });
1445
- ```
1446
-
1447
- ## Server-Side Implementation Guide
1448
-
1449
- Complete server reference — what events to listen for and how to respond.
1450
-
1451
- ### Message Format
1452
-
1453
- All messages are JSON with two fields (configurable via `events` option):
1454
-
1455
- ```
1456
- Client → Server: { "event": "event.name", "data": { ... } }
1457
- Server → Client: { "event": "event.name", "data": { ... } }
1458
- ```
1459
-
1460
- ### System Events (sent by client automatically)
1461
-
1462
- | Event | When | Payload | Your Server Should |
1463
- |-------|------|---------|-------------------|
1464
- | `ping` | Every 30s (heartbeat) | `{ "type": "ping" }` | Respond with `{ "type": "pong" }` or ignore |
1465
- | `$channel:join` | `ws.channel('name')` | `{ "channel": "chat:room_1" }` | Track which channels this connection belongs to |
1466
- | `$channel:leave` | `channel.leave()` | `{ "channel": "chat:room_1" }` | Remove connection from channel |
1467
- | `$topic:subscribe` | `ws.subscribe('topic')` | `{ "topic": "notifications:orders" }` | Start sending events for this topic to this connection |
1468
- | `$topic:unsubscribe` | `ws.unsubscribe('topic')` | `{ "topic": "notifications:orders" }` | Stop sending events for this topic |
1469
-
1470
- ### Node.js (ws) — Complete Server Example
1471
-
1472
- ```typescript
1473
- import { WebSocketServer, WebSocket } from 'ws';
1474
-
1475
- const wss = new WebSocketServer({ port: 8080 });
1476
-
1477
- // Track per-connection state
1478
- interface ClientState {
1479
- userId?: string;
1480
- channels: Set<string>;
1481
- topics: Set<string>;
1482
- }
1483
-
1484
- const clients = new Map<WebSocket, ClientState>();
1485
-
1486
- wss.on('connection', (ws, req) => {
1487
- // Extract auth token from URL
1488
- const url = new URL(req.url!, `http://${req.headers.host}`);
1489
- const token = url.searchParams.get('token');
1490
- const userId = verifyToken(token); // your auth logic
1491
-
1492
- const state: ClientState = {
1493
- userId,
1494
- channels: new Set(),
1495
- topics: new Set(),
1496
- };
1497
- clients.set(ws, state);
1498
-
1499
- // Send welcome
1500
- send(ws, 'welcome', { userId, timestamp: Date.now() });
1501
-
1502
- ws.on('message', (raw) => {
1503
- const msg = JSON.parse(raw.toString());
1504
- const { event, data } = msg;
1505
-
1506
- switch (event) {
1507
- // ─── System Events ───────────────────────
1508
-
1509
- case 'ping':
1510
- // Respond to heartbeat (optional — some servers ignore pings)
1511
- ws.send(JSON.stringify({ type: 'pong' }));
1512
- break;
1513
-
1514
- // ─── Channel Events ──────────────────────
1515
-
1516
- case '$channel:join':
1517
- state.channels.add(data.channel);
1518
- console.log(`${userId} joined ${data.channel}`);
1519
- // Send channel history, presence, etc.
1520
- break;
1521
-
1522
- case '$channel:leave':
1523
- state.channels.delete(data.channel);
1524
- console.log(`${userId} left ${data.channel}`);
1525
- break;
1526
-
1527
- // ─── Topic Events ────────────────────────
1528
-
1529
- case '$topic:subscribe':
1530
- state.topics.add(data.topic);
1531
- console.log(`${userId} subscribed to ${data.topic}`);
1532
- break;
1533
-
1534
- case '$topic:unsubscribe':
1535
- state.topics.delete(data.topic);
1536
- break;
1537
-
1538
- // ─── App Events ──────────────────────────
1539
-
1540
- case 'chat.send':
1541
- // Broadcast to all clients in the same channel
1542
- const channel = data.roomId ? `chat:${data.roomId}` : null;
1543
- broadcastToChannel(channel, 'chat.message', {
1544
- id: crypto.randomUUID(),
1545
- userId: state.userId,
1546
- text: data.text,
1547
- timestamp: Date.now(),
1548
- });
1549
- break;
1550
-
1551
- case 'chat.typing':
1552
- broadcastToChannel(`chat:${data.roomId}`, 'chat.typing', {
1553
- userId: state.userId,
1554
- }, ws); // exclude sender
1555
- break;
1556
-
1557
- default:
1558
- console.log('Unknown event:', event, data);
1559
- }
1560
- });
1561
-
1562
- ws.on('close', () => {
1563
- clients.delete(ws);
1564
- });
1565
- });
1566
-
1567
- // ─── Helpers ─────────────────────────────────────
1568
-
1569
- function send(ws: WebSocket, event: string, data: unknown) {
1570
- if (ws.readyState === WebSocket.OPEN) {
1571
- ws.send(JSON.stringify({ event, data }));
1572
- }
1573
- }
1574
-
1575
- function broadcastToChannel(
1576
- channel: string | null,
1577
- event: string,
1578
- data: unknown,
1579
- exclude?: WebSocket,
1580
- ) {
1581
- for (const [ws, state] of clients) {
1582
- if (ws === exclude) continue;
1583
- if (channel && !state.channels.has(channel)) continue;
1584
- send(ws, event, data);
1585
- }
1586
- }
1587
-
1588
- function broadcastToTopic(topic: string, event: string, data: unknown) {
1589
- for (const [ws, state] of clients) {
1590
- if (!state.topics.has(topic)) continue;
1591
- send(ws, `${topic}:${event}`, data);
1592
- }
1593
- }
1594
-
1595
- // ─── Example: Send notifications by topic ────────
1596
-
1597
- function notifyNewOrder(order: Order) {
1598
- broadcastToTopic('notifications:orders', 'new', {
1599
- id: order.id,
1600
- total: order.total,
1601
- customer: order.customerName,
1602
- });
1603
- // Only clients who called ws.subscribe('notifications:orders') receive this
1604
- }
1605
-
1606
- // ─── Push Notifications ──────────────────────────
1607
-
1608
- // Client listens via: ws.push('notification', { render: ... })
1609
- // Server sends 'notification' event — client shows toast/push
1610
- function sendPushNotification(
1611
- targetUserId: string,
1612
- notification: { id: string; title: string; body: string; type: string; url?: string },
1613
- ) {
1614
- for (const [ws, state] of clients) {
1615
- if (state.userId === targetUserId) {
1616
- send(ws, 'notification', notification);
1617
- }
1618
- }
1619
- }
1620
-
1621
- // Broadcast push to all connected clients
1622
- function broadcastPush(notification: { id: string; title: string; body: string; type: string }) {
1623
- for (const [ws] of clients) {
1624
- send(ws, 'notification', notification);
1625
- }
1626
- }
1627
-
1628
- // ─── Usage examples ──────────────────────────────
1629
-
1630
- // After order created — notify the merchant
1631
- async function onOrderCreated(order: Order) {
1632
- // 1. Notify via topic (only subscribers)
1633
- broadcastToTopic('notifications:orders', 'new', order);
1634
-
1635
- // 2. Push notification to specific user
1636
- sendPushNotification(order.merchantId, {
1637
- id: `order-${order.id}`,
1638
- title: `New Order #${order.id}`,
1639
- body: `$${order.total} from ${order.customerName}`,
1640
- type: 'success',
1641
- url: `/orders/${order.id}`,
1642
- });
1643
- }
1644
-
1645
- // Payment failed — critical alert to user
1646
- async function onPaymentFailed(payment: Payment) {
1647
- sendPushNotification(payment.userId, {
1648
- id: `payment-${payment.id}`,
1649
- title: 'Payment Failed',
1650
- body: `Your payment of $${payment.amount} could not be processed`,
1651
- type: 'error',
1652
- url: `/payments/${payment.id}`,
1653
- });
1654
- }
1655
-
1656
- // System maintenance — broadcast to everyone
1657
- async function onMaintenanceScheduled(time: string) {
1658
- broadcastPush({
1659
- id: `maintenance-${Date.now()}`,
1660
- title: 'Scheduled Maintenance',
1661
- body: `System will be down for maintenance at ${time}`,
1662
- type: 'warning',
1663
- });
1664
- }
1665
- ```
1666
-
1667
- ### Go — Server Example
1668
-
1669
- ```go
1670
- // Message format
1671
- type Message struct {
1672
- Event string `json:"event"`
1673
- Data json.RawMessage `json:"data"`
1674
- }
1675
-
1676
- // Handle incoming messages
1677
- func handleMessage(conn *websocket.Conn, state *ClientState, msg Message) {
1678
- switch msg.Event {
1679
- case "$channel:join":
1680
- var payload struct{ Channel string `json:"channel"` }
1681
- json.Unmarshal(msg.Data, &payload)
1682
- state.Channels[payload.Channel] = true
1683
-
1684
- case "$channel:leave":
1685
- var payload struct{ Channel string `json:"channel"` }
1686
- json.Unmarshal(msg.Data, &payload)
1687
- delete(state.Channels, payload.Channel)
1688
-
1689
- case "$topic:subscribe":
1690
- var payload struct{ Topic string `json:"topic"` }
1691
- json.Unmarshal(msg.Data, &payload)
1692
- state.Topics[payload.Topic] = true
1693
-
1694
- case "$topic:unsubscribe":
1695
- var payload struct{ Topic string `json:"topic"` }
1696
- json.Unmarshal(msg.Data, &payload)
1697
- delete(state.Topics, payload.Topic)
1698
-
1699
- case "chat.send":
1700
- // broadcast to channel...
1701
-
1702
- case "ping":
1703
- conn.WriteJSON(Message{Event: "pong"})
1704
- }
1705
- }
1706
-
1707
- // Send push notification to specific user
1708
- func sendPushNotification(userID string, title, body, notifType string) {
1709
- for conn, state := range clients {
1710
- if state.UserID == userID {
1711
- conn.WriteJSON(Message{
1712
- Event: "notification",
1713
- Data: json.RawMessage(fmt.Sprintf(
1714
- `{"id":"%s","title":"%s","body":"%s","type":"%s"}`,
1715
- uuid.NewString(), title, body, notifType,
1716
- )),
1717
- })
1718
- }
1719
- }
1720
- }
1721
- ```
1722
-
1723
- ### PHP (Laravel + Ratchet/Swoole) — Server Example
1724
-
1725
- ```php
1726
- // Handle incoming WebSocket message
1727
- public function onMessage(ConnectionInterface $conn, $msg): void
1728
- {
1729
- $data = json_decode($msg, true);
1730
- $event = $data['event'] ?? 'message';
1731
- $payload = $data['data'] ?? [];
1732
-
1733
- match ($event) {
1734
- '$channel:join' => $this->joinChannel($conn, $payload['channel']),
1735
- '$channel:leave' => $this->leaveChannel($conn, $payload['channel']),
1736
- '$topic:subscribe' => $this->subscribeTopic($conn, $payload['topic']),
1737
- '$topic:unsubscribe' => $this->unsubscribeTopic($conn, $payload['topic']),
1738
- 'chat.send' => $this->handleChatMessage($conn, $payload),
1739
- 'ping' => $conn->send(json_encode(['type' => 'pong'])),
1740
- default => logger()->warning("Unknown event: {$event}"),
1741
- };
1742
- }
1743
-
1744
- // Send to topic subscribers
1745
- public function notifyTopic(string $topic, string $event, array $data): void
1746
- {
1747
- foreach ($this->connections as $conn) {
1748
- if (in_array($topic, $this->topics[$conn->resourceId] ?? [])) {
1749
- $conn->send(json_encode([
1750
- 'event' => "{$topic}:{$event}",
1751
- 'data' => $data,
1752
- ]));
1753
- }
1754
- }
1755
- }
1756
-
1757
- // Send push notification to user
1758
- public function sendPushNotification(string $userId, array $notification): void
1759
- {
1760
- foreach ($this->connections as $conn) {
1761
- if ($this->getUserId($conn) === $userId) {
1762
- $conn->send(json_encode([
1763
- 'event' => 'notification',
1764
- 'data' => $notification,
1765
- ]));
1766
- }
1767
- }
1768
- }
1769
-
1770
- // Usage:
1771
- // $this->sendPushNotification($order->merchant_id, [
1772
- // 'id' => Str::uuid(),
1773
- // 'title' => "New Order #{$order->id}",
1774
- // 'body' => "\${$order->total} from {$order->customer_name}",
1775
- // 'type' => 'success',
1776
- // 'url' => "/orders/{$order->id}",
1777
- // ]);
1778
- ```
1779
-
1780
- ## Exported Types
1781
-
1782
- All types are available for import in your projects:
1783
-
1784
- ```typescript
1785
- import type {
1786
- // Core
1787
- SharedWebSocketOptions, // constructor options
1788
- SocketState, // 'connecting' | 'connected' | 'reconnecting' | 'closed'
1789
- TabRole, // 'leader' | 'follower'
1790
- Unsubscribe, // () => void
1791
- EventHandler, // (data: any) => void
1792
-
1793
- // Channels
1794
- Channel, // scoped channel handle from ws.channel()
1795
- EventProtocol, // custom event/field names
1796
-
1797
- // Lifecycle
1798
- SocketLifecycleHandlers, // { onConnect?, onDisconnect?, onReconnecting?, ... }
1799
-
1800
- // withSocket
1801
- SocketScope, // { ws, signal } — callback argument
1802
- WithSocketOptions, // extends SharedWebSocketOptions + signal
1803
- WithSocketCallback, // (scope: SocketScope) => void | Promise<void>
1804
-
1805
- // Internal (advanced)
1806
- BusMessage, // BroadcastChannel message envelope
1807
- } from '@gwakko/shared-websocket';
1808
- ```
1809
-
1810
- ```tsx
1811
- // React — all hooks + types
1812
- import {
1813
- SharedWebSocketProvider,
1814
- useSharedWebSocket,
1815
- useSocketEvent,
1816
- useSocketStream,
1817
- useSocketSync,
1818
- useSocketCallback,
1819
- useSocketStatus,
1820
- useSocketLifecycle,
1821
- useChannel,
1822
- useTopics,
1823
- usePush,
1824
- } from '@gwakko/shared-websocket/react';
1825
- ```
1826
-
1827
- ```typescript
1828
- // Vue — all composables + types
1829
- import {
1830
- createSharedWebSocketPlugin,
1831
- useSharedWebSocket,
1832
- useSocketEvent,
1833
- useSocketStream,
1834
- useSocketSync,
1835
- useSocketCallback,
1836
- useSocketStatus,
1837
- useSocketLifecycle,
1838
- useChannel,
1839
- useTopics,
1840
- usePush,
1841
- SharedWebSocketKey,
1842
- } from '@gwakko/shared-websocket/vue';
1843
- ```
1844
-
1845
- ### Usage with custom types
1846
-
1847
- ```typescript
1848
- import type { Channel, SocketLifecycleHandlers, EventProtocol } from '@gwakko/shared-websocket';
1849
-
1850
- // Type your channel
1851
- const chat: Channel = ws.channel('chat:room_1');
1852
-
1853
- // Type lifecycle handlers separately
1854
- const handlers: SocketLifecycleHandlers = {
1855
- onConnect: () => setStatus('online'),
1856
- onDisconnect: () => setStatus('offline'),
1857
- };
1858
- useSocketLifecycle(handlers);
1859
-
1860
- // Type your protocol config
1861
- const protocol: Partial<EventProtocol> = {
1862
- eventField: 'type',
1863
- dataField: 'payload',
1864
- channelJoin: 'subscribe',
1865
- };
1866
- new SharedWebSocket(url, { events: protocol });
1867
- ```
209
+ | Option | Type | Default | Description |
210
+ |--------|------|---------|-------------|
211
+ | `protocols` | `string[]` | `[]` | WebSocket subprotocols |
212
+ | `reconnect` | `boolean` | `true` | Auto-reconnect |
213
+ | `reconnectMaxDelay` | `number` | `30000` | Max backoff (ms) |
214
+ | `heartbeatInterval` | `number` | `30000` | Ping interval (ms) |
215
+ | `sendBuffer` | `number` | `100` | Buffered messages during reconnect |
216
+ | `auth` | `() => string` | — | Token callback (each connect) |
217
+ | `authToken` | `string` | — | Static token |
218
+ | `authParam` | `string` | `"token"` | URL query param name |
219
+ | `useWorker` | `boolean` | `false` | WebSocket in Web Worker |
220
+ | `workerUrl` | `string \| URL` | — | Custom worker file |
221
+ | `serialize` | `(data) => string \| ArrayBuffer` | `JSON.stringify` | Global serializer |
222
+ | `deserialize` | `(raw) => unknown` | `JSON.parse` | Global deserializer |
223
+ | `events` | `Partial<EventProtocol>` | | Custom event/field names |
224
+ | `debug` | `boolean` | `false` | Enable logging |
225
+ | `logger` | `Logger` | `console` | Custom logger |
226
+
227
+ ## Documentation
228
+
229
+ | Document | Contents |
230
+ |----------|----------|
231
+ | **[Getting Started](docs/getting-started.md)** | Installation, basic usage, withSocket |
232
+ | **[API Reference](docs/api-reference.md)** | Methods, options, React hooks, Vue composables |
233
+ | **[Features](docs/features.md)** | Typed events, channels, topics, push, sync, lifecycle, Zod |
234
+ | **[Configuration](docs/configuration.md)** | Serialization, middleware, event protocol, debug, Worker |
235
+ | **[Server Guide](docs/server-guide.md)** | Node.js, Go, PHP examples + system events |
236
+ | **[Types](docs/types.md)** | All exported types with import examples |
1868
237
 
1869
238
  ## Browser Support
1870
239