@iam4x/reconnecting-websocket 1.1.0 → 1.2.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/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ interface ReconnectOptions {
6
6
  connectionTimeout?: number;
7
7
  backoffFactor?: number;
8
8
  WebSocketConstructor?: typeof WebSocket;
9
+ healthCheckInterval?: number;
9
10
  }
10
11
  export declare class ReconnectingWebSocket {
11
12
  options: Required<ReconnectOptions & {
@@ -15,20 +16,30 @@ export declare class ReconnectingWebSocket {
15
16
  abortController?: AbortController;
16
17
  connectTimeout?: ReturnType<typeof setTimeout>;
17
18
  reconnectTimeout?: ReturnType<typeof setTimeout>;
19
+ healthCheckInterval?: ReturnType<typeof setInterval>;
18
20
  retryCount: number;
19
21
  forcedClose: boolean;
20
22
  wasConnected: boolean;
23
+ private openFn?;
24
+ private msgFn?;
25
+ private closeFn?;
26
+ private errorFn?;
27
+ private abortHandler?;
21
28
  listeners: Record<EventType, Listener[]>;
29
+ private messageQueue;
22
30
  get readyState(): number;
23
31
  get bufferedAmount(): number;
24
32
  constructor(url: string, options?: ReconnectOptions);
25
33
  connect(): void;
26
34
  emit(event: EventType, payload: any): void;
27
35
  scheduleReconnect(): void;
36
+ startHealthCheck(): void;
37
+ stopHealthCheck(): void;
28
38
  clearTimers(): void;
29
39
  addEventListener(event: EventType, listener: Listener): void;
30
40
  removeEventListener(event: EventType, listener: Listener): void;
31
41
  send(...args: Parameters<WebSocket["send"]>): void;
42
+ private flushMessageQueue;
32
43
  close(...args: Parameters<WebSocket["close"]>): void;
33
44
  }
34
45
  export {};
package/dist/index.js CHANGED
@@ -4,9 +4,16 @@ export class ReconnectingWebSocket {
4
4
  abortController;
5
5
  connectTimeout;
6
6
  reconnectTimeout;
7
+ healthCheckInterval;
7
8
  retryCount = 0;
8
9
  forcedClose = false;
9
10
  wasConnected = false;
11
+ // Store event handlers so we can remove them when cleaning up
12
+ openFn;
13
+ msgFn;
14
+ closeFn;
15
+ errorFn;
16
+ abortHandler;
10
17
  listeners = {
11
18
  open: [],
12
19
  message: [],
@@ -14,6 +21,8 @@ export class ReconnectingWebSocket {
14
21
  reconnect: [],
15
22
  error: [],
16
23
  };
24
+ // Queue for messages sent when socket is not open
25
+ messageQueue = [];
17
26
  get readyState() {
18
27
  return this.ws?.readyState ?? WebSocket.CLOSED;
19
28
  }
@@ -28,43 +37,89 @@ export class ReconnectingWebSocket {
28
37
  connectionTimeout: options.connectionTimeout ?? 10_000,
29
38
  backoffFactor: options.backoffFactor ?? 2,
30
39
  WebSocketConstructor: options.WebSocketConstructor ?? WebSocket,
40
+ healthCheckInterval: options.healthCheckInterval ?? 30_000,
31
41
  };
32
42
  this.connect();
33
43
  }
34
44
  connect() {
45
+ // Reset forcedClose flag to allow reconnection for new connection attempts
46
+ // This ensures that manual reconnections (via connect()) can auto-reconnect
47
+ this.forcedClose = false;
48
+ // Remove event listeners from old socket
49
+ if (this.openFn)
50
+ this.ws?.removeEventListener("open", this.openFn);
51
+ if (this.msgFn)
52
+ this.ws?.removeEventListener("message", this.msgFn);
53
+ if (this.closeFn)
54
+ this.ws?.removeEventListener("close", this.closeFn);
55
+ if (this.errorFn)
56
+ this.ws?.removeEventListener("error", this.errorFn);
57
+ // Close old socket if still connecting or open
58
+ if (this.ws?.readyState === WebSocket.CONNECTING ||
59
+ this.ws?.readyState === WebSocket.OPEN) {
60
+ this.ws?.close();
61
+ }
62
+ // Clear any pending timers (this also removes abort listener from old controller)
63
+ this.clearTimers();
64
+ // Create new abort controller
35
65
  this.abortController = new AbortController();
36
- this.abortController.signal.addEventListener("abort", () => {
66
+ this.abortHandler = () => {
37
67
  if (this.ws?.readyState === WebSocket.CONNECTING) {
38
68
  this.ws.close();
39
69
  }
40
- });
70
+ };
71
+ this.abortController.signal.addEventListener("abort", this.abortHandler);
72
+ // Create new socket
41
73
  this.ws = new this.options.WebSocketConstructor(this.options.url);
42
74
  this.connectTimeout = setTimeout(() => {
43
75
  this.abortController?.abort();
44
76
  }, 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);
77
+ // Create and store new event handlers
78
+ // Capture the new socket reference to check against in handlers
79
+ const currentWs = this.ws;
80
+ this.openFn = (event) => {
81
+ // Only process if event is from the current socket
82
+ if (event.target === currentWs && this.ws === currentWs) {
83
+ this.clearTimers();
84
+ this.retryCount = 0;
85
+ this.emit("open", event);
86
+ // Emit reconnect event if this was a reconnection after a disconnection
87
+ if (this.wasConnected) {
88
+ this.emit("reconnect", event);
89
+ }
90
+ this.wasConnected = true;
91
+ this.startHealthCheck();
92
+ // Flush any queued messages now that socket is open
93
+ this.flushMessageQueue();
52
94
  }
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();
95
+ };
96
+ this.msgFn = (event) => {
97
+ // Only process if event is from the current socket
98
+ if (event.target === currentWs && this.ws === currentWs) {
99
+ this.emit("message", event);
100
+ }
101
+ };
102
+ this.closeFn = (event) => {
103
+ // Only process if event is from the current socket
104
+ if (event.target === currentWs && this.ws === currentWs) {
105
+ this.stopHealthCheck();
106
+ this.emit("close", { code: event.code, reason: event.reason });
107
+ if (!this.forcedClose) {
108
+ this.scheduleReconnect();
109
+ }
62
110
  }
63
- });
64
- this.ws.addEventListener("error", (event) => {
65
- this.emit("error", event);
66
- // Error will typically cause connection to close, triggering reconnect
67
- });
111
+ };
112
+ this.errorFn = (event) => {
113
+ // Only process if event is from the current socket
114
+ if (event.target === currentWs && this.ws === currentWs) {
115
+ this.emit("error", event);
116
+ }
117
+ };
118
+ // Add event listeners to new socket
119
+ currentWs.addEventListener("open", this.openFn);
120
+ currentWs.addEventListener("message", this.msgFn);
121
+ currentWs.addEventListener("close", this.closeFn);
122
+ currentWs.addEventListener("error", this.errorFn);
68
123
  }
69
124
  emit(event, payload) {
70
125
  for (const listener of this.listeners[event]) {
@@ -72,11 +127,44 @@ export class ReconnectingWebSocket {
72
127
  }
73
128
  }
74
129
  scheduleReconnect() {
130
+ // Clear any existing reconnect timeout first to prevent multiple reconnects
131
+ if (this.reconnectTimeout) {
132
+ clearTimeout(this.reconnectTimeout);
133
+ this.reconnectTimeout = undefined;
134
+ }
75
135
  const { retryDelay, backoffFactor, maxRetryDelay } = this.options;
76
136
  const delay = Math.min(retryDelay * Math.pow(backoffFactor, this.retryCount), maxRetryDelay);
77
137
  this.retryCount += 1;
78
138
  this.reconnectTimeout = setTimeout(() => this.connect(), delay);
79
139
  }
140
+ startHealthCheck() {
141
+ this.stopHealthCheck();
142
+ if (this.options.healthCheckInterval <= 0) {
143
+ return;
144
+ }
145
+ this.healthCheckInterval = setInterval(() => {
146
+ // Only check if we're not forcing a close and we expect to be connected
147
+ if (this.forcedClose) {
148
+ return;
149
+ }
150
+ // If we've been connected before and the socket is not OPEN, trigger reconnection
151
+ if (this.wasConnected && this.readyState !== WebSocket.OPEN) {
152
+ // Clear the existing socket reference since it's in a bad state
153
+ if (this.ws) {
154
+ // Don't emit close event since we didn't receive one - this is a silent failure
155
+ this.ws = undefined;
156
+ }
157
+ this.stopHealthCheck();
158
+ this.scheduleReconnect();
159
+ }
160
+ }, this.options.healthCheckInterval);
161
+ }
162
+ stopHealthCheck() {
163
+ if (this.healthCheckInterval) {
164
+ clearInterval(this.healthCheckInterval);
165
+ this.healthCheckInterval = undefined;
166
+ }
167
+ }
80
168
  clearTimers() {
81
169
  if (this.connectTimeout) {
82
170
  clearTimeout(this.connectTimeout);
@@ -86,9 +174,15 @@ export class ReconnectingWebSocket {
86
174
  clearTimeout(this.reconnectTimeout);
87
175
  this.reconnectTimeout = undefined;
88
176
  }
89
- if (this.abortController) {
177
+ if (this.abortController && this.abortHandler) {
178
+ this.abortController.signal.removeEventListener("abort", this.abortHandler);
90
179
  this.abortController = undefined;
180
+ this.abortHandler = undefined;
91
181
  }
182
+ else if (this.abortController) {
183
+ this.abortController = undefined;
184
+ }
185
+ this.stopHealthCheck();
92
186
  }
93
187
  addEventListener(event, listener) {
94
188
  this.listeners[event].push(listener);
@@ -97,12 +191,35 @@ export class ReconnectingWebSocket {
97
191
  this.listeners[event] = this.listeners[event].filter((l) => l !== listener);
98
192
  }
99
193
  send(...args) {
100
- this.ws?.send(...args);
194
+ if (this.ws?.readyState === WebSocket.OPEN) {
195
+ this.ws.send(...args);
196
+ }
197
+ else {
198
+ this.messageQueue.push(args);
199
+ }
200
+ }
201
+ flushMessageQueue() {
202
+ while (this.messageQueue.length > 0 &&
203
+ this.ws?.readyState === WebSocket.OPEN) {
204
+ const args = this.messageQueue.shift();
205
+ this.ws.send(...args);
206
+ }
101
207
  }
102
208
  close(...args) {
103
209
  this.forcedClose = true;
104
210
  this.clearTimers();
211
+ // Clear the message queue on forced close
212
+ this.messageQueue = [];
105
213
  if (this.ws) {
214
+ // Remove event listeners before closing to prevent memory leaks
215
+ if (this.openFn)
216
+ this.ws.removeEventListener("open", this.openFn);
217
+ if (this.msgFn)
218
+ this.ws.removeEventListener("message", this.msgFn);
219
+ if (this.closeFn)
220
+ this.ws.removeEventListener("close", this.closeFn);
221
+ if (this.errorFn)
222
+ this.ws.removeEventListener("error", this.errorFn);
106
223
  this.ws.close(...args);
107
224
  this.ws = undefined;
108
225
  }
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.2.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.1",
28
+ "@types/bun": "^1.3.3",
29
+ "@typescript-eslint/parser": "^8.48.1",
30
+ "eslint": "^9.39.1",
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
34
  "eslint-plugin-prettier": "^5.5.4",
34
- "globals": "^16.4.0",
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.7.3",
38
+ "typescript-eslint": "^8.48.1"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "typescript": "^5.8.3"