@iam4x/reconnecting-websocket 1.1.0 → 1.3.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 CHANGED
@@ -5,7 +5,9 @@ A robust, TypeScript-first WebSocket client with automatic reconnection, exponen
5
5
  ## Features
6
6
 
7
7
  - ✅ **Automatic Reconnection** - Automatically reconnects on connection loss with exponential backoff
8
+ - ✅ **Message Queueing** - Messages sent while disconnected are queued and delivered on reconnection
8
9
  - ✅ **Connection Timeout** - Configurable timeout to detect stalled connections
10
+ - ✅ **Inactivity Detection** - Optionally reconnect when no messages are received within a timeout
9
11
  - ✅ **Event-Driven API** - Familiar event listener pattern matching WebSocket API
10
12
  - ✅ **TypeScript Support** - Full TypeScript definitions included
11
13
  - ✅ **Customizable** - Configurable retry delays, backoff factors, and WebSocket implementations
@@ -76,6 +78,8 @@ interface ReconnectOptions {
76
78
  maxRetryDelay?: number; // Maximum retry delay in ms (default: 30000)
77
79
  connectionTimeout?: number; // Connection timeout in ms (default: 10000)
78
80
  backoffFactor?: number; // Exponential backoff multiplier (default: 2)
81
+ healthCheckInterval?: number; // Health check interval in ms (default: 30000)
82
+ watchingInactivityTimeout?: number; // Inactivity timeout in ms (default: 0, disabled)
79
83
  WebSocketConstructor?: typeof WebSocket; // Custom WebSocket implementation
80
84
  }
81
85
  ```
@@ -86,6 +90,8 @@ interface ReconnectOptions {
86
90
  - **maxRetryDelay**: The maximum delay between reconnection attempts. The delay will grow exponentially but won't exceed this value
87
91
  - **connectionTimeout**: If a connection doesn't establish within this time, it will be aborted and retried
88
92
  - **backoffFactor**: The multiplier for exponential backoff. Each retry delay is multiplied by this factor
93
+ - **healthCheckInterval**: Interval for checking if the socket is still healthy. Set to `0` to disable (default: 30000ms)
94
+ - **watchingInactivityTimeout**: If no message is received within this timeout, the connection will be closed and a reconnection attempt will be made. Useful for detecting silent connection failures or keeping connections alive on servers that expect regular activity. Set to `0` to disable (default: 0, disabled). A common value is `300000` (5 minutes)
89
95
  - **WebSocketConstructor**: Allows you to provide a custom WebSocket implementation (useful for Node.js environments using libraries like `ws`)
90
96
 
91
97
  ### Methods
@@ -119,14 +125,14 @@ ws.removeEventListener("open", handler);
119
125
 
120
126
  #### `send(data)`
121
127
 
122
- Sends data through the WebSocket connection.
128
+ Sends data through the WebSocket connection. If the socket is not open, messages are automatically queued and sent once the connection is established.
123
129
 
124
130
  ```typescript
125
131
  ws.send("Hello, Server!");
126
132
  ws.send(JSON.stringify({ type: "ping" }));
127
133
  ```
128
134
 
129
- **Note:** This method will silently fail if the socket is not connected. Check `readyState` before sending if needed.
135
+ **Note:** Messages sent while disconnected are queued and delivered in order when the socket opens. The queue is cleared if `close()` is called.
130
136
 
131
137
  #### `close(code?, reason?)`
132
138
 
@@ -181,6 +187,26 @@ const ws = new ReconnectingWebSocket("wss://api.example.com", {
181
187
  });
182
188
  ```
183
189
 
190
+ ### Inactivity Timeout
191
+
192
+ Use `watchingInactivityTimeout` to automatically reconnect when no messages are received for a period of time. This is useful for detecting silent connection failures or when the server expects regular activity:
193
+
194
+ ```typescript
195
+ const ws = new ReconnectingWebSocket("wss://api.example.com", {
196
+ watchingInactivityTimeout: 300000, // Reconnect if no message received for 5 minutes
197
+ });
198
+
199
+ ws.addEventListener("message", (event: MessageEvent) => {
200
+ // Each message received resets the inactivity timer
201
+ console.log("Received:", event.data);
202
+ });
203
+
204
+ ws.addEventListener("close", (event) => {
205
+ // This will fire when inactivity timeout triggers a reconnect
206
+ console.log("Connection closed:", event.code, event.reason);
207
+ });
208
+ ```
209
+
184
210
  ### Using with Node.js
