@gwakko/shared-websocket 0.9.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,52 +1,24 @@
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
+
5
+ [![npm](https://img.shields.io/npm/v/@gwakko/shared-websocket)](https://www.npmjs.com/package/@gwakko/shared-websocket)
4
6
 
5
7
  ## Table of Contents
6
8
 
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
9
  - [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)
10
+ - [Installation](#installation)
11
+ - [Quick Start](#quick-start)
12
+ - [Vanilla TypeScript](#vanilla-typescript)
13
+ - [React 19](#react-19)
14
+ - [Vue 3](#vue-3)
15
+ - [Features](#features)
16
+ - [Processing Pipeline](#processing-pipeline)
17
+ - [Options](#options)
18
+ - [Documentation](#documentation)
40
19
  - [Browser Support](#browser-support)
41
- - [License](#license)
42
-
43
- ## Problem
44
20
 
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.
21
+ ## How It Works
50
22
 
51
23
  ```
52
24
  Tab 1 (Leader) Tab 2 (Follower) Tab 3 (Follower)
@@ -58,144 +30,57 @@ Tab 1 (Leader) Tab 2 (Follower) Tab 3 (Follower)
58
30
  └────── BroadcastChannel ──────────────────────┘
59
31
  ```
60
32
 
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
- ```
33
+ 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
34
 
83
- ### Build from source
35
+ ## Installation
84
36
 
85
37
  ```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();
38
+ npm install @gwakko/shared-websocket # npm
39
+ npm install github:Gwakko/shared-websocket # from GitHub
124
40
  ```
125
41
 
126
- ### Scoped Lifecycle — `withSocket()`
42
+ ## Quick Start
127
43
 
128
- Auto-creates, connects, and disposes. Guarantees cleanup even on errors.
44
+ ### Vanilla TypeScript
129
45
 
130
46
  ```typescript
131
47
  import { withSocket } from '@gwakko/shared-websocket';
132
48
 
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
49
  await withSocket('wss://api.example.com/ws', {
141
50
  auth: () => localStorage.getItem('token')!,
51
+ useWorker: true, // optional: offload to Web Worker
142
52
  }, 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
53
 
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
-
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
- });
54
+ // Listen to events (works in ALL tabs)
55
+ ws.on('chat.message', (msg) => renderMessage(msg));
176
56
 
177
- // One tab adds to cart → all tabs update
178
- ws.onSync('cart', (cart) => renderCart(cart));
57
+ // Send (auto-routed through leader)
58
+ ws.send('chat.message', { text: 'Hello!' });
179
59
 
180
- // Stream server messages with auto-cleanup
181
- for await (const msg of ws.stream('chat.messages', signal)) {
182
- renderMessage(msg);
60
+ // Stream
61
+ for await (const tick of ws.stream('trading.tick', signal)) {
62
+ updateChart(tick);
183
63
  }
184
- });
185
64
 
186
- // With external cancellation (AbortController)
187
- const controller = new AbortController();
188
- setTimeout(() => controller.abort(), 30_000);
65
+ // Sync state across tabs (no server)
66
+ ws.sync('cart', { items: [1, 2, 3] });
67
+ ws.onSync('cart', (cart) => updateBadge(cart.items.length));
189
68
 
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));
69
+ // Private channel
70
+ const chat = ws.channel('chat:room_42');
71
+ chat.on('message', (msg) => render(msg));
72
+ chat.send('message', { text: 'Hi room!' });
73
+
74
+ // Push notifications
75
+ ws.push('notification', {
76
+ render: (n) => toast(n.title), // sonner/react-hot-toast
77
+ title: (n) => n.title, // + browser Notification
78
+ target: 'active', // active | leader | all
79
+ });
195
80
  });
196
81
  ```
197
82
 
198
- ## Usage — React
83
+ ### React 19
199
84
 
200
85
  ```tsx
201
86
  import {
@@ -204,18 +89,19 @@ import {
204
89
  useSocketEvent,
205
90
  useSocketStream,
206
91
  useSocketSync,
92
+ useSocketCallback,
207
93
  useSocketStatus,
94
+ useSocketLifecycle,
95
+ useChannel,
96
+ useTopics,
97
+ usePush,
208
98
  } from '@gwakko/shared-websocket/react';
209
99
 
210
- // Provider accepts url and options as props
211
100
  function App() {
212
101
  return (
213
102
  <SharedWebSocketProvider
214
103
  url="wss://api.example.com/ws"
215
- options={{
216
- auth: () => localStorage.getItem('token')!,
217
- useWorker: true,
218
- }}
104
+ options={{ auth: () => getToken(), useWorker: true }}
219
105
  >
220
106
  <Dashboard />
221
107
  </SharedWebSocketProvider>
@@ -224,1647 +110,144 @@ function App() {
224
110
 
225
111
  function Dashboard() {
226
112
  const ws = useSharedWebSocket();
227
-
228
- // Latest event value (reactive) — no need to pass ws, uses context
229
113
  const order = useSocketEvent<Order>('order.created');
114
+ const [cart, setCart] = useSocketSync('cart', { items: [] });
115
+ const { connected, tabRole } = useSocketStatus();
230
116
 
231
- // Accumulated stream
232
- const messages = useSocketStream<Message>('chat.message');
117
+ // Callback variant
118
+ useSocketEvent<Order>('order.created', (order) => {
119
+ playSound('new-order');
120
+ });
233
121
 
234
- // Synced across tabs (no server roundtrip)
235
- const [cart, setCart] = useSocketSync('cart', { items: [] });
122
+ // Lifecycle
123
+ useSocketLifecycle({
124
+ onConnect: () => toast.success('Connected'),
125
+ onActive: () => refreshData(),
126
+ });
236
127
 
237
- // Connection status
238
- const { connected, tabRole } = useSocketStatus();
128
+ // Channel
129
+ const chat = useChannel(`chat:${roomId}`);
239
130
 
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
- );
131
+ // Topics
132
+ useTopics(['notifications:orders']);
133
+
134
+ // Push
135
+ usePush('notification', {
136
+ render: (n) => toast(n.title),
137
+ target: 'active',
138
+ });
139
+
140
+ return <div>{connected ? 'Online' : 'Offline'} ({tabRole})</div>;
250
141
  }
251
142
  ```
252
143
 
253
- ## Usage — Vue 3
254
-
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';
144
+ ### Vue 3
261
145
 
262
- const app = createApp(App);
146
+ ```typescript
147
+ // main.ts
263
148
  app.use(createSharedWebSocketPlugin('wss://api.example.com/ws', {
264
- auth: () => localStorage.getItem('token')!,
149
+ auth: () => getToken(),
265
150
  useWorker: true,
266
151
  }));
267
- app.mount('#app');
268
- </script>
269
152
  ```
270
153
 
271
154
  ```vue
272
- <!-- Dashboard.vue -->
273
155
  <script setup lang="ts">
274
156
  import {
275
157
  useSharedWebSocket,
276
158
  useSocketEvent,
277
- useSocketStream,
278
159
  useSocketSync,
279
- useSocketStatus,
160
+ useSocketLifecycle,
161
+ useChannel,
162
+ useTopics,
163
+ usePush,
280
164
  } from '@gwakko/shared-websocket/vue';
281
165
 
282
166
  const ws = useSharedWebSocket();
283
-
284
167
  const order = useSocketEvent<Order>('order.created');
285
- const messages = useSocketStream<Message>('chat.message');
286
168
  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
-
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
169
 
591
- const msg: ChatMessage = result.data;
592
- console.log(msg.text); // fully typed and validated
170
+ useSocketLifecycle({
171
+ onConnect: () => toast.success('Connected'),
172
+ onActive: () => refreshData(),
593
173
  });
594
174
 
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';
175
+ const chat = useChannel(`chat:${roomId}`);
176
+ useTopics(['notifications:orders']);
630
177
 
631
- const ChatMessageSchema = z.object({
632
- text: z.string(),
633
- userId: z.string(),
178
+ usePush('notification', {
179
+ render: (n) => toast(n.title),
634
180
  });
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
181
  </script>
649
182
  ```
650
183
 
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
- }
184
+ ## Features
696
185
 
697
- // Or setup middleware in a component
698
- function SetupMiddleware() {
699
- const ws = useSharedWebSocket();
186
+ | Feature | Description |
187
+ |---------|-------------|
188
+ | **Leader Election** | One tab holds WebSocket, others receive via BroadcastChannel |
189
+ | **Auto Failover** | Leader closes → new election → reconnect in ~5s |
190
+ | **Typed Events** | `SharedWebSocket<EventMap>` — type-safe on/send/stream |
191
+ | **Channels** | `ws.channel('room')` — scoped events, auto join/leave |
192
+ | **Topics** | `ws.subscribe('topic')` — server-side filtered subscriptions |
193
+ | **Tab Sync** | `ws.sync(key, value)` — state across tabs, no server |
194
+ | **Push Notifications** | `ws.push()` — render (sonner) + browser Notification API |
195
+ | **Middleware** | `ws.use('incoming'/'outgoing', fn)` — transform, filter, log |
196
+ | **Worker Mode** | `useWorker: true` — WebSocket off main thread |
197
+ | **Custom Serialization** | `serialize`/`deserialize` — JSON, MessagePack, Protobuf |
198
+ | **Per-Event Serializers** | `ws.serializer(event, fn)` — binary for specific events |
199
+ | **Lifecycle Hooks** | onConnect, onDisconnect, onActive, onInactive, onLeaderChange |
200
+ | **Debug/Logger** | `debug: true` + injectable logger (pino, Sentry) |
201
+ | **Event Protocol** | Configurable field names (Socket.IO, Phoenix, Laravel Echo) |
202
+ | **Auth** | `auth` callback, `authToken` string, custom `authParam` |
203
+ | **Zero Dependencies** | Pure browser APIs |
700
204
 
701
- useEffect(() => {
702
- ws.use('outgoing', (msg) => ({ ...msg, timestamp: Date.now() }));
703
- ws.use('incoming', zodValidate(MessageSchema));
704
- }, [ws]);
205
+ ## Processing Pipeline
705
206
 
706
- return null;
707
- }
708
207
  ```
208
+ Outgoing: ws.send(event, data)
209
+ → per-event serializer (if registered)
210
+ → outgoing middleware (transform/inspect/drop)
211
+ → global serialize (JSON/msgpack — configurable)
212
+ → WebSocket.send() (or Worker → WebSocket.send)
709
213
 
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>
214
+ Incoming: WebSocket.onmessage
215
+ global deserialize (JSON/msgpack configurable)
216
+ incoming middleware (transform/inspect/drop)
217
+ per-event deserializer (if registered)
218
+ emit to handlers (all tabs via BroadcastChannel)
718
219
  ```
719
220
 
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
- ```
221
+ ## Options
770
222
 
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
- ```
223
+ | Option | Type | Default | Description |
224
+ |--------|------|---------|-------------|
225
+ | `protocols` | `string[]` | `[]` | WebSocket subprotocols |
226
+ | `reconnect` | `boolean` | `true` | Auto-reconnect |
227
+ | `reconnectMaxDelay` | `number` | `30000` | Max backoff (ms) |
228
+ | `heartbeatInterval` | `number` | `30000` | Ping interval (ms) |
229
+ | `sendBuffer` | `number` | `100` | Buffered messages during reconnect |
230
+ | `auth` | `() => string` | — | Token callback (each connect) |
231
+ | `authToken` | `string` | — | Static token |
232
+ | `authParam` | `string` | `"token"` | URL query param name |
233
+ | `useWorker` | `boolean` | `false` | WebSocket in Web Worker |
234
+ | `workerUrl` | `string \| URL` | — | Custom worker file |
235
+ | `serialize` | `(data) => string \| ArrayBuffer` | `JSON.stringify` | Global serializer |
236
+ | `deserialize` | `(raw) => unknown` | `JSON.parse` | Global deserializer |
237
+ | `events` | `Partial<EventProtocol>` | | Custom event/field names |
238
+ | `debug` | `boolean` | `false` | Enable logging |
239
+ | `logger` | `Logger` | `console` | Custom logger |
240
+
241
+ ## Documentation
242
+
243
+ | Document | Contents |
244
+ |----------|----------|
245
+ | **[Getting Started](docs/getting-started.md)** | Installation, basic usage, withSocket |
246
+ | **[API Reference](docs/api-reference.md)** | Methods, options, React hooks, Vue composables |
247
+ | **[Features](docs/features.md)** | Typed events, channels, topics, push, sync, lifecycle, Zod |
248
+ | **[Configuration](docs/configuration.md)** | Serialization, middleware, event protocol, debug, Worker |
249
+ | **[Server Guide](docs/server-guide.md)** | Node.js, Go, PHP examples + system events |
250
+ | **[Types](docs/types.md)** | All exported types with import examples |
1868
251
 
1869
252
  ## Browser Support
1870
253