@gwakko/shared-websocket 0.1.0
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/LICENSE +9 -0
- package/README.md +381 -0
- package/dist/MessageBus.d.ts +20 -0
- package/dist/SharedSocket.d.ts +37 -0
- package/dist/SharedWebSocket.d.ts +45 -0
- package/dist/SubscriptionManager.d.ts +14 -0
- package/dist/TabCoordinator.d.ts +36 -0
- package/dist/WorkerSocket.d.ts +42 -0
- package/dist/adapters/index.d.ts +0 -0
- package/dist/adapters/react.d.ts +79 -0
- package/dist/adapters/vue.d.ts +53 -0
- package/dist/chunk-SMH3X34N.cjs +737 -0
- package/dist/chunk-SMH3X34N.cjs.map +1 -0
- package/dist/chunk-TNEMKPGP.js +737 -0
- package/dist/chunk-TNEMKPGP.js.map +1 -0
- package/dist/index.cjs +46 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +100 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +100 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +27 -0
- package/dist/utils/backoff.d.ts +2 -0
- package/dist/utils/disposable.d.ts +0 -0
- package/dist/utils/id.d.ts +1 -0
- package/dist/vue.cjs +93 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.js +93 -0
- package/dist/vue.js.map +1 -0
- package/dist/withSocket.d.ts +51 -0
- package/dist/worker/socket.worker.d.ts +51 -0
- package/package.json +74 -0
- package/src/MessageBus.ts +112 -0
- package/src/SharedSocket.ts +183 -0
- package/src/SharedWebSocket.ts +225 -0
- package/src/SubscriptionManager.ts +86 -0
- package/src/TabCoordinator.ts +162 -0
- package/src/WorkerSocket.ts +149 -0
- package/src/adapters/index.ts +3 -0
- package/src/adapters/react.ts +189 -0
- package/src/adapters/vue.ts +149 -0
- package/src/index.ts +8 -0
- package/src/types.ts +29 -0
- package/src/utils/backoff.ts +9 -0
- package/src/utils/disposable.ts +4 -0
- package/src/utils/id.ts +6 -0
- package/src/withSocket.ts +89 -0
- package/src/worker/socket.worker.ts +205 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gwakko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# Shared WebSocket
|
|
2
|
+
|
|
3
|
+
Share ONE WebSocket connection across multiple browser tabs. Zero dependencies. React and Vue adapters included.
|
|
4
|
+
|
|
5
|
+
## Problem
|
|
6
|
+
|
|
7
|
+
5 tabs open = 5 WebSocket connections = 5x server resources for the same user.
|
|
8
|
+
|
|
9
|
+
## Solution
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Tab 1 (Leader) Tab 2 (Follower) Tab 3 (Follower)
|
|
15
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
16
|
+
│ WebSocket│ │ │ │ │
|
|
17
|
+
│ ↕ │ │ │ │ │
|
|
18
|
+
│ Server │ │ │ │ │
|
|
19
|
+
└────┬─────┘ └─────┬────┘ └─────┬────┘
|
|
20
|
+
└────── BroadcastChannel ──────────────────────┘
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
### From npm
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install shared-websocket
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### From GitHub (latest source)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install github:Gwakko/shared-websocket
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Manual (copy into your project)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Clone and copy src/ into your project
|
|
41
|
+
git clone https://github.com/Gwakko/shared-websocket.git
|
|
42
|
+
cp -r shared-websocket/src ./your-project/shared-websocket
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Build from source
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/Gwakko/shared-websocket.git
|
|
49
|
+
cd shared-websocket
|
|
50
|
+
npm install
|
|
51
|
+
npm run build # outputs ESM + CJS + types to dist/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage — Vanilla TypeScript
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { SharedWebSocket } from 'shared-websocket';
|
|
58
|
+
|
|
59
|
+
const ws = new SharedWebSocket('wss://api.example.com/ws', {
|
|
60
|
+
auth: () => localStorage.getItem('token')!,
|
|
61
|
+
useWorker: true, // optional: run WebSocket in Web Worker (offloads main thread)
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await ws.connect();
|
|
65
|
+
|
|
66
|
+
// Subscribe to events (works in ALL tabs)
|
|
67
|
+
ws.on('order.created', (order) => {
|
|
68
|
+
console.log('New order:', order);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Send message (auto-routed through leader tab)
|
|
72
|
+
ws.send('chat.message', { text: 'Hello!' });
|
|
73
|
+
|
|
74
|
+
// Generator streaming
|
|
75
|
+
for await (const msg of ws.stream('chat.messages')) {
|
|
76
|
+
console.log(msg);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Sync state across tabs (no server roundtrip)
|
|
80
|
+
ws.sync('cart', { items: [1, 2, 3] });
|
|
81
|
+
ws.onSync('cart', (cart) => console.log('Cart updated:', cart));
|
|
82
|
+
|
|
83
|
+
// Cleanup
|
|
84
|
+
ws.disconnect();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Scoped Lifecycle — `withSocket()`
|
|
88
|
+
|
|
89
|
+
Auto-creates, connects, and disposes. Guarantees cleanup even on errors.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { withSocket } from 'shared-websocket';
|
|
93
|
+
|
|
94
|
+
// Basic
|
|
95
|
+
await withSocket('wss://api.example.com/ws', async ({ ws }) => {
|
|
96
|
+
ws.on('order.created', (order) => console.log(order));
|
|
97
|
+
await longRunningWork();
|
|
98
|
+
}); // auto-disposed here
|
|
99
|
+
|
|
100
|
+
// With auth
|
|
101
|
+
await withSocket('wss://api.example.com/ws', {
|
|
102
|
+
auth: () => localStorage.getItem('token')!,
|
|
103
|
+
}, async ({ ws, signal }) => {
|
|
104
|
+
for await (const msg of ws.stream('chat.messages', signal)) {
|
|
105
|
+
renderMessage(msg);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Tab-to-tab sync via BroadcastChannel (no server roundtrip)
|
|
110
|
+
await withSocket('wss://api.example.com/ws', async ({ ws }) => {
|
|
111
|
+
// Send state to ALL tabs instantly
|
|
112
|
+
ws.sync('cart', { items: [1, 2, 3] });
|
|
113
|
+
ws.sync('theme', 'dark');
|
|
114
|
+
ws.sync('locale', 'en');
|
|
115
|
+
|
|
116
|
+
// Read synced state from other tabs
|
|
117
|
+
const cart = ws.getSync<Cart>('cart'); // { items: [1, 2, 3] }
|
|
118
|
+
|
|
119
|
+
// React to changes from other tabs
|
|
120
|
+
ws.onSync('cart', (cart) => {
|
|
121
|
+
updateCartBadge(cart.items.length);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.onSync('theme', (theme) => {
|
|
125
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Combine: server events + tab sync
|
|
130
|
+
await withSocket('wss://api.example.com/ws', {
|
|
131
|
+
auth: () => localStorage.getItem('token')!,
|
|
132
|
+
}, async ({ ws, signal }) => {
|
|
133
|
+
// Server events → update state → sync to all tabs
|
|
134
|
+
ws.on('order.status', (order) => {
|
|
135
|
+
ws.sync('activeOrder', order); // all tabs see the update
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// One tab adds to cart → all tabs update
|
|
139
|
+
ws.onSync('cart', (cart) => renderCart(cart));
|
|
140
|
+
|
|
141
|
+
// Stream server messages with auto-cleanup
|
|
142
|
+
for await (const msg of ws.stream('chat.messages', signal)) {
|
|
143
|
+
renderMessage(msg);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// With external cancellation (AbortController)
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
setTimeout(() => controller.abort(), 30_000);
|
|
150
|
+
|
|
151
|
+
await withSocket('wss://api.example.com/ws', {
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
}, async ({ ws, signal }) => {
|
|
154
|
+
ws.on('notifications', showToast);
|
|
155
|
+
await new Promise((_, reject) => signal.addEventListener('abort', reject));
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Usage — React
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import {
|
|
163
|
+
SharedWebSocketProvider,
|
|
164
|
+
useSharedWebSocket,
|
|
165
|
+
useSocketEvent,
|
|
166
|
+
useSocketStream,
|
|
167
|
+
useSocketSync,
|
|
168
|
+
useSocketStatus,
|
|
169
|
+
} from 'shared-websocket/adapters/react';
|
|
170
|
+
|
|
171
|
+
// Provider accepts url and options as props
|
|
172
|
+
function App() {
|
|
173
|
+
return (
|
|
174
|
+
<SharedWebSocketProvider
|
|
175
|
+
url="wss://api.example.com/ws"
|
|
176
|
+
options={{
|
|
177
|
+
auth: () => localStorage.getItem('token')!,
|
|
178
|
+
useWorker: true,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
<Dashboard />
|
|
182
|
+
</SharedWebSocketProvider>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function Dashboard() {
|
|
187
|
+
const ws = useSharedWebSocket();
|
|
188
|
+
|
|
189
|
+
// Latest event value (reactive) — no need to pass ws, uses context
|
|
190
|
+
const order = useSocketEvent<Order>('order.created');
|
|
191
|
+
|
|
192
|
+
// Accumulated stream
|
|
193
|
+
const messages = useSocketStream<Message>('chat.message');
|
|
194
|
+
|
|
195
|
+
// Synced across tabs (no server roundtrip)
|
|
196
|
+
const [cart, setCart] = useSocketSync('cart', { items: [] });
|
|
197
|
+
|
|
198
|
+
// Connection status
|
|
199
|
+
const { connected, tabRole } = useSocketStatus();
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div>
|
|
203
|
+
<p>Status: {connected ? 'Online' : 'Offline'} ({tabRole})</p>
|
|
204
|
+
{order && <p>Latest order: #{order.id}</p>}
|
|
205
|
+
<button onClick={() => ws.send('ping', {})}>Ping</button>
|
|
206
|
+
<button onClick={() => setCart({ items: [...cart.items, Date.now()] })}>
|
|
207
|
+
Add to cart ({cart.items.length})
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Usage — Vue 3
|
|
215
|
+
|
|
216
|
+
```vue
|
|
217
|
+
<!-- main.ts -->
|
|
218
|
+
<script setup>
|
|
219
|
+
import { createApp } from 'vue';
|
|
220
|
+
import { createSharedWebSocketPlugin } from 'shared-websocket/adapters/vue';
|
|
221
|
+
import App from './App.vue';
|
|
222
|
+
|
|
223
|
+
const app = createApp(App);
|
|
224
|
+
app.use(createSharedWebSocketPlugin('wss://api.example.com/ws'));
|
|
225
|
+
app.mount('#app');
|
|
226
|
+
</script>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
```vue
|
|
230
|
+
<!-- Dashboard.vue -->
|
|
231
|
+
<script setup lang="ts">
|
|
232
|
+
import {
|
|
233
|
+
useSharedWebSocket,
|
|
234
|
+
useSocketEvent,
|
|
235
|
+
useSocketStream,
|
|
236
|
+
useSocketSync,
|
|
237
|
+
useSocketStatus,
|
|
238
|
+
} from 'shared-websocket/adapters/vue';
|
|
239
|
+
|
|
240
|
+
const ws = useSharedWebSocket();
|
|
241
|
+
|
|
242
|
+
const order = useSocketEvent<Order>('order.created');
|
|
243
|
+
const messages = useSocketStream<Message>('chat.message');
|
|
244
|
+
const cart = useSocketSync('cart', { items: [] });
|
|
245
|
+
const { connected, tabRole } = useSocketStatus();
|
|
246
|
+
|
|
247
|
+
function addToCart() {
|
|
248
|
+
cart.value = { items: [...cart.value.items, Date.now()] };
|
|
249
|
+
}
|
|
250
|
+
</script>
|
|
251
|
+
|
|
252
|
+
<template>
|
|
253
|
+
<p>Status: {{ connected ? 'Online' : 'Offline' }} ({{ tabRole }})</p>
|
|
254
|
+
<p v-if="order">Latest order: #{{ order.id }}</p>
|
|
255
|
+
<button @click="ws.send('ping', {})">Ping</button>
|
|
256
|
+
<button @click="addToCart">Add to cart ({{ cart.items.length }})</button>
|
|
257
|
+
</template>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## API Reference
|
|
261
|
+
|
|
262
|
+
### SharedWebSocket
|
|
263
|
+
|
|
264
|
+
| Method | Description |
|
|
265
|
+
|--------|-------------|
|
|
266
|
+
| `connect()` | Start leader election and connect |
|
|
267
|
+
| `on(event, handler)` | Subscribe to server events (all tabs) |
|
|
268
|
+
| `once(event, handler)` | Subscribe once |
|
|
269
|
+
| `off(event, handler?)` | Unsubscribe |
|
|
270
|
+
| `stream(event, signal?)` | AsyncGenerator for consuming events |
|
|
271
|
+
| `send(event, data)` | Send to server (routed through leader) |
|
|
272
|
+
| `request(event, data, timeout?)` | Request/response via server |
|
|
273
|
+
| `sync(key, value)` | Sync state across tabs |
|
|
274
|
+
| `getSync(key)` | Get synced value |
|
|
275
|
+
| `onSync(key, fn)` | Listen for sync changes |
|
|
276
|
+
| `disconnect()` | Close connection and cleanup |
|
|
277
|
+
| `[Symbol.dispose]()` | Cleanup (also called by `disconnect`) |
|
|
278
|
+
|
|
279
|
+
### withSocket()
|
|
280
|
+
|
|
281
|
+
| Signature | Description |
|
|
282
|
+
|-----------|-------------|
|
|
283
|
+
| `withSocket(url, callback)` | Scoped lifecycle, auto-dispose |
|
|
284
|
+
| `withSocket(url, options, callback)` | With auth, signal, etc. |
|
|
285
|
+
|
|
286
|
+
Callback receives `{ ws, signal }` — destructure what you need. Signal aborts when scope exits.
|
|
287
|
+
|
|
288
|
+
### Options
|
|
289
|
+
|
|
290
|
+
| Option | Type | Default | Description |
|
|
291
|
+
|--------|------|---------|-------------|
|
|
292
|
+
| `protocols` | `string[]` | `[]` | WebSocket subprotocols |
|
|
293
|
+
| `reconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
|
|
294
|
+
| `reconnectMaxDelay` | `number` | `30000` | Max reconnect backoff (ms) |
|
|
295
|
+
| `heartbeatInterval` | `number` | `30000` | Ping interval (ms) |
|
|
296
|
+
| `sendBuffer` | `number` | `100` | Max buffered messages during reconnect |
|
|
297
|
+
| `auth` | `() => string` | — | JWT token provider |
|
|
298
|
+
| **`useWorker`** | **`boolean`** | **`false`** | **Run WebSocket in Web Worker** |
|
|
299
|
+
| `workerUrl` | `string \| URL` | — | Custom worker URL (if useWorker) |
|
|
300
|
+
| `electionTimeout` | `number` | `200` | Leader election timeout (ms) |
|
|
301
|
+
| `leaderHeartbeat` | `number` | `2000` | Leader heartbeat interval (ms) |
|
|
302
|
+
| `leaderTimeout` | `number` | `5000` | Leader absence timeout (ms) |
|
|
303
|
+
|
|
304
|
+
### Properties
|
|
305
|
+
|
|
306
|
+
| Property | Type | Description |
|
|
307
|
+
|----------|------|-------------|
|
|
308
|
+
| `connected` | `boolean` | Connection status |
|
|
309
|
+
| `tabRole` | `'leader' \| 'follower'` | Current tab's role |
|
|
310
|
+
|
|
311
|
+
### React Hooks (React 19, uses `useEffectEvent` for stable refs)
|
|
312
|
+
|
|
313
|
+
| Hook | Returns | Description |
|
|
314
|
+
|------|---------|-------------|
|
|
315
|
+
| `useSharedWebSocket()` | `SharedWebSocket` | Access instance from context |
|
|
316
|
+
| `useSocketEvent<T>(event)` | `T \| undefined` | Latest event value |
|
|
317
|
+
| `useSocketStream<T>(event)` | `T[]` | Accumulated events |
|
|
318
|
+
| `useSocketSync<T>(key, init)` | `[T, setter]` | Cross-tab synced state |
|
|
319
|
+
| `useSocketStatus()` | `{ connected, tabRole }` | Connection status |
|
|
320
|
+
|
|
321
|
+
All hooks use context internally — no need to pass `ws` as argument.
|
|
322
|
+
|
|
323
|
+
### Vue Composables
|
|
324
|
+
|
|
325
|
+
| Composable | Returns | Description |
|
|
326
|
+
|-----------|---------|-------------|
|
|
327
|
+
| `useSocketEvent<T>(event)` | `Ref<T>` | Latest event value |
|
|
328
|
+
| `useSocketStream<T>(event)` | `Ref<T[]>` | Accumulated events |
|
|
329
|
+
| `useSocketSync<T>(key, init)` | `Ref<T>` | Cross-tab synced state (two-way) |
|
|
330
|
+
| `useSocketStatus()` | `{ connected, tabRole }` | Reactive connection status |
|
|
331
|
+
|
|
332
|
+
## How It Works
|
|
333
|
+
|
|
334
|
+
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.
|
|
335
|
+
|
|
336
|
+
2. **Message Flow** — follower calls `send()` → message goes to BroadcastChannel → leader picks it up → forwards to WebSocket → server response → leader broadcasts to all tabs.
|
|
337
|
+
|
|
338
|
+
3. **Failover** — leader tab closes → `beforeunload` fires `abdicate` → followers detect missing heartbeat → election → new leader connects WebSocket → zero data loss (buffered messages replayed).
|
|
339
|
+
|
|
340
|
+
4. **Resource Safety** — `withSocket()` for scoped lifecycle, `Symbol.dispose` support. All timers, listeners, and channels properly cleaned up.
|
|
341
|
+
|
|
342
|
+
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.
|
|
343
|
+
|
|
344
|
+
## When to Use `useWorker: true`
|
|
345
|
+
|
|
346
|
+
| Scenario | useWorker | Why |
|
|
347
|
+
|----------|-----------|-----|
|
|
348
|
+
| Chat (10-50 msgs/sec) | `false` | Low overhead, not worth Worker complexity |
|
|
349
|
+
| Simple notifications | `false` | Few messages, main thread handles fine |
|
|
350
|
+
| Live trading feed (100+ msgs/sec) | **`true`** | JSON parsing 100+ msgs/sec blocks rendering |
|
|
351
|
+
| Real-time dashboard (50+ metrics/sec) | **`true`** | Continuous data stream, UI must stay smooth |
|
|
352
|
+
| Heavy payload (>100KB per message) | **`true`** | Parsing large JSON blocks main thread |
|
|
353
|
+
| Complex UI (React with 10k+ rows) | **`true`** | Main thread already busy, any extra work causes jank |
|
|
354
|
+
| Mobile / low-end devices | **`true`** | Less CPU available, offloading helps |
|
|
355
|
+
| Simple landing page | `false` | Minimal UI, no rendering pressure |
|
|
356
|
+
| SSR / Node.js | `false` | Workers are browser-only |
|
|
357
|
+
| Debugging | `false` | Worker DevTools is less convenient |
|
|
358
|
+
|
|
359
|
+
**Rule of thumb:** If your app drops frames when WebSocket messages arrive — add `useWorker: true`.
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
// Without worker (default) — WebSocket in main thread
|
|
363
|
+
const ws = new SharedWebSocket(url);
|
|
364
|
+
|
|
365
|
+
// With worker — WebSocket in Web Worker
|
|
366
|
+
const ws = new SharedWebSocket(url, { useWorker: true });
|
|
367
|
+
|
|
368
|
+
// API is identical — only internal transport changes
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Browser Support
|
|
372
|
+
|
|
373
|
+
| API | Chrome | Firefox | Safari | Edge |
|
|
374
|
+
|-----|--------|---------|--------|------|
|
|
375
|
+
| BroadcastChannel | 54+ | 38+ | 15.4+ | 79+ |
|
|
376
|
+
| Web Worker | ✅ | ✅ | ✅ | ✅ |
|
|
377
|
+
| AsyncGenerator | 63+ | 57+ | 12+ | 79+ |
|
|
378
|
+
|
|
379
|
+
## License
|
|
380
|
+
|
|
381
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { Unsubscribe } from './types';
|
|
3
|
+
export declare class MessageBus implements Disposable {
|
|
4
|
+
private readonly tabId;
|
|
5
|
+
private channel;
|
|
6
|
+
private listeners;
|
|
7
|
+
private pendingRequests;
|
|
8
|
+
constructor(channelName: string, tabId: string);
|
|
9
|
+
subscribe<T>(topic: string, fn: (data: T) => void): Unsubscribe;
|
|
10
|
+
publish<T>(topic: string, data: T): void;
|
|
11
|
+
broadcast<T>(topic: string, data: T): void;
|
|
12
|
+
request<T, R>(topic: string, data: T, timeout?: number): Promise<R>;
|
|
13
|
+
respond<T, R>(topic: string, fn: (data: T) => R | Promise<R>): Unsubscribe;
|
|
14
|
+
private handleMessage;
|
|
15
|
+
private postMessage;
|
|
16
|
+
private createMessage;
|
|
17
|
+
private addListener;
|
|
18
|
+
private removeListener;
|
|
19
|
+
[Symbol.dispose](): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { SocketState, Unsubscribe, EventHandler } from './types';
|
|
3
|
+
interface SharedSocketOptions {
|
|
4
|
+
protocols?: string[];
|
|
5
|
+
reconnect?: boolean;
|
|
6
|
+
reconnectMaxDelay?: number;
|
|
7
|
+
heartbeatInterval?: number;
|
|
8
|
+
sendBuffer?: number;
|
|
9
|
+
auth?: () => string | Promise<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare class SharedSocket implements Disposable {
|
|
12
|
+
private url;
|
|
13
|
+
private ws;
|
|
14
|
+
private _state;
|
|
15
|
+
private buffer;
|
|
16
|
+
private disposed;
|
|
17
|
+
private heartbeatTimer;
|
|
18
|
+
private reconnectTimer;
|
|
19
|
+
private onMessageFns;
|
|
20
|
+
private onStateChangeFns;
|
|
21
|
+
private readonly opts;
|
|
22
|
+
constructor(url: string, options?: SharedSocketOptions);
|
|
23
|
+
get state(): SocketState;
|
|
24
|
+
connect(): Promise<void>;
|
|
25
|
+
disconnect(): void;
|
|
26
|
+
send(data: unknown): void;
|
|
27
|
+
onMessage(fn: EventHandler): Unsubscribe;
|
|
28
|
+
onStateChange(fn: (state: SocketState) => void): Unsubscribe;
|
|
29
|
+
private reconnect;
|
|
30
|
+
private flushBuffer;
|
|
31
|
+
private startHeartbeat;
|
|
32
|
+
private stopHeartbeat;
|
|
33
|
+
private clearReconnect;
|
|
34
|
+
private setState;
|
|
35
|
+
[Symbol.dispose](): void;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { SharedWebSocketOptions, TabRole, Unsubscribe, EventHandler } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* SharedWebSocket — shares ONE WebSocket connection across browser tabs.
|
|
5
|
+
*
|
|
6
|
+
* One tab becomes the "leader" and holds the WebSocket.
|
|
7
|
+
* Other tabs are "followers" receiving data via BroadcastChannel.
|
|
8
|
+
* If the leader closes, a new leader is elected automatically.
|
|
9
|
+
*/
|
|
10
|
+
export declare class SharedWebSocket implements Disposable {
|
|
11
|
+
private readonly url;
|
|
12
|
+
private readonly options;
|
|
13
|
+
private bus;
|
|
14
|
+
private coordinator;
|
|
15
|
+
private socket;
|
|
16
|
+
private subs;
|
|
17
|
+
private syncStore;
|
|
18
|
+
private tabId;
|
|
19
|
+
private cleanups;
|
|
20
|
+
private disposed;
|
|
21
|
+
constructor(url: string, options?: SharedWebSocketOptions);
|
|
22
|
+
get connected(): boolean;
|
|
23
|
+
get tabRole(): TabRole;
|
|
24
|
+
/** Start leader election and connect. */
|
|
25
|
+
connect(): Promise<void>;
|
|
26
|
+
/** Subscribe to server events (works in ALL tabs). */
|
|
27
|
+
on(event: string, handler: EventHandler): Unsubscribe;
|
|
28
|
+
once(event: string, handler: EventHandler): Unsubscribe;
|
|
29
|
+
off(event: string, handler?: EventHandler): void;
|
|
30
|
+
/** Async generator for consuming events. */
|
|
31
|
+
stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
|
|
32
|
+
/** Send message to server (auto-routed through leader). */
|
|
33
|
+
send(event: string, data: unknown): void;
|
|
34
|
+
/** Request/response through server via leader. */
|
|
35
|
+
request<T>(event: string, data: unknown, timeout?: number): Promise<T>;
|
|
36
|
+
/** Sync state across tabs (no server roundtrip). */
|
|
37
|
+
sync<T>(key: string, value: T): void;
|
|
38
|
+
getSync<T>(key: string): T | undefined;
|
|
39
|
+
onSync<T>(key: string, fn: (value: T) => void): Unsubscribe;
|
|
40
|
+
disconnect(): void;
|
|
41
|
+
private createSocket;
|
|
42
|
+
private onBecomeLeader;
|
|
43
|
+
private onLoseLeadership;
|
|
44
|
+
[Symbol.dispose](): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { EventHandler, Unsubscribe } from './types';
|
|
3
|
+
export declare class SubscriptionManager implements Disposable {
|
|
4
|
+
private handlers;
|
|
5
|
+
private lastMessages;
|
|
6
|
+
on(event: string, handler: EventHandler): Unsubscribe;
|
|
7
|
+
once(event: string, handler: EventHandler): Unsubscribe;
|
|
8
|
+
off(event: string, handler?: EventHandler): void;
|
|
9
|
+
emit(event: string, data: unknown): void;
|
|
10
|
+
getLastMessage(event: string): unknown | undefined;
|
|
11
|
+
stream(event: string, signal?: AbortSignal): AsyncGenerator<unknown>;
|
|
12
|
+
offAll(): void;
|
|
13
|
+
[Symbol.dispose](): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import { MessageBus } from './MessageBus';
|
|
3
|
+
import type { Unsubscribe } from './types';
|
|
4
|
+
interface CoordinatorOptions {
|
|
5
|
+
electionTimeout?: number;
|
|
6
|
+
heartbeatInterval?: number;
|
|
7
|
+
leaderTimeout?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class TabCoordinator implements Disposable {
|
|
10
|
+
private readonly bus;
|
|
11
|
+
private readonly tabId;
|
|
12
|
+
private _isLeader;
|
|
13
|
+
private heartbeatTimer;
|
|
14
|
+
private leaderCheckTimer;
|
|
15
|
+
private lastHeartbeat;
|
|
16
|
+
private disposed;
|
|
17
|
+
private onBecomeLeaderFns;
|
|
18
|
+
private onLoseLeadershipFns;
|
|
19
|
+
private cleanups;
|
|
20
|
+
private readonly electionTimeout;
|
|
21
|
+
private readonly heartbeatInterval;
|
|
22
|
+
private readonly leaderTimeout;
|
|
23
|
+
constructor(bus: MessageBus, tabId: string, options?: CoordinatorOptions);
|
|
24
|
+
get isLeader(): boolean;
|
|
25
|
+
elect(): Promise<void>;
|
|
26
|
+
abdicate(): void;
|
|
27
|
+
onBecomeLeader(fn: () => void): Unsubscribe;
|
|
28
|
+
onLoseLeadership(fn: () => void): Unsubscribe;
|
|
29
|
+
private becomeLeader;
|
|
30
|
+
private startHeartbeat;
|
|
31
|
+
private stopHeartbeat;
|
|
32
|
+
private startLeaderCheck;
|
|
33
|
+
private stopLeaderCheck;
|
|
34
|
+
[Symbol.dispose](): void;
|
|
35
|
+
}
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import './utils/disposable';
|
|
2
|
+
import type { SocketState, Unsubscribe, EventHandler } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* WorkerSocket — WebSocket running inside a Web Worker.
|
|
5
|
+
*
|
|
6
|
+
* Same interface as SharedSocket, but WebSocket lives off main thread.
|
|
7
|
+
* Benefits: heartbeat timers and JSON parsing don't block UI rendering.
|
|
8
|
+
*
|
|
9
|
+
* Use when:
|
|
10
|
+
* - High message rate (50+ msgs/sec)
|
|
11
|
+
* - Heavy JSON payloads
|
|
12
|
+
* - UI does complex rendering that could block main thread
|
|
13
|
+
*
|
|
14
|
+
* Don't use when:
|
|
15
|
+
* - Low message rate (simple chat, notifications)
|
|
16
|
+
* - Bundle size matters (adds worker file)
|
|
17
|
+
* - Debugging (Worker DevTools is less convenient)
|
|
18
|
+
*/
|
|
19
|
+
export declare class WorkerSocket implements Disposable {
|
|
20
|
+
private url;
|
|
21
|
+
private options;
|
|
22
|
+
private worker;
|
|
23
|
+
private _state;
|
|
24
|
+
private onMessageFns;
|
|
25
|
+
private onStateChangeFns;
|
|
26
|
+
constructor(url: string, options?: {
|
|
27
|
+
protocols?: string[];
|
|
28
|
+
reconnect?: boolean;
|
|
29
|
+
reconnectMaxDelay?: number;
|
|
30
|
+
heartbeatInterval?: number;
|
|
31
|
+
sendBuffer?: number;
|
|
32
|
+
workerUrl?: string | URL;
|
|
33
|
+
});
|
|
34
|
+
get state(): SocketState;
|
|
35
|
+
connect(): void;
|
|
36
|
+
send(data: unknown): void;
|
|
37
|
+
disconnect(): void;
|
|
38
|
+
onMessage(fn: EventHandler): Unsubscribe;
|
|
39
|
+
onStateChange(fn: (state: SocketState) => void): Unsubscribe;
|
|
40
|
+
private createWorkerBlob;
|
|
41
|
+
[Symbol.dispose](): void;
|
|
42
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { SharedWebSocket } from '../SharedWebSocket';
|
|
3
|
+
import type { SharedWebSocketOptions, TabRole } from '../types';
|
|
4
|
+
/**
|
|
5
|
+
* Provider props — pass URL and options as props for flexibility.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <SharedWebSocketProvider url="wss://api.example.com/ws" options={{ auth: getToken }}>
|
|
9
|
+
* <App />
|
|
10
|
+
* </SharedWebSocketProvider>
|
|
11
|
+
*/
|
|
12
|
+
export interface SharedWebSocketProviderProps {
|
|
13
|
+
url: string;
|
|
14
|
+
options?: SharedWebSocketOptions;
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Provider component — creates SharedWebSocket from props, auto-disposes on unmount.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* function App() {
|
|
22
|
+
* return (
|
|
23
|
+
* <SharedWebSocketProvider
|
|
24
|
+
* url="wss://api.example.com/ws"
|
|
25
|
+
* options={{
|
|
26
|
+
* auth: () => localStorage.getItem('token')!,
|
|
27
|
+
* useWorker: true,
|
|
28
|
+
* }}
|
|
29
|
+
* >
|
|
30
|
+
* <Dashboard />
|
|
31
|
+
* </SharedWebSocketProvider>
|
|
32
|
+
* );
|
|
33
|
+
* }
|
|
34
|
+
*/
|
|
35
|
+
export declare function SharedWebSocketProvider({ url, options, children }: SharedWebSocketProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SharedWebSocket | null>>;
|
|
36
|
+
/**
|
|
37
|
+
* Access the SharedWebSocket instance from context.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const ws = useSharedWebSocket();
|
|
41
|
+
* ws.send('chat.message', { text: 'Hello' });
|
|
42
|
+
*/
|
|
43
|
+
export declare function useSharedWebSocket(): SharedWebSocket;
|
|
44
|
+
/**
|
|
45
|
+
* Subscribe to a WebSocket event. Returns the latest received value.
|
|
46
|
+
* Uses useEffectEvent for a stable callback ref — no stale closures.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const order = useSocketEvent<Order>('order.created');
|
|
50
|
+
*/
|
|
51
|
+
export declare function useSocketEvent<T>(event: string): T | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Accumulate WebSocket events into an array.
|
|
54
|
+
* Uses useEffectEvent — handler always sees latest state without re-subscribing.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* const messages = useSocketStream<ChatMessage>('chat.message');
|
|
58
|
+
*/
|
|
59
|
+
export declare function useSocketStream<T>(event: string): T[];
|
|
60
|
+
/**
|
|
61
|
+
* Two-way state sync across browser tabs.
|
|
62
|
+
* Uses useEffectEvent for stable sync callback.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* const [cart, setCart] = useSocketSync<Cart>('cart', { items: [] });
|
|
66
|
+
* // setCart in one tab → updates all tabs instantly
|
|
67
|
+
*/
|
|
68
|
+
export declare function useSocketSync<T>(key: string, initialValue: T): [T, (value: T) => void];
|
|
69
|
+
/**
|
|
70
|
+
* Reactive connection status.
|
|
71
|
+
* Uses useEffectEvent to avoid re-creating interval on state change.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const { connected, tabRole } = useSocketStatus();
|
|
75
|
+
*/
|
|
76
|
+
export declare function useSocketStatus(): {
|
|
77
|
+
connected: boolean;
|
|
78
|
+
tabRole: TabRole;
|
|
79
|
+
};
|