185
211
 
186
212
  ```typescript
@@ -194,30 +220,24 @@ const ws = new ReconnectingWebSocket("wss://api.example.com", {
194
220
 
195
221
  ### Handling Reconnections
196
222
 
197
- ```typescript
198
- let messageQueue: string[] = [];
223
+ Messages sent while disconnected are automatically queued and delivered when the connection is restored:
199
224
 
200
- ws.addEventListener("open", () => {
201
- // Flush queued messages when reconnected
202
- while (messageQueue.length > 0) {
203
- ws.send(messageQueue.shift()!);
204
- }
205
- });
225
+ ```typescript
226
+ const ws = new ReconnectingWebSocket("wss://api.example.com");
206
227
 
207
228
  ws.addEventListener("reconnect", () => {
208
229
  console.log("Reconnected! Resuming operations...");
209
230
  });
210
231
 
211
- // Queue messages when disconnected
212
- function sendMessage(data: string) {
213
- if (ws.readyState === WebSocket.OPEN) {
214
- ws.send(data);
215
- } else {
216
- messageQueue.push(data);
217
- }
218
- }
232
+ // Safe to call anytime - messages are queued if disconnected
233
+ ws.send("message1");
234
+ ws.send("message2");
235
+
236
+ // When socket opens/reconnects, queued messages are sent automatically
219
237
  ```
220
238
 
239
+ **Note:** Calling `close()` clears the message queue. Messages queued before a forced close are discarded.
240
+
221
241
  ### Error Handling
222
242
 
