react-ws-kit 1.0.2 → 1.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/README.md +64 -0
- package/dist/index.d.mts +35 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +65 -1
- package/dist/index.mjs +65 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ A production-quality, typed WebSocket hook for React with intelligent connection
|
|
|
9
9
|
- **Per-Hook State**: Each hook maintains its own `allData` history and UI state
|
|
10
10
|
- **Message Queuing**: Optional FIFO queue for offline message buffering
|
|
11
11
|
- **Auto-Reconnect**: Configurable linear backoff strategy
|
|
12
|
+
- **Heartbeat/Ping**: Built-in connection health monitoring with automatic ping/pong
|
|
12
13
|
- **Kill Switch**: Programmatically close connections for all subscribers
|
|
13
14
|
- **Zero Dependencies**: Only peer dependency is React 16.8+ (hooks support)
|
|
14
15
|
|
|
@@ -95,6 +96,18 @@ function TypedChat() {
|
|
|
95
96
|
| `parse` | `(event: MessageEvent) => TIn` | `JSON.parse` | Custom message parser |
|
|
96
97
|
| `serialize` | `(data: TOut) => string` | `JSON.stringify` | Custom message serializer |
|
|
97
98
|
| `key` | `string` | `undefined` | Deterministic key for function identity |
|
|
99
|
+
| `heartbeat` | `HeartbeatOptions` | `undefined` | Heartbeat/ping configuration (see below) |
|
|
100
|
+
|
|
101
|
+
#### Heartbeat Options
|
|
102
|
+
|
|
103
|
+
| Option | Type | Default | Description |
|
|
104
|
+
|--------|------|---------|-------------|
|
|
105
|
+
| `enabled` | `boolean` | `false` | Enable automatic heartbeat/ping |
|
|
106
|
+
| `interval` | `number` | `30000` | Interval between ping messages (ms) |
|
|
107
|
+
| `timeout` | `number` | `5000` | Timeout waiting for pong response (ms) |
|
|
108
|
+
| `pingMessage` | `TOut \| (() => TOut)` | `{ type: 'ping' }` | Message to send as ping |
|
|
109
|
+
| `isPong` | `(message: TIn) => boolean` | `(msg) => msg?.type === 'pong'` | Function to detect pong response |
|
|
110
|
+
| `reconnectOnFailure` | `boolean` | `true` | Trigger reconnection on heartbeat failure |
|
|
98
111
|
|
|
99
112
|
#### Return Value
|
|
100
113
|
|
|
@@ -179,6 +192,57 @@ send({ message: 'World' })
|
|
|
179
192
|
// On reconnect, both messages are sent in order
|
|
180
193
|
```
|
|
181
194
|
|
|
195
|
+
## Heartbeat/Ping
|
|
196
|
+
|
|
197
|
+
Enable automatic connection health monitoring with heartbeat/ping:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
const socket = useSocket('ws://api.example.com/chat', {
|
|
201
|
+
autoConnect: true,
|
|
202
|
+
autoReconnect: true,
|
|
203
|
+
heartbeat: {
|
|
204
|
+
enabled: true,
|
|
205
|
+
interval: 30000, // Send ping every 30 seconds
|
|
206
|
+
timeout: 5000, // Expect pong within 5 seconds
|
|
207
|
+
pingMessage: { type: 'ping' },
|
|
208
|
+
isPong: (msg) => msg.type === 'pong',
|
|
209
|
+
reconnectOnFailure: true // Auto-reconnect if pong not received
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Custom Ping/Pong Messages
|
|
215
|
+
|
|
216
|
+
You can customize the ping message format and pong detection:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const socket = useSocket('ws://api.example.com/chat', {
|
|
220
|
+
heartbeat: {
|
|
221
|
+
enabled: true,
|
|
222
|
+
interval: 20000,
|
|
223
|
+
// Dynamic ping message
|
|
224
|
+
pingMessage: () => ({
|
|
225
|
+
cmd: 'heartbeat',
|
|
226
|
+
timestamp: Date.now()
|
|
227
|
+
}),
|
|
228
|
+
// Custom pong detection
|
|
229
|
+
isPong: (msg) => msg.cmd === 'heartbeat_ack',
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### How It Works
|
|
235
|
+
|
|
236
|
+
1. **Ping Interval**: When connected, a ping message is sent at the configured interval
|
|
237
|
+
2. **Pong Detection**: When a message matching `isPong()` is received, the heartbeat timer is reset
|
|
238
|
+
3. **Timeout**: If no pong is received within the timeout period, the connection is considered unhealthy
|
|
239
|
+
4. **Reconnection**: If `reconnectOnFailure` is true, the socket will close and trigger auto-reconnect
|
|
240
|
+
5. **No Broadcast**: Pong messages are filtered and not broadcast to `allData` or `lastReturnedData`
|
|
241
|
+
|
|
242
|
+
### Native WebSocket Ping/Pong
|
|
243
|
+
|
|
244
|
+
If your WebSocket server uses native WebSocket ping/pong frames (not application-level messages), you don't need to configure heartbeat - the browser handles it automatically!
|
|
245
|
+
|
|
182
246
|
## Testing
|
|
183
247
|
|
|
184
248
|
The package includes comprehensive unit tests:
|
package/dist/index.d.mts
CHANGED
|
@@ -56,6 +56,41 @@ interface Options<TIn = unknown, TOut = unknown> {
|
|
|
56
56
|
* Use when parse/serialize functions are not referentially stable
|
|
57
57
|
*/
|
|
58
58
|
key?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Heartbeat/ping configuration for connection health monitoring
|
|
61
|
+
*/
|
|
62
|
+
heartbeat?: {
|
|
63
|
+
/**
|
|
64
|
+
* Enable automatic heartbeat/ping
|
|
65
|
+
* @default false
|
|
66
|
+
*/
|
|
67
|
+
enabled?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Interval between ping messages in milliseconds
|
|
70
|
+
* @default 30000 (30 seconds)
|
|
71
|
+
*/
|
|
72
|
+
interval?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Timeout waiting for pong response in milliseconds
|
|
75
|
+
* @default 5000 (5 seconds)
|
|
76
|
+
*/
|
|
77
|
+
timeout?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Message to send as ping. Can be a static value or function.
|
|
80
|
+
* @default { type: 'ping' }
|
|
81
|
+
*/
|
|
82
|
+
pingMessage?: TOut | (() => TOut);
|
|
83
|
+
/**
|
|
84
|
+
* Function to detect if an incoming message is a pong response
|
|
85
|
+
* @default (msg) => msg?.type === 'pong'
|
|
86
|
+
*/
|
|
87
|
+
isPong?: (message: TIn) => boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Trigger automatic reconnection on heartbeat failure
|
|
90
|
+
* @default true
|
|
91
|
+
*/
|
|
92
|
+
reconnectOnFailure?: boolean;
|
|
93
|
+
};
|
|
59
94
|
}
|
|
60
95
|
/**
|
|
61
96
|
* Return value of the useSocket hook
|
package/dist/index.d.ts
CHANGED
|
@@ -56,6 +56,41 @@ interface Options<TIn = unknown, TOut = unknown> {
|
|
|
56
56
|
* Use when parse/serialize functions are not referentially stable
|
|
57
57
|
*/
|
|
58
58
|
key?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Heartbeat/ping configuration for connection health monitoring
|
|
61
|
+
*/
|
|
62
|
+
heartbeat?: {
|
|
63
|
+
/**
|
|
64
|
+
* Enable automatic heartbeat/ping
|
|
65
|
+
* @default false
|
|
66
|
+
*/
|
|
67
|
+
enabled?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Interval between ping messages in milliseconds
|
|
70
|
+
* @default 30000 (30 seconds)
|
|
71
|
+
*/
|
|
72
|
+
interval?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Timeout waiting for pong response in milliseconds
|
|
75
|
+
* @default 5000 (5 seconds)
|
|
76
|
+
*/
|
|
77
|
+
timeout?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Message to send as ping. Can be a static value or function.
|
|
80
|
+
* @default { type: 'ping' }
|
|
81
|
+
*/
|
|
82
|
+
pingMessage?: TOut | (() => TOut);
|
|
83
|
+
/**
|
|
84
|
+
* Function to detect if an incoming message is a pong response
|
|
85
|
+
* @default (msg) => msg?.type === 'pong'
|
|
86
|
+
*/
|
|
87
|
+
isPong?: (message: TIn) => boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Trigger automatic reconnection on heartbeat failure
|
|
90
|
+
* @default true
|
|
91
|
+
*/
|
|
92
|
+
reconnectOnFailure?: boolean;
|
|
93
|
+
};
|
|
59
94
|
}
|
|
60
95
|
/**
|
|
61
96
|
* Return value of the useSocket hook
|
package/dist/index.js
CHANGED
|
@@ -143,7 +143,7 @@ var defaultSerialize = (data) => {
|
|
|
143
143
|
return JSON.stringify(data);
|
|
144
144
|
};
|
|
145
145
|
function normalizeOptions(options) {
|
|
146
|
-
|
|
146
|
+
const normalized = {
|
|
147
147
|
autoConnect: options?.autoConnect ?? false,
|
|
148
148
|
protocols: options?.protocols,
|
|
149
149
|
autoReconnect: options?.autoReconnect ?? false,
|
|
@@ -155,11 +155,67 @@ function normalizeOptions(options) {
|
|
|
155
155
|
serialize: options?.serialize ?? defaultSerialize,
|
|
156
156
|
key: options?.key
|
|
157
157
|
};
|
|
158
|
+
if (options?.heartbeat?.enabled) {
|
|
159
|
+
normalized.heartbeat = {
|
|
160
|
+
enabled: true,
|
|
161
|
+
interval: options.heartbeat.interval ?? 3e4,
|
|
162
|
+
timeout: options.heartbeat.timeout ?? 5e3,
|
|
163
|
+
pingMessage: options.heartbeat.pingMessage ?? { type: "ping" },
|
|
164
|
+
isPong: options.heartbeat.isPong ?? ((msg) => msg?.type === "pong"),
|
|
165
|
+
reconnectOnFailure: options.heartbeat.reconnectOnFailure ?? true
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return normalized;
|
|
158
169
|
}
|
|
159
170
|
var subscriberIdCounter = 0;
|
|
160
171
|
function generateSubscriberId() {
|
|
161
172
|
return `sub-${++subscriberIdCounter}-${Date.now()}`;
|
|
162
173
|
}
|
|
174
|
+
function startHeartbeat(instance) {
|
|
175
|
+
if (!instance.config.heartbeat?.enabled || !instance.socket) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const { interval, timeout, pingMessage, reconnectOnFailure } = instance.config.heartbeat;
|
|
179
|
+
const sendPing = () => {
|
|
180
|
+
if (instance.socket?.readyState !== WebSocket.OPEN) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const ping = typeof pingMessage === "function" ? pingMessage() : pingMessage;
|
|
185
|
+
const serialized = instance.config.serialize(ping);
|
|
186
|
+
instance.socket.send(serialized);
|
|
187
|
+
instance.heartbeatTimeout = setTimeout(() => {
|
|
188
|
+
console.warn("[useSocket] Heartbeat timeout - no pong received");
|
|
189
|
+
if (reconnectOnFailure && instance.socket) {
|
|
190
|
+
instance.socket.close();
|
|
191
|
+
}
|
|
192
|
+
}, timeout);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error("[useSocket] Heartbeat ping error:", error);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
instance.heartbeatInterval = setInterval(sendPing, interval);
|
|
198
|
+
sendPing();
|
|
199
|
+
console.log(`[useSocket] Heartbeat started (interval: ${interval}ms, timeout: ${timeout}ms)`);
|
|
200
|
+
}
|
|
201
|
+
function stopHeartbeat(instance) {
|
|
202
|
+
if (instance.heartbeatInterval) {
|
|
203
|
+
clearInterval(instance.heartbeatInterval);
|
|
204
|
+
instance.heartbeatInterval = null;
|
|
205
|
+
}
|
|
206
|
+
if (instance.heartbeatTimeout) {
|
|
207
|
+
clearTimeout(instance.heartbeatTimeout);
|
|
208
|
+
instance.heartbeatTimeout = null;
|
|
209
|
+
}
|
|
210
|
+
console.log("[useSocket] Heartbeat stopped");
|
|
211
|
+
}
|
|
212
|
+
function recordPong(instance) {
|
|
213
|
+
if (instance.heartbeatTimeout) {
|
|
214
|
+
clearTimeout(instance.heartbeatTimeout);
|
|
215
|
+
instance.heartbeatTimeout = null;
|
|
216
|
+
}
|
|
217
|
+
instance.lastPongTime = Date.now();
|
|
218
|
+
}
|
|
163
219
|
function useSocket(url, options) {
|
|
164
220
|
const config = (0, import_react.useRef)(normalizeOptions(options)).current;
|
|
165
221
|
const socketKey = (0, import_react.useRef)(createSocketKey(url, config)).current;
|
|
@@ -254,10 +310,15 @@ function useSocket(url, options) {
|
|
|
254
310
|
instance.reconnectAttemptsMade = 0;
|
|
255
311
|
updateAllSubscribers(instance, "connected");
|
|
256
312
|
flushQueue(instance);
|
|
313
|
+
startHeartbeat(instance);
|
|
257
314
|
};
|
|
258
315
|
ws.onmessage = (event) => {
|
|
259
316
|
try {
|
|
260
317
|
const data = instance.config.parse(event);
|
|
318
|
+
if (instance.config.heartbeat?.enabled && instance.config.heartbeat.isPong(data)) {
|
|
319
|
+
recordPong(instance);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
261
322
|
instance.subscribers.forEach((sub) => {
|
|
262
323
|
if (sub.isConnected) {
|
|
263
324
|
sub.setLastReturnedData(data);
|
|
@@ -274,6 +335,7 @@ function useSocket(url, options) {
|
|
|
274
335
|
};
|
|
275
336
|
ws.onclose = () => {
|
|
276
337
|
console.log("[useSocket] Disconnected:", socketKey);
|
|
338
|
+
stopHeartbeat(instance);
|
|
277
339
|
instance.socket = null;
|
|
278
340
|
if (!instance.killed && instance.config.autoReconnect && instance.refCount > 0) {
|
|
279
341
|
scheduleReconnect(instance);
|
|
@@ -323,6 +385,7 @@ function useSocket(url, options) {
|
|
|
323
385
|
clearTimeout(instance.reconnectTimer);
|
|
324
386
|
instance.reconnectTimer = null;
|
|
325
387
|
}
|
|
388
|
+
stopHeartbeat(instance);
|
|
326
389
|
if (instance.socket) {
|
|
327
390
|
instance.socket.close();
|
|
328
391
|
instance.socket = null;
|
|
@@ -363,6 +426,7 @@ function useSocket(url, options) {
|
|
|
363
426
|
clearTimeout(instance.reconnectTimer);
|
|
364
427
|
instance.reconnectTimer = null;
|
|
365
428
|
}
|
|
429
|
+
stopHeartbeat(instance);
|
|
366
430
|
if (instance.socket) {
|
|
367
431
|
instance.socket.close();
|
|
368
432
|
instance.socket = null;
|
package/dist/index.mjs
CHANGED
|
@@ -116,7 +116,7 @@ var defaultSerialize = (data) => {
|
|
|
116
116
|
return JSON.stringify(data);
|
|
117
117
|
};
|
|
118
118
|
function normalizeOptions(options) {
|
|
119
|
-
|
|
119
|
+
const normalized = {
|
|
120
120
|
autoConnect: options?.autoConnect ?? false,
|
|
121
121
|
protocols: options?.protocols,
|
|
122
122
|
autoReconnect: options?.autoReconnect ?? false,
|
|
@@ -128,11 +128,67 @@ function normalizeOptions(options) {
|
|
|
128
128
|
serialize: options?.serialize ?? defaultSerialize,
|
|
129
129
|
key: options?.key
|
|
130
130
|
};
|
|
131
|
+
if (options?.heartbeat?.enabled) {
|
|
132
|
+
normalized.heartbeat = {
|
|
133
|
+
enabled: true,
|
|
134
|
+
interval: options.heartbeat.interval ?? 3e4,
|
|
135
|
+
timeout: options.heartbeat.timeout ?? 5e3,
|
|
136
|
+
pingMessage: options.heartbeat.pingMessage ?? { type: "ping" },
|
|
137
|
+
isPong: options.heartbeat.isPong ?? ((msg) => msg?.type === "pong"),
|
|
138
|
+
reconnectOnFailure: options.heartbeat.reconnectOnFailure ?? true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return normalized;
|
|
131
142
|
}
|
|
132
143
|
var subscriberIdCounter = 0;
|
|
133
144
|
function generateSubscriberId() {
|
|
134
145
|
return `sub-${++subscriberIdCounter}-${Date.now()}`;
|
|
135
146
|
}
|
|
147
|
+
function startHeartbeat(instance) {
|
|
148
|
+
if (!instance.config.heartbeat?.enabled || !instance.socket) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const { interval, timeout, pingMessage, reconnectOnFailure } = instance.config.heartbeat;
|
|
152
|
+
const sendPing = () => {
|
|
153
|
+
if (instance.socket?.readyState !== WebSocket.OPEN) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const ping = typeof pingMessage === "function" ? pingMessage() : pingMessage;
|
|
158
|
+
const serialized = instance.config.serialize(ping);
|
|
159
|
+
instance.socket.send(serialized);
|
|
160
|
+
instance.heartbeatTimeout = setTimeout(() => {
|
|
161
|
+
console.warn("[useSocket] Heartbeat timeout - no pong received");
|
|
162
|
+
if (reconnectOnFailure && instance.socket) {
|
|
163
|
+
instance.socket.close();
|
|
164
|
+
}
|
|
165
|
+
}, timeout);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error("[useSocket] Heartbeat ping error:", error);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
instance.heartbeatInterval = setInterval(sendPing, interval);
|
|
171
|
+
sendPing();
|
|
172
|
+
console.log(`[useSocket] Heartbeat started (interval: ${interval}ms, timeout: ${timeout}ms)`);
|
|
173
|
+
}
|
|
174
|
+
function stopHeartbeat(instance) {
|
|
175
|
+
if (instance.heartbeatInterval) {
|
|
176
|
+
clearInterval(instance.heartbeatInterval);
|
|
177
|
+
instance.heartbeatInterval = null;
|
|
178
|
+
}
|
|
179
|
+
if (instance.heartbeatTimeout) {
|
|
180
|
+
clearTimeout(instance.heartbeatTimeout);
|
|
181
|
+
instance.heartbeatTimeout = null;
|
|
182
|
+
}
|
|
183
|
+
console.log("[useSocket] Heartbeat stopped");
|
|
184
|
+
}
|
|
185
|
+
function recordPong(instance) {
|
|
186
|
+
if (instance.heartbeatTimeout) {
|
|
187
|
+
clearTimeout(instance.heartbeatTimeout);
|
|
188
|
+
instance.heartbeatTimeout = null;
|
|
189
|
+
}
|
|
190
|
+
instance.lastPongTime = Date.now();
|
|
191
|
+
}
|
|
136
192
|
function useSocket(url, options) {
|
|
137
193
|
const config = useRef(normalizeOptions(options)).current;
|
|
138
194
|
const socketKey = useRef(createSocketKey(url, config)).current;
|
|
@@ -227,10 +283,15 @@ function useSocket(url, options) {
|
|
|
227
283
|
instance.reconnectAttemptsMade = 0;
|
|
228
284
|
updateAllSubscribers(instance, "connected");
|
|
229
285
|
flushQueue(instance);
|
|
286
|
+
startHeartbeat(instance);
|
|
230
287
|
};
|
|
231
288
|
ws.onmessage = (event) => {
|
|
232
289
|
try {
|
|
233
290
|
const data = instance.config.parse(event);
|
|
291
|
+
if (instance.config.heartbeat?.enabled && instance.config.heartbeat.isPong(data)) {
|
|
292
|
+
recordPong(instance);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
234
295
|
instance.subscribers.forEach((sub) => {
|
|
235
296
|
if (sub.isConnected) {
|
|
236
297
|
sub.setLastReturnedData(data);
|
|
@@ -247,6 +308,7 @@ function useSocket(url, options) {
|
|
|
247
308
|
};
|
|
248
309
|
ws.onclose = () => {
|
|
249
310
|
console.log("[useSocket] Disconnected:", socketKey);
|
|
311
|
+
stopHeartbeat(instance);
|
|
250
312
|
instance.socket = null;
|
|
251
313
|
if (!instance.killed && instance.config.autoReconnect && instance.refCount > 0) {
|
|
252
314
|
scheduleReconnect(instance);
|
|
@@ -296,6 +358,7 @@ function useSocket(url, options) {
|
|
|
296
358
|
clearTimeout(instance.reconnectTimer);
|
|
297
359
|
instance.reconnectTimer = null;
|
|
298
360
|
}
|
|
361
|
+
stopHeartbeat(instance);
|
|
299
362
|
if (instance.socket) {
|
|
300
363
|
instance.socket.close();
|
|
301
364
|
instance.socket = null;
|
|
@@ -336,6 +399,7 @@ function useSocket(url, options) {
|
|
|
336
399
|
clearTimeout(instance.reconnectTimer);
|
|
337
400
|
instance.reconnectTimer = null;
|
|
338
401
|
}
|
|
402
|
+
stopHeartbeat(instance);
|
|
339
403
|
if (instance.socket) {
|
|
340
404
|
instance.socket.close();
|
|
341
405
|
instance.socket = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-ws-kit",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Production-quality typed WebSocket hook for React with intelligent connection sharing, auto-reconnect, and message queuing",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -59,7 +59,9 @@
|
|
|
59
59
|
"message-queue",
|
|
60
60
|
"typed-websocket",
|
|
61
61
|
"react-hook",
|
|
62
|
-
"websocket-client"
|
|
62
|
+
"websocket-client",
|
|
63
|
+
"heartbeat",
|
|
64
|
+
"ping-pong"
|
|
63
65
|
],
|
|
64
66
|
"license": "MIT",
|
|
65
67
|
"sideEffects": false,
|