@hardlydifficult/websocket 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +316 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# @hardlydifficult/websocket
|
|
2
|
+
|
|
3
|
+
A resilient WebSocket client for Node.js with automatic reconnection, heartbeat-based dead connection detection, and graceful request draining.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @hardlydifficult/websocket
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js 18+.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { ReconnectingWebSocket } from "@hardlydifficult/websocket";
|
|
17
|
+
|
|
18
|
+
interface Message {
|
|
19
|
+
type: string;
|
|
20
|
+
data: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = new ReconnectingWebSocket<Message>({
|
|
24
|
+
url: "ws://localhost:8080",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
client.on("open", () => {
|
|
28
|
+
console.log("Connected");
|
|
29
|
+
client.send({ type: "hello", data: "world" });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
client.on("message", (msg) => {
|
|
33
|
+
console.log("Received:", msg);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
client.on("error", (err) => {
|
|
37
|
+
console.error("Error:", err);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
client.connect();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## ReconnectingWebSocket
|
|
44
|
+
|
|
45
|
+
A generic WebSocket client that automatically reconnects on disconnection, sends protocol-level pings for heartbeats, and parses JSON messages.
|
|
46
|
+
|
|
47
|
+
### Constructor
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
const client = new ReconnectingWebSocket<T>(options: WebSocketOptions);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Option | Type | Default | Description |
|
|
54
|
+
|--------|------|---------|-------------|
|
|
55
|
+
| `url` | `string` | — | WebSocket server URL (required) |
|
|
56
|
+
| `backoff.initialDelayMs` | `number` | `1000` | Initial reconnection delay in milliseconds |
|
|
57
|
+
| `backoff.maxDelayMs` | `number` | `30000` | Maximum reconnection delay in milliseconds |
|
|
58
|
+
| `backoff.multiplier` | `number` | `2` | Multiplier applied per reconnection attempt |
|
|
59
|
+
| `heartbeat.intervalMs` | `number` | `30000` | Interval between pings in milliseconds |
|
|
60
|
+
| `heartbeat.timeoutMs` | `number` | `10000` | Time to wait for pong before terminating |
|
|
61
|
+
|
|
62
|
+
### Methods
|
|
63
|
+
|
|
64
|
+
#### `connect(): void`
|
|
65
|
+
|
|
66
|
+
Connect to the WebSocket server. Idempotent — calling multiple times has no additional effect. If a reconnect timer is pending, cancels it and connects immediately, resetting the attempt counter.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
client.connect();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
#### `disconnect(): void`
|
|
73
|
+
|
|
74
|
+
Disconnect from the server and stop all reconnection attempts. Closes the socket with code 1000.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
client.disconnect();
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### `send(message: T): void`
|
|
81
|
+
|
|
82
|
+
Send a message as JSON. No-op if not currently connected.
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
client.send({ type: "ping" });
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### `stopReconnecting(): void`
|
|
89
|
+
|
|
90
|
+
Prevent reconnection without closing the current connection. Useful for graceful shutdown: deliver in-flight results but do not reconnect if the socket drops.
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
client.stopReconnecting();
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
#### `on<K extends keyof WebSocketEvents<T>>(event: K, listener: WebSocketEvents<T>[K]): () => void`
|
|
97
|
+
|
|
98
|
+
Subscribe to a WebSocket lifecycle event. Multiple listeners per event are supported. Returns an unsubscribe function.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const unsubscribe = client.on("message", (msg) => {
|
|
102
|
+
console.log(msg);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
unsubscribe(); // Stop listening
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Events
|
|
109
|
+
|
|
110
|
+
#### `open`
|
|
111
|
+
|
|
112
|
+
Fired when the connection is established.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
client.on("open", () => {
|
|
116
|
+
console.log("Connected");
|
|
117
|
+
});
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `close`
|
|
121
|
+
|
|
122
|
+
Fired when the connection is closed.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
client.on("close", (code: number, reason: string) => {
|
|
126
|
+
console.log(`Closed with code ${code}: ${reason}`);
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### `error`
|
|
131
|
+
|
|
132
|
+
Fired on connection or parse errors.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
client.on("error", (error: Error) => {
|
|
136
|
+
console.error("Error:", error.message);
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
#### `message`
|
|
141
|
+
|
|
142
|
+
Fired when a message is received and parsed.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
client.on("message", (data: T) => {
|
|
146
|
+
console.log("Received:", data);
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Properties
|
|
151
|
+
|
|
152
|
+
#### `connected: boolean`
|
|
153
|
+
|
|
154
|
+
Whether the socket is currently open.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
if (client.connected) {
|
|
158
|
+
client.send({ type: "ping" });
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Exponential Backoff
|
|
163
|
+
|
|
164
|
+
The client uses exponential backoff for reconnection delays. The delay for attempt `n` is calculated as:
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
delay = min(initialDelayMs × multiplier^n, maxDelayMs)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
For example, with default settings (initial: 1000ms, max: 30000ms, multiplier: 2):
|
|
171
|
+
- Attempt 0: 1000ms
|
|
172
|
+
- Attempt 1: 2000ms
|
|
173
|
+
- Attempt 2: 4000ms
|
|
174
|
+
- Attempt 3: 8000ms
|
|
175
|
+
- Attempt 4+: 30000ms (capped)
|
|
176
|
+
|
|
177
|
+
You can access this calculation directly:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
import { getBackoffDelay } from "@hardlydifficult/websocket";
|
|
181
|
+
|
|
182
|
+
const delay = getBackoffDelay(2, {
|
|
183
|
+
initialDelayMs: 1000,
|
|
184
|
+
maxDelayMs: 30000,
|
|
185
|
+
multiplier: 2,
|
|
186
|
+
});
|
|
187
|
+
// delay = 4000
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## RequestTracker
|
|
191
|
+
|
|
192
|
+
Tracks active requests and manages draining state. Centralizes the pattern of rejecting new work during shutdown and notifying listeners when the last request completes.
|
|
193
|
+
|
|
194
|
+
### Constructor
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const tracker = new RequestTracker();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Methods
|
|
201
|
+
|
|
202
|
+
#### `tryAccept(): boolean`
|
|
203
|
+
|
|
204
|
+
Try to accept a new request. Returns `false` if draining — caller should send a rejection response.
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
if (tracker.tryAccept()) {
|
|
208
|
+
// Process request
|
|
209
|
+
tracker.complete();
|
|
210
|
+
} else {
|
|
211
|
+
// Send rejection (service is shutting down)
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
#### `complete(): void`
|
|
216
|
+
|
|
217
|
+
Mark a request as complete. Decrements the active count and emits `drained` when the last request finishes during a drain.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
tracker.complete();
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
#### `startDraining(reason: string): void`
|
|
224
|
+
|
|
225
|
+
Enter draining mode — no new requests will be accepted. Idempotent: subsequent calls are ignored. Emits `draining` immediately and `drained` when active reaches zero.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
tracker.startDraining("server shutting down");
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### `on<K extends keyof RequestTrackerEvents>(event: K, listener: RequestTrackerEvents[K]): () => void`
|
|
232
|
+
|
|
233
|
+
Subscribe to a RequestTracker event. Returns an unsubscribe function.
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
const unsubscribe = tracker.on("drained", () => {
|
|
237
|
+
console.log("All requests completed");
|
|
238
|
+
});
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Events
|
|
242
|
+
|
|
243
|
+
#### `draining`
|
|
244
|
+
|
|
245
|
+
Fired when draining mode is entered.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
tracker.on("draining", (reason: string) => {
|
|
249
|
+
console.log(`Draining: ${reason}`);
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### `drained`
|
|
254
|
+
|
|
255
|
+
Fired when all active requests complete during drain.
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
tracker.on("drained", () => {
|
|
259
|
+
console.log("Ready to shut down");
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Properties
|
|
264
|
+
|
|
265
|
+
#### `draining: boolean`
|
|
266
|
+
|
|
267
|
+
Whether the tracker is in draining mode.
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
if (tracker.draining) {
|
|
271
|
+
console.log("Not accepting new requests");
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
#### `active: number`
|
|
276
|
+
|
|
277
|
+
Number of currently active requests.
|
|
278
|
+
|
|
279
|
+
```typescript
|
|
280
|
+
console.log(`${tracker.active} requests in flight`);
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Example: Graceful Shutdown
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import { ReconnectingWebSocket, RequestTracker } from "@hardlydifficult/websocket";
|
|
287
|
+
|
|
288
|
+
const client = new ReconnectingWebSocket({ url: "ws://localhost:8080" });
|
|
289
|
+
const tracker = new RequestTracker();
|
|
290
|
+
|
|
291
|
+
client.on("message", (msg) => {
|
|
292
|
+
if (!tracker.tryAccept()) {
|
|
293
|
+
// Reject new requests during shutdown
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Process message
|
|
298
|
+
processMessage(msg);
|
|
299
|
+
tracker.complete();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Initiate graceful shutdown
|
|
303
|
+
async function shutdown() {
|
|
304
|
+
client.stopReconnecting();
|
|
305
|
+
tracker.startDraining("server shutting down");
|
|
306
|
+
|
|
307
|
+
// Wait for all in-flight requests to complete
|
|
308
|
+
await new Promise<void>((resolve) => {
|
|
309
|
+
tracker.on("drained", () => {
|
|
310
|
+
resolve();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
client.disconnect();
|
|
315
|
+
}
|
|
316
|
+
```
|