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