@iam4x/reconnecting-websocket 1.0.1 → 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 +11 -0
- package/dist/index.js +142 -25
- package/package.json +8 -7
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.
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
if (this.
|
|
51
|
-
this.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
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?.
|
|
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.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.
|
|
28
|
-
"@
|
|
29
|
-
"eslint": "^
|
|
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.
|
|
35
|
+
"globals": "^16.5.0",
|
|
35
36
|
"husky": "^9.1.7",
|
|
36
|
-
"prettier": "^3.
|
|
37
|
-
"typescript-eslint": "^8.
|
|
37
|
+
"prettier": "^3.7.3",
|
|
38
|
+
"typescript-eslint": "^8.48.1"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
41
|
"typescript": "^5.8.3"
|