223
243
  ```typescript
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ interface ReconnectOptions {
6
6
  connectionTimeout?: number;
7
7
  backoffFactor?: number;
8
8
  WebSocketConstructor?: typeof WebSocket;
9
+ healthCheckInterval?: number;
10
+ watchingInactivityTimeout?: number;
9
11
  }
10
12
  export declare class ReconnectingWebSocket {
11
13
  options: Required<ReconnectOptions & {
@@ -15,20 +17,34 @@ export declare class ReconnectingWebSocket {
15
17
  abortController?: AbortController;
16
18
  connectTimeout?: ReturnType<typeof setTimeout>;
17
19
  reconnectTimeout?: ReturnType<typeof setTimeout>;
20
+ healthCheckInterval?: ReturnType<typeof setInterval>;
21
+ inactivityTimeout?: ReturnType<typeof setTimeout>;
18
22
  retryCount: number;
19
23
  forcedClose: boolean;
20
24
  wasConnected: boolean;
25
+ private openFn?;
26
+ private msgFn?;
27
+ private closeFn?;
28
+ private errorFn?;
29
+ private abortHandler?;
21
30
  listeners: Record<EventType, Listener[]>;
31
+ private messageQueue;
22
32
  get readyState(): number;
23
33
  get bufferedAmount(): number;
24
34
  constructor(url: string, options?: ReconnectOptions);
25
35
  connect(): void;
26
36
  emit(event: EventType, payload: any): void;
27
37
  scheduleReconnect(): void;
38
+ startHealthCheck(): void;
39
+ stopHealthCheck(): void;
40
+ startInactivityTimer(): void;
41
+ stopInactivityTimer(): void;
42
+ resetInactivityTimer(): void;
28
43
  clearTimers(): void;
29
44
  addEventListener(event: EventType, listener: Listener): void;
30
45
  removeEventListener(event: EventType, listener: Listener): void;
31
46
  send(...args: Parameters<WebSocket["send"]>): void;
47
+ private flushMessageQueue;
32
48
  close(...args: Parameters<WebSocket["close"]>): void;
33
49
  }
34
50
  export {};
package/dist/index.js CHANGED
@@ -4,9 +4,17 @@ export class ReconnectingWebSocket {
4
4
  abortController;
5
5
  connectTimeout;
6
6
  reconnectTimeout;
7
+ healthCheckInterval;
8
+ inactivityTimeout;
7
9
  retryCount = 0;
8
10
  forcedClose = false;
9
11
  wasConnected = false;
12
+ // Store event handlers so we can remove them when cleaning up
13
+ openFn;
14
+ msgFn;
15
+ closeFn;
16
+ errorFn;
17
+ abortHandler;
10
18
  listeners = {
11
19
  open: [],
12
20
  message: [],
@@ -14,6 +22,8 @@ export class ReconnectingWebSocket {
14
22
  reconnect: [],
15
23
  error: [],
16
24
  };
25
+ // Queue for messages sent when socket is not open
26
+ messageQueue = [];
17
27
  get readyState() {
18
28
  return this.ws?.readyState ?? WebSocket.CLOSED;
19
29
  }
@@ -28,43 +38,93 @@ export class ReconnectingWebSocket {
28
38
  connectionTimeout: options.connectionTimeout ?? 10_000,
29
39
  backoffFactor: options.backoffFactor ?? 2,
30
40
  WebSocketConstructor: options.WebSocketConstructor ?? WebSocket,
41
+ healthCheckInterval: options.healthCheckInterval ?? 30_000,
42
+ watchingInactivityTimeout: options.watchingInactivityTimeout ?? 0, // disabled by default, set to 300_000 for 5 minutes
31
43
  };
32
44
  this.connect();
33
45
  }
34
46
  connect() {
47
+ // Reset forcedClose flag to allow reconnection for new connection attempts
48
+ // This ensures that manual reconnections (via connect()) can auto-reconnect
49
+ this.forcedClose = false;
50
+ // Remove event listeners from old socket
51
+ if (this.openFn)
52
+ this.ws?.removeEventListener("open", this.openFn);
53
+ if (this.msgFn)
54
+ this.ws?.removeEventListener("message", this.msgFn);
55
+ if (this.closeFn)
56
+ this.ws?.removeEventListener("close", this.closeFn);
57
+ if (this.errorFn)
58
+ this.ws?.removeEventListener("error", this.errorFn);
59
+ // Close old socket if still connecting or open
60
+ if (this.ws?.readyState === WebSocket.CONNECTING ||
61
+ this.ws?.readyState === WebSocket.OPEN) {
62
+ this.ws?.close();
63
+ }
64
+ // Clear any pending timers (this also removes abort listener from old controller)
65
+ this.clearTimers();
66
+ // Create new abort controller
35
67
  this.abortController = new AbortController();
36
- this.abortController.signal.addEventListener("abort", () => {
68
+ this.abortHandler = () => {
37
69
  if (this.ws?.readyState === WebSocket.CONNECTING) {
38
70
  this.ws.close();
39
71
  }
40
- });
72
+ };
73
+ this.abortController.signal.addEventListener("abort", this.abortHandler);
74
+ // Create new socket
41
75
  this.ws = new this.options.WebSocketConstructor(this.options.url);
42
76
  this.connectTimeout = setTimeout(() => {
43
77
  this.abortController?.abort();
44
78
  }, this.options.connectionTimeout);
45
- this.ws.addEventListener("open", (event) => {
46
- this.clearTimers();
47
- this.retryCount = 0;
48
- this.emit("open", event);
49
- // Emit reconnect event if this was a reconnection after a disconnection
50
- if (this.wasConnected) {
51
- this.emit("reconnect", event);
79
+ // Create and store new event handlers
80
+ // Capture the new socket reference to check against in handlers
81
+ const currentWs = this.ws;
82
+ this.openFn = (event) => {
83
+ // Only process if event is from the current socket
84
+ if (event.target === currentWs && this.ws === currentWs) {
85
+ this.clearTimers();
86
+ this.retryCount = 0;
87
+ this.emit("open", event);
88
+ // Emit reconnect event if this was a reconnection after a disconnection
89
+ if (this.wasConnected) {
90
+ this.emit("reconnect", event);
91
+ }
92
+ this.wasConnected = true;
93
+ this.startHealthCheck();
94
+ this.startInactivityTimer();
95
+ // Flush any queued messages now that socket is open
96
+ this.flushMessageQueue();
52
97
  }
53
- this.wasConnected = true;
54
- });
55
- this.ws.addEventListener("message", (event) => {
56
- this.emit("message", event);
57
- });
58
- this.ws.addEventListener("close", (event) => {
59
- this.emit("close", { code: event.code, reason: event.reason });
60
- if (!this.forcedClose) {
61
- this.scheduleReconnect();
98
+ };
99
+ this.msgFn = (event) => {
100
+ // Only process if event is from the current socket
101
+ if (event.target === currentWs && this.ws === currentWs) {
102
+ this.resetInactivityTimer();
103
+ this.emit("message", event);
104
+ }
105
+ };
106
+ this.closeFn = (event) => {
107
+ // Only process if event is from the current socket
108
+ if (event.target === currentWs && this.ws === currentWs) {
109
+ this.stopHealthCheck();
110
+ this.stopInactivityTimer();
111
+ this.emit("close", { code: event.code, reason: event.reason });
112
+ if (!this.forcedClose) {
113
+ this.scheduleReconnect();
114
+ }
115
+ }
116
+ };
117
+ this.errorFn = (event) => {
118
+ // Only process if event is from the current socket
119
+ if (event.target === currentWs && this.ws === currentWs) {
120
+ this.emit("error", event);
62
121
  }
63
- });
64
- this.ws.addEventListener("error", (event) => {
65
- this.emit("error", event);
66
- // Error will typically cause connection to close, triggering reconnect
67
- });
122
+ };
123
+ // Add event listeners to new socket
124
+ currentWs.addEventListener("open", this.openFn);
125
+ currentWs.addEventListener("message", this.msgFn);
126
+ currentWs.addEventListener("close", this.closeFn);
127
+ currentWs.addEventListener("error", this.errorFn);
68
128
  }
69
129
  emit(event, payload) {
70
130
  for (const listener of this.listeners[event]) {
@@ -72,11 +132,71 @@ export class ReconnectingWebSocket {
72
132
  }
73
133
  }
74
134
  scheduleReconnect() {
135
+ // Clear any existing reconnect timeout first to prevent multiple reconnects
136
+ if (this.reconnectTimeout) {
137
+ clearTimeout(this.reconnectTimeout);
138
+ this.reconnectTimeout = undefined;
139
+ }
75
140
  const { retryDelay, backoffFactor, maxRetryDelay } = this.options;
76
141
  const delay = Math.min(retryDelay * Math.pow(backoffFactor, this.retryCount), maxRetryDelay);
77
142
  this.retryCount += 1;
78
143
  this.reconnectTimeout = setTimeout(() => this.connect(), delay);
79
144
  }
145
+ startHealthCheck() {
146
+ this.stopHealthCheck();
147
+ if (this.options.healthCheckInterval <= 0) {
148
+ return;
149
+ }
150
+ this.healthCheckInterval = setInterval(() => {
151
+ // Only check if we're not forcing a close and we expect to be connected
152
+ if (this.forcedClose) {
153
+ return;
154
+ }
155
+ // If we've been connected before and the socket is not OPEN, trigger reconnection
156
+ if (this.wasConnected && this.readyState !== WebSocket.OPEN) {
157
+ // Clear the existing socket reference since it's in a bad state
158
+ if (this.ws) {
159
+ // Don't emit close event since we didn't receive one - this is a silent failure
160
+ this.ws = undefined;
161
+ }
162
+ this.stopHealthCheck();
163
+ this.scheduleReconnect();
164
+ }
165
+ }, this.options.healthCheckInterval);
166
+ }
167
+ stopHealthCheck() {
168
+ if (this.healthCheckInterval) {
169
+ clearInterval(this.healthCheckInterval);
170
+ this.healthCheckInterval = undefined;
171
+ }
172
+ }
173
+ startInactivityTimer() {
174
+ this.stopInactivityTimer();
175
+ if (this.options.watchingInactivityTimeout <= 0) {
176
+ return;
177
+ }
178
+ this.inactivityTimeout = setTimeout(() => {
179
+ // Only trigger if we're not forcing a close and we expect to be connected
180
+ if (this.forcedClose) {
181
+ return;
182
+ }
183
+ // Trigger reconnection due to inactivity
184
+ if (this.ws) {
185
+ this.ws.close();
186
+ }
187
+ }, this.options.watchingInactivityTimeout);
188
+ }
189
+ stopInactivityTimer() {
190
+ if (this.inactivityTimeout) {
191
+ clearTimeout(this.inactivityTimeout);
192
+ this.inactivityTimeout = undefined;
193
+ }
194
+ }
195
+ resetInactivityTimer() {
196
+ if (this.options.watchingInactivityTimeout > 0) {
197
+ this.startInactivityTimer();
198
+ }
199
+ }
80
200
  clearTimers() {
81
201
  if (this.connectTimeout) {
82
202
  clearTimeout(this.connectTimeout);
@@ -86,9 +206,16 @@ export class ReconnectingWebSocket {
86
206
  clearTimeout(this.reconnectTimeout);
87
207
  this.reconnectTimeout = undefined;
88
208
  }
89
- if (this.abortController) {
209
+ if (this.abortController && this.abortHandler) {
210
+ this.abortController.signal.removeEventListener("abort", this.abortHandler);
211
+ this.abortController = undefined;
212
+ this.abortHandler = undefined;
213
+ }
214
+ else if (this.abortController) {
90
215
  this.abortController = undefined;
91
216
  }
217
+ this.stopHealthCheck();
218
+ this.stopInactivityTimer();
92
219
  }
93
220
  addEventListener(event, listener) {
94
221
  this.listeners[event].push(listener);
@@ -97,12 +224,35 @@ export class ReconnectingWebSocket {
97
224
  this.listeners[event] = this.listeners[event].filter((l) => l !== listener);
98
225
  }
99
226
  send(...args) {
100
- this.ws?.send(...args);
227
+ if (this.ws?.readyState === WebSocket.OPEN) {
228
+ this.ws.send(...args);
229
+ }
230
+ else {
231
+ this.messageQueue.push(args);
232
+ }
233
+ }
234
+ flushMessageQueue() {
235
+ while (this.messageQueue.length > 0 &&
236
+ this.ws?.readyState === WebSocket.OPEN) {
237
+ const args = this.messageQueue.shift();
238
+ this.ws.send(...args);
239
+ }
101
240
  }
102
241
  close(...args) {
103
242
  this.forcedClose = true;
104
243
  this.clearTimers();
244
+ // Clear the message queue on forced close
245
+ this.messageQueue = [];
105
246
  if (this.ws) {
247
+ // Remove event listeners before closing to prevent memory leaks
248
+ if (this.openFn)
249
+ this.ws.removeEventListener("open", this.openFn);
250
+ if (this.msgFn)
251
+ this.ws.removeEventListener("message", this.msgFn);
252
+ if (this.closeFn)
253
+ this.ws.removeEventListener("close", this.closeFn);
254
+ if (this.errorFn)
255
+ this.ws.removeEventListener("error", this.errorFn);
106
256
  this.ws.close(...args);
107
257
  this.ws = undefined;
108
258
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "module": "src/index.ts",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "version": "1.1.0",
6
+ "version": "1.3.0",
7
7
  "private": false,
8
8
  "types": "./dist/index.d.ts",
9
9
  "exports": {
@@ -24,17 +24,18 @@
24
24
  "prepare": "husky"
25
25
  },
26
26
  "devDependencies": {
27
- "@eslint/js": "^9.38.0",
28
- "@typescript-eslint/parser": "^8.46.2",
29
- "eslint": "^9.38.0",
27
+ "@eslint/js": "^9.39.2",
28
+ "@types/bun": "^1.3.6",
29
+ "@typescript-eslint/parser": "^8.54.0",
30
+ "eslint": "^9.39.2",
30
31
  "eslint-config-prettier": "10.1.1",
31
32
  "eslint-import-resolver-typescript": "4.3.1",
32
33
  "eslint-plugin-import": "^2.32.0",
33
- "eslint-plugin-prettier": "^5.5.4",
34
- "globals": "^16.4.0",
34
+ "eslint-plugin-prettier": "^5.5.5",
35
+ "globals": "^16.5.0",
35
36
  "husky": "^9.1.7",
36
- "prettier": "^3.6.2",
37
- "typescript-eslint": "^8.46.2"
37
+ "prettier": "^3.8.1",
38
+ "typescript-eslint": "^8.54.0"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "typescript": "^5.8.3"