@luxexchange/websocket 1.0.0 → 1.0.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/package.json +2 -2
- package/.depcheckrc +0 -15
- package/.eslintrc.js +0 -20
- package/README.md +0 -275
- package/src/client/__tests__/MockWebSocket.ts +0 -54
- package/src/client/__tests__/createWebSocketClient.integration.test.ts +0 -831
- package/src/client/__tests__/testUtils.ts +0 -113
- package/src/client/createWebSocketClient.ts +0 -262
- package/src/index.ts +0 -22
- package/src/store/createZustandConnectionStore.test.ts +0 -162
- package/src/store/createZustandConnectionStore.ts +0 -74
- package/src/store/types.ts +0 -17
- package/src/subscriptions/SubscriptionManager.test.ts +0 -556
- package/src/subscriptions/SubscriptionManager.ts +0 -274
- package/src/subscriptions/types.ts +0 -20
- package/src/types.ts +0 -88
- package/src/utils/backoff.test.ts +0 -48
- package/src/utils/backoff.ts +0 -15
- package/tsconfig.lint.json +0 -8
- package/vitest.config.ts +0 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luxexchange/websocket",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"partysocket": "1.1.10",
|
|
6
6
|
"zustand": "5.0.6"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"devDependencies": {
|
|
9
9
|
"@types/node": "22.13.1",
|
|
10
10
|
"@typescript/native-preview": "7.0.0-dev.20260108.1",
|
|
11
|
-
"@luxfi/eslint-config": "
|
|
11
|
+
"@luxfi/eslint-config": "workspace:^",
|
|
12
12
|
"@vitest/coverage-v8": "3.2.1",
|
|
13
13
|
"depcheck": "1.4.7",
|
|
14
14
|
"eslint": "8.57.1",
|
package/.depcheckrc
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
ignores: [
|
|
2
|
-
# Dependencies that depcheck incorrectly marks as unused
|
|
3
|
-
"typescript",
|
|
4
|
-
"@typescript/native-preview",
|
|
5
|
-
"depcheck",
|
|
6
|
-
|
|
7
|
-
# Test dependencies (used by vitest config)
|
|
8
|
-
"@vitest/coverage-v8",
|
|
9
|
-
|
|
10
|
-
# Core dependencies (will be used when implementation is added)
|
|
11
|
-
"partysocket",
|
|
12
|
-
|
|
13
|
-
# Internal packages / workspaces
|
|
14
|
-
"@universe/websocket",
|
|
15
|
-
]
|
package/.eslintrc.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
module.exports = {
|
|
2
|
-
extends: ['@luxfi/eslint-config/lib'],
|
|
3
|
-
parserOptions: {
|
|
4
|
-
tsconfigRootDir: __dirname,
|
|
5
|
-
},
|
|
6
|
-
overrides: [
|
|
7
|
-
{
|
|
8
|
-
files: ['*.ts', '*.tsx'],
|
|
9
|
-
rules: {
|
|
10
|
-
'no-relative-import-paths/no-relative-import-paths': [
|
|
11
|
-
'error',
|
|
12
|
-
{
|
|
13
|
-
allowSameFolder: false,
|
|
14
|
-
prefix: '@universe/websocket',
|
|
15
|
-
},
|
|
16
|
-
],
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
],
|
|
20
|
-
}
|
package/README.md
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
# @universe/websocket
|
|
2
|
-
|
|
3
|
-
A generic, type-safe WebSocket client with built-in subscription management, automatic reconnection, and reference counting.
|
|
4
|
-
|
|
5
|
-
## Features
|
|
6
|
-
|
|
7
|
-
- **Lazy connection lifecycle** - Connects on first subscribe, disconnects on last unsubscribe
|
|
8
|
-
- **Microtask batching** - Coalesces subscribe/unsubscribe calls within the same microtask via `queueMicrotask`
|
|
9
|
-
- **Subscription deduplication** - Multiple subscribers to the same params share one subscription via reference counting
|
|
10
|
-
- **Auto-resubscribe** - Automatically resubscribes all active subscriptions after reconnection
|
|
11
|
-
- **Message routing** - Routes incoming messages to appropriate subscriber callbacks
|
|
12
|
-
- **Type-safe** - Full TypeScript generics for params and message types
|
|
13
|
-
- **Framework agnostic** - Works with any framework; consumers provide their own REST handlers
|
|
14
|
-
|
|
15
|
-
## Installation
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
bun add @universe/websocket
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
## Quick Start
|
|
22
|
-
|
|
23
|
-
```typescript
|
|
24
|
-
import { createWebSocketClient } from '@universe/websocket'
|
|
25
|
-
|
|
26
|
-
// Define your param and message types
|
|
27
|
-
interface PriceParams {
|
|
28
|
-
tokenAddress: string
|
|
29
|
-
chainId: number
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface PriceMessage {
|
|
33
|
-
price: number
|
|
34
|
-
timestamp: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Create the client
|
|
38
|
-
const client = createWebSocketClient<PriceParams, PriceMessage>({
|
|
39
|
-
config: {
|
|
40
|
-
url: 'wss://api.example.com/ws',
|
|
41
|
-
},
|
|
42
|
-
subscriptionHandler: {
|
|
43
|
-
subscribe: async (connectionId, params) => {
|
|
44
|
-
await fetch('/api/subscribe', {
|
|
45
|
-
method: 'POST',
|
|
46
|
-
body: JSON.stringify({ connectionId, ...params }),
|
|
47
|
-
})
|
|
48
|
-
},
|
|
49
|
-
unsubscribe: async (connectionId, params) => {
|
|
50
|
-
await fetch('/api/unsubscribe', {
|
|
51
|
-
method: 'POST',
|
|
52
|
-
body: JSON.stringify({ connectionId, ...params }),
|
|
53
|
-
})
|
|
54
|
-
},
|
|
55
|
-
// Optional: batch subscribe/unsubscribe for efficiency
|
|
56
|
-
subscribeBatch: async (connectionId, paramsList) => {
|
|
57
|
-
await fetch('/api/subscribe-batch', {
|
|
58
|
-
method: 'POST',
|
|
59
|
-
body: JSON.stringify({ connectionId, subscriptions: paramsList }),
|
|
60
|
-
})
|
|
61
|
-
},
|
|
62
|
-
unsubscribeBatch: async (connectionId, paramsList) => {
|
|
63
|
-
await fetch('/api/unsubscribe-batch', {
|
|
64
|
-
method: 'POST',
|
|
65
|
-
body: JSON.stringify({ connectionId, subscriptions: paramsList }),
|
|
66
|
-
})
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
parseConnectionMessage: (raw) => {
|
|
70
|
-
if (raw && typeof raw === 'object' && 'connectionId' in raw) {
|
|
71
|
-
return { connectionId: raw.connectionId as string }
|
|
72
|
-
}
|
|
73
|
-
return null
|
|
74
|
-
},
|
|
75
|
-
parseMessage: (raw) => {
|
|
76
|
-
if (raw && typeof raw === 'object' && 'channel' in raw && 'data' in raw) {
|
|
77
|
-
const msg = raw as { channel: string; tokenAddress: string; chainId: number; data: PriceMessage }
|
|
78
|
-
return {
|
|
79
|
-
channel: msg.channel,
|
|
80
|
-
key: `${msg.channel}:${msg.tokenAddress}:${msg.chainId}`,
|
|
81
|
-
data: msg.data,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
return null
|
|
85
|
-
},
|
|
86
|
-
createSubscriptionKey: (channel, params) => `${channel}:${params.tokenAddress}:${params.chainId}`,
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
// Subscribe — connection opens automatically on first subscribe
|
|
90
|
-
const unsubscribe = client.subscribe({
|
|
91
|
-
channel: 'prices',
|
|
92
|
-
params: { tokenAddress: '0x...', chainId: 1 },
|
|
93
|
-
onMessage: (message) => {
|
|
94
|
-
console.log('Price update:', message.price)
|
|
95
|
-
},
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
// Later: unsubscribe — connection closes automatically when last subscriber leaves
|
|
99
|
-
unsubscribe()
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Architecture
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
┌─────────────────────────────────────────────────────────────┐
|
|
106
|
-
│ WebSocketClient │
|
|
107
|
-
│ ┌─────────────────┐ ┌─────────────────────────────────┐ │
|
|
108
|
-
│ │ PartySocket │ │ SubscriptionManager │ │
|
|
109
|
-
│ │ (connection) │ │ - Reference counting │ │
|
|
110
|
-
│ │ │ │ - Microtask batching │ │
|
|
111
|
-
│ │ │ │ - Auto-resubscribe │ │
|
|
112
|
-
│ │ │ │ - Message dispatch │ │
|
|
113
|
-
│ └─────────────────┘ └─────────────────────────────────┘ │
|
|
114
|
-
│ ┌─────────────────────────────────────────────────────┐ │
|
|
115
|
-
│ │ ConnectionStore (Zustand) │ │
|
|
116
|
-
│ │ - status: disconnected|connecting|connected|... │ │
|
|
117
|
-
│ │ - connectionId: string | null │ │
|
|
118
|
-
│ │ - error: Error | null │ │
|
|
119
|
-
│ └─────────────────────────────────────────────────────┘ │
|
|
120
|
-
└─────────────────────────────────────────────────────────────┘
|
|
121
|
-
│
|
|
122
|
-
▼
|
|
123
|
-
Consumer-provided handlers
|
|
124
|
-
(REST subscribe/unsubscribe)
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
## Lazy Connection Lifecycle
|
|
128
|
-
|
|
129
|
-
The client manages its WebSocket connection automatically:
|
|
130
|
-
|
|
131
|
-
1. **First subscribe** - Opens WebSocket connection
|
|
132
|
-
2. **Connection established** - Receives connectionId, resubscribes all queued subscriptions
|
|
133
|
-
3. **Last unsubscribe** - Closes WebSocket connection and cleans up state
|
|
134
|
-
|
|
135
|
-
There are no `connect()` or `disconnect()` methods — the connection lifecycle is driven entirely by subscription activity.
|
|
136
|
-
|
|
137
|
-
## API Reference
|
|
138
|
-
|
|
139
|
-
### `createWebSocketClient<TParams, TMessage>(options)`
|
|
140
|
-
|
|
141
|
-
Creates a new WebSocket client instance.
|
|
142
|
-
|
|
143
|
-
#### Options
|
|
144
|
-
|
|
145
|
-
| Option | Type | Required | Description |
|
|
146
|
-
|--------|------|----------|-------------|
|
|
147
|
-
| `config` | `ConnectionConfig` | Yes | Connection configuration |
|
|
148
|
-
| `subscriptionHandler` | `SubscriptionHandler<TParams>` | Yes | REST API handlers for subscribe/unsubscribe |
|
|
149
|
-
| `parseMessage` | `(raw: unknown) => { channel, key, data } \| null` | Yes | Parse incoming WebSocket messages |
|
|
150
|
-
| `parseConnectionMessage` | `(raw: unknown) => { connectionId } \| null` | Yes | Parse connection established message |
|
|
151
|
-
| `createSubscriptionKey` | `(channel, params) => string` | Yes | Create unique key for subscription deduplication |
|
|
152
|
-
| `onError` | `(error: unknown) => void` | No | Error callback |
|
|
153
|
-
| `onRawMessage` | `(message: unknown) => void` | No | Raw message callback (for external caching or debugging) |
|
|
154
|
-
|
|
155
|
-
#### SubscriptionHandler
|
|
156
|
-
|
|
157
|
-
| Method | Type | Required | Description |
|
|
158
|
-
|--------|------|----------|-------------|
|
|
159
|
-
| `subscribe` | `(connectionId, params) => Promise<void>` | Yes | Subscribe to a single channel |
|
|
160
|
-
| `unsubscribe` | `(connectionId, params) => Promise<void>` | Yes | Unsubscribe from a single channel |
|
|
161
|
-
| `subscribeBatch` | `(connectionId, params[]) => Promise<void>` | No | Subscribe to multiple channels at once |
|
|
162
|
-
| `unsubscribeBatch` | `(connectionId, params[]) => Promise<void>` | No | Unsubscribe from multiple channels at once |
|
|
163
|
-
| `refreshSession` | `(connectionId) => Promise<void>` | No | Refresh the session |
|
|
164
|
-
|
|
165
|
-
When `subscribeBatch`/`unsubscribeBatch` are provided, batched calls use them. Otherwise, individual `subscribe`/`unsubscribe` calls are made for each param in the batch.
|
|
166
|
-
|
|
167
|
-
#### ConnectionConfig
|
|
168
|
-
|
|
169
|
-
| Option | Type | Default | Description |
|
|
170
|
-
|--------|------|---------|-------------|
|
|
171
|
-
| `url` | `string` | - | WebSocket URL |
|
|
172
|
-
| `maxReconnectionDelay` | `number` | `10000` | Maximum delay (ms) between reconnection attempts |
|
|
173
|
-
| `minReconnectionDelay` | `number` | `1000` | Minimum delay (ms) before jitter is applied |
|
|
174
|
-
| `connectionTimeout` | `number` | `4000` | Time (ms) to wait for connection |
|
|
175
|
-
| `maxRetries` | `number` | `5` | Maximum reconnection attempts |
|
|
176
|
-
| `debug` | `boolean` | `false` | Enable debug logging |
|
|
177
|
-
|
|
178
|
-
#### Returns: `WebSocketClient<TParams, TMessage>`
|
|
179
|
-
|
|
180
|
-
| Method | Description |
|
|
181
|
-
|--------|-------------|
|
|
182
|
-
| `isConnected()` | Check if currently connected |
|
|
183
|
-
| `getConnectionStatus()` | Get current status: `'disconnected' \| 'connecting' \| 'connected' \| 'reconnecting'` |
|
|
184
|
-
| `getConnectionId()` | Get current connection ID or null |
|
|
185
|
-
| `subscribe(options)` | Subscribe to a channel, returns unsubscribe function |
|
|
186
|
-
| `onStatusChange(callback)` | Listen to status changes, returns cleanup function |
|
|
187
|
-
| `onConnectionEstablished(callback)` | Listen to connection events, returns cleanup function |
|
|
188
|
-
|
|
189
|
-
#### SubscriptionOptions
|
|
190
|
-
|
|
191
|
-
| Option | Type | Required | Description |
|
|
192
|
-
|--------|------|----------|-------------|
|
|
193
|
-
| `channel` | `string` | Yes | Channel name |
|
|
194
|
-
| `params` | `TParams` | Yes | Subscription parameters |
|
|
195
|
-
| `onMessage` | `(message: TMessage) => void` | No | Callback for incoming messages. Omit when using `onRawMessage` for external cache population. |
|
|
196
|
-
|
|
197
|
-
### `SubscriptionManager<TParams, TMessage>`
|
|
198
|
-
|
|
199
|
-
Lower-level subscription manager with reference counting and microtask batching. Used internally by `createWebSocketClient`, but can be used directly for custom implementations.
|
|
200
|
-
|
|
201
|
-
```typescript
|
|
202
|
-
import { SubscriptionManager } from '@universe/websocket'
|
|
203
|
-
|
|
204
|
-
const manager = new SubscriptionManager<MyParams, MyMessage>({
|
|
205
|
-
handler: subscriptionHandler,
|
|
206
|
-
createKey: (channel, params) => `${channel}:${params.id}`,
|
|
207
|
-
onError: (error, operation) => console.error(operation, error),
|
|
208
|
-
onSubscriptionCountChange: (count) => {
|
|
209
|
-
// React to subscription count changes (e.g., lazy connect/disconnect)
|
|
210
|
-
},
|
|
211
|
-
})
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
## Microtask Batching
|
|
215
|
-
|
|
216
|
-
Subscribe and unsubscribe calls are batched within the same microtask using `queueMicrotask`:
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
// These three subscribes are coalesced into a single subscribeBatch call
|
|
220
|
-
const unsub1 = client.subscribe({ channel: 'prices', params: paramsA, onMessage: handleA })
|
|
221
|
-
const unsub2 = client.subscribe({ channel: 'prices', params: paramsB, onMessage: handleB })
|
|
222
|
-
const unsub3 = client.subscribe({ channel: 'events', params: paramsC, onMessage: handleC })
|
|
223
|
-
// ^ After the microtask flushes: one subscribeBatch([paramsA, paramsB, paramsC]) call
|
|
224
|
-
|
|
225
|
-
// Subscribe + immediate unsubscribe in the same microtask = net-zero API calls
|
|
226
|
-
const unsub = client.subscribe({ channel: 'prices', params: paramsA, onMessage: handle })
|
|
227
|
-
unsub()
|
|
228
|
-
// ^ The pending subscribe and unsubscribe cancel each other out — no REST calls made
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
## How Reference Counting Works
|
|
232
|
-
|
|
233
|
-
When multiple components subscribe to the same params:
|
|
234
|
-
|
|
235
|
-
1. **First subscriber** - Queues REST subscribe API call
|
|
236
|
-
2. **Additional subscribers** - Just adds callback, no REST call
|
|
237
|
-
3. **Subscriber leaves** - Removes callback
|
|
238
|
-
4. **Last subscriber leaves** - Queues REST unsubscribe API call
|
|
239
|
-
|
|
240
|
-
```typescript
|
|
241
|
-
// Component A subscribes - REST subscribe queued
|
|
242
|
-
const unsubA = client.subscribe({ channel: 'prices', params, onMessage: handleA })
|
|
243
|
-
|
|
244
|
-
// Component B subscribes to same params - no REST call, shares subscription
|
|
245
|
-
const unsubB = client.subscribe({ channel: 'prices', params, onMessage: handleB })
|
|
246
|
-
|
|
247
|
-
// Component A unsubscribes - just removes callback
|
|
248
|
-
unsubA()
|
|
249
|
-
|
|
250
|
-
// Component B unsubscribes - REST unsubscribe queued (last subscriber)
|
|
251
|
-
unsubB()
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
## Reconnection Behavior
|
|
255
|
-
|
|
256
|
-
The client uses jittered exponential backoff to prevent thundering herd:
|
|
257
|
-
|
|
258
|
-
1. Connection drops -> status becomes `'reconnecting'`
|
|
259
|
-
2. Waits `minDelay + random(0, 4000)ms` before first attempt
|
|
260
|
-
3. Each subsequent attempt multiplies delay by 1.3x (up to `maxDelay`)
|
|
261
|
-
4. On successful reconnect, automatically resubscribes all active subscriptions
|
|
262
|
-
5. After `maxRetries` failures, stops attempting
|
|
263
|
-
|
|
264
|
-
## Development
|
|
265
|
-
|
|
266
|
-
```bash
|
|
267
|
-
# Run tests
|
|
268
|
-
bun websocket test
|
|
269
|
-
|
|
270
|
-
# Type check
|
|
271
|
-
bun websocket typecheck
|
|
272
|
-
|
|
273
|
-
# Lint
|
|
274
|
-
bun websocket lint:fix
|
|
275
|
-
```
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { WebSocketLike } from '@luxexchange/websocket/src/types'
|
|
2
|
-
|
|
3
|
-
type EventHandler = (event: unknown) => void
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Mock WebSocket implementation for testing.
|
|
7
|
-
* Simulates WebSocket behavior without actual network connections.
|
|
8
|
-
*/
|
|
9
|
-
export class MockWebSocket implements WebSocketLike {
|
|
10
|
-
readyState: number = WebSocket.CONNECTING
|
|
11
|
-
|
|
12
|
-
private listeners = new Map<string, Set<EventHandler>>()
|
|
13
|
-
|
|
14
|
-
addEventListener(event: string, handler: EventHandler): void {
|
|
15
|
-
if (!this.listeners.has(event)) {
|
|
16
|
-
this.listeners.set(event, new Set())
|
|
17
|
-
}
|
|
18
|
-
this.listeners.get(event)?.add(handler)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
close(): void {
|
|
22
|
-
this.readyState = WebSocket.CLOSED
|
|
23
|
-
this.emit('close', { code: 1000, reason: 'Normal closure' })
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Test helpers - simulate server behavior
|
|
27
|
-
|
|
28
|
-
simulateOpen(): void {
|
|
29
|
-
this.readyState = WebSocket.OPEN
|
|
30
|
-
this.emit('open', {})
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
simulateClose(code = 1000, reason = 'Connection closed'): void {
|
|
34
|
-
this.readyState = WebSocket.CLOSED
|
|
35
|
-
this.emit('close', { code, reason })
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
simulateError(message = 'WebSocket error'): void {
|
|
39
|
-
this.emit('error', { message })
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
simulateMessage(data: unknown): void {
|
|
43
|
-
this.emit('message', { data: JSON.stringify(data) })
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private emit(event: string, payload: unknown): void {
|
|
47
|
-
const handlers = this.listeners.get(event)
|
|
48
|
-
if (handlers) {
|
|
49
|
-
for (const handler of handlers) {
|
|
50
|
-
handler(payload)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|