@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 +135 -1632
- package/dist/SharedSocket.d.ts +4 -0
- package/dist/SharedWebSocket.d.ts +27 -0
- package/dist/{chunk-QCINVFBG.cjs → chunk-MJXKQYRZ.cjs} +56 -9
- package/dist/chunk-MJXKQYRZ.cjs.map +1 -0
- package/dist/{chunk-TTOZNGEX.js → chunk-PKZXBX5I.js} +55 -8
- package/dist/chunk-PKZXBX5I.js.map +1 -0
- package/dist/index.cjs +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react.cjs +2 -2
- package/dist/react.js +1 -1
- package/dist/types.d.ts +15 -0
- package/dist/vue.cjs +2 -2
- package/dist/vue.js +1 -1
- package/package.json +1 -1
- package/src/SharedSocket.ts +16 -4
- package/src/SharedWebSocket.ts +52 -2
- package/src/index.ts +1 -0
- package/src/types.ts +16 -0
- package/src/worker/socket.worker.template.txt +220 -0
- package/dist/chunk-QCINVFBG.cjs.map +0 -1
- package/dist/chunk-TTOZNGEX.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,51 +1,10 @@
|
|
|
1
1
|
# Shared WebSocket
|
|
2
2
|
|
|
3
|
-
Share
|
|
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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@gwakko/shared-websocket)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
## Installation
|
|
83
22
|
|
|
84
23
|
```bash
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
28
|
+
## Quick Start
|
|
126
29
|
|
|
127
|
-
|
|
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
|
-
//
|
|
168
|
-
|
|
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
|
-
//
|
|
177
|
-
ws.
|
|
43
|
+
// Send (auto-routed through leader)
|
|
44
|
+
ws.send('chat.message', { text: 'Hello!' });
|
|
178
45
|
|
|
179
|
-
// Stream
|
|
180
|
-
for await (const
|
|
181
|
-
|
|
46
|
+
// Stream
|
|
47
|
+
for await (const tick of ws.stream('trading.tick', signal)) {
|
|
48
|
+
updateChart(tick);
|
|
182
49
|
}
|
|
183
|
-
});
|
|
184
50
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
231
|
-
|
|
103
|
+
// Callback variant
|
|
104
|
+
useSocketEvent<Order>('order.created', (order) => {
|
|
105
|
+
playSound('new-order');
|
|
106
|
+
});
|
|
232
107
|
|
|
233
|
-
//
|
|
234
|
-
|
|
108
|
+
// Lifecycle
|
|
109
|
+
useSocketLifecycle({
|
|
110
|
+
onConnect: () => toast.success('Connected'),
|
|
111
|
+
onActive: () => refreshData(),
|
|
112
|
+
});
|
|
235
113
|
|
|
236
|
-
//
|
|
237
|
-
const
|
|
114
|
+
// Channel
|
|
115
|
+
const chat = useChannel(`chat:${roomId}`);
|
|
238
116
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
130
|
+
### Vue 3
|
|
253
131
|
|
|
254
|
-
```
|
|
255
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|