react-ws-kit 1.0.1 → 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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 react-websocket-kit contributors
3
+ Copyright (c) 2025 react-ws-kit contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
- return {
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
- return {
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.1",
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",
@@ -21,7 +21,8 @@
21
21
  "build": "tsup src/index.ts --format cjs,esm --dts",
22
22
  "prepublishOnly": "npm run build",
23
23
  "test": "vitest run",
24
- "test:watch": "vitest"
24
+ "test:watch": "vitest",
25
+ "test:coverage": "vitest run --coverage"
25
26
  },
26
27
  "repository": {
27
28
  "type": "git",
@@ -38,6 +39,7 @@
38
39
  "devDependencies": {
39
40
  "@testing-library/react": "^14.1.2",
40
41
  "@types/react": "^18.2.45",
42
+ "@vitest/coverage-v8": "^1.6.1",
41
43
  "@vitest/ui": "^1.1.0",
42
44
  "happy-dom": "^12.10.3",
43
45
  "react": "^18.2.0",
@@ -57,7 +59,9 @@
57
59
  "message-queue",
58
60
  "typed-websocket",
59
61
  "react-hook",
60
- "websocket-client"
62
+ "websocket-client",
63
+ "heartbeat",
64
+ "ping-pong"
61
65
  ],
62
66
  "license": "MIT",
63
67
  "sideEffects": false,
@@ -65,4 +69,3 @@
65
69
  "node": ">=18.0.0"
66
70
  }
67
71
  }
68
-