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