polfan-server-js-client 0.4.3 → 0.4.4
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/.idea/workspace.xml +22 -16
- package/README.md +20 -0
- package/build/index.cjs.js +172 -51
- package/build/index.cjs.js.map +1 -1
- package/build/index.umd.js +1 -1
- package/build/index.umd.js.map +1 -1
- package/build/types/WebSocketChatClient.d.ts +34 -2
- package/package.json +1 -1
- package/src/WebSocketChatClient.ts +157 -26
- package/tests/websocket-reconnect.test.ts +408 -0
|
@@ -16,14 +16,29 @@ export interface WebSocketClientOptions {
|
|
|
16
16
|
ping?: {
|
|
17
17
|
enabled?: boolean;
|
|
18
18
|
/**
|
|
19
|
-
* Time without activity after which a ping will be sent. Default is
|
|
19
|
+
* Time without activity after which a ping will be sent. Default is 15 seconds.
|
|
20
20
|
*/
|
|
21
21
|
noActivityTimeoutMs?: number;
|
|
22
22
|
/**
|
|
23
|
-
* Time to wait for a pong response before considering the connection dead. Default is
|
|
23
|
+
* Time to wait for a pong response before considering the connection dead. Default is 5 seconds.
|
|
24
24
|
*/
|
|
25
25
|
pongBackTimeoutMs?: number;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Automatic reconnection. After the connection is lost (error, connecting timeout, missing pong
|
|
29
|
+
* or closure with a code other than 1000) the client retries indefinitely, waiting between attempts
|
|
30
|
+
* with an exponential backoff. Calling `disconnect()` stops retrying until the next `connect()`.
|
|
31
|
+
*/
|
|
32
|
+
reconnect?: {
|
|
33
|
+
/**
|
|
34
|
+
* Delay before the first retry; each subsequent one doubles it. Default is 1 second.
|
|
35
|
+
*/
|
|
36
|
+
minDelayMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Upper limit of the delay between retries. Default is 30 seconds.
|
|
39
|
+
*/
|
|
40
|
+
maxDelayMs?: number;
|
|
41
|
+
};
|
|
27
42
|
}
|
|
28
43
|
declare enum WebSocketChatClientEvent {
|
|
29
44
|
connect = "connect",
|
|
@@ -50,14 +65,31 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
|
|
|
50
65
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
51
66
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
52
67
|
protected lastReceivedMessageAt?: number;
|
|
68
|
+
protected reconnectEnabled: boolean;
|
|
69
|
+
protected reconnectTimeoutId?: any;
|
|
70
|
+
protected reconnectAttempts: number;
|
|
53
71
|
constructor(options: WebSocketClientOptions);
|
|
54
72
|
connect(): Promise<void>;
|
|
55
73
|
disconnect(): void;
|
|
56
74
|
send<CommandType extends keyof CommandsMap>(commandType: CommandType, commandData: CommandRequest<CommandType>): Promise<CommandResult<CommandResponse<CommandType>>>;
|
|
57
75
|
get isReady(): boolean;
|
|
76
|
+
private openSocket;
|
|
58
77
|
private sendEnvelope;
|
|
59
78
|
private onMessage;
|
|
60
79
|
private onClose;
|
|
80
|
+
/**
|
|
81
|
+
* Abandon the current socket without waiting for its close event (which
|
|
82
|
+
* may never come, or take minutes on a dead TCP connection), settle
|
|
83
|
+
* everything that depended on it and schedule a retry when requested.
|
|
84
|
+
*/
|
|
85
|
+
private handleConnectionLoss;
|
|
86
|
+
/**
|
|
87
|
+
* Detach the current socket from the client and close it if it is still
|
|
88
|
+
* alive, together with all timers bound to it.
|
|
89
|
+
*/
|
|
90
|
+
private releaseSocket;
|
|
91
|
+
private scheduleReconnect;
|
|
92
|
+
private cancelScheduledReconnect;
|
|
61
93
|
/**
|
|
62
94
|
* Resolve (or reject, when an error is given) a pending connect() promise.
|
|
63
95
|
* No-op when there is nothing pending.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {ObservableInterface} from "./EventTarget";
|
|
2
2
|
import {AbstractChatClient, CommandRequest, CommandResult, CommandResponse, CommandsMap, EventsMap} from "./AbstractChatClient";
|
|
3
3
|
import {ChatStateTracker} from "./state-tracker/ChatStateTracker";
|
|
4
|
-
import {Envelope} from "./types/src";
|
|
4
|
+
import {Bye, Envelope} from "./types/src";
|
|
5
5
|
|
|
6
6
|
export interface WebSocketClientOptions {
|
|
7
7
|
url: string;
|
|
@@ -16,14 +16,29 @@ export interface WebSocketClientOptions {
|
|
|
16
16
|
ping?: {
|
|
17
17
|
enabled?: boolean;
|
|
18
18
|
/**
|
|
19
|
-
* Time without activity after which a ping will be sent. Default is
|
|
19
|
+
* Time without activity after which a ping will be sent. Default is 15 seconds.
|
|
20
20
|
*/
|
|
21
21
|
noActivityTimeoutMs?: number;
|
|
22
22
|
/**
|
|
23
|
-
* Time to wait for a pong response before considering the connection dead. Default is
|
|
23
|
+
* Time to wait for a pong response before considering the connection dead. Default is 5 seconds.
|
|
24
24
|
*/
|
|
25
25
|
pongBackTimeoutMs?: number;
|
|
26
26
|
},
|
|
27
|
+
/**
|
|
28
|
+
* Automatic reconnection. After the connection is lost (error, connecting timeout, missing pong
|
|
29
|
+
* or closure with a code other than 1000) the client retries indefinitely, waiting between attempts
|
|
30
|
+
* with an exponential backoff. Calling `disconnect()` stops retrying until the next `connect()`.
|
|
31
|
+
*/
|
|
32
|
+
reconnect?: {
|
|
33
|
+
/**
|
|
34
|
+
* Delay before the first retry; each subsequent one doubles it. Default is 1 second.
|
|
35
|
+
*/
|
|
36
|
+
minDelayMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Upper limit of the delay between retries. Default is 30 seconds.
|
|
39
|
+
*/
|
|
40
|
+
maxDelayMs?: number;
|
|
41
|
+
},
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
enum WebSocketChatClientEvent {
|
|
@@ -53,6 +68,9 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
53
68
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
54
69
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
55
70
|
protected lastReceivedMessageAt?: number;
|
|
71
|
+
protected reconnectEnabled: boolean = false;
|
|
72
|
+
protected reconnectTimeoutId?: any;
|
|
73
|
+
protected reconnectAttempts: number = 0;
|
|
56
74
|
|
|
57
75
|
public constructor(private readonly options: WebSocketClientOptions) {
|
|
58
76
|
super();
|
|
@@ -67,6 +85,10 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
67
85
|
}
|
|
68
86
|
|
|
69
87
|
public async connect(): Promise<void> {
|
|
88
|
+
this.reconnectEnabled = true;
|
|
89
|
+
// A manual call does not wait for a scheduled retry.
|
|
90
|
+
this.cancelScheduledReconnect();
|
|
91
|
+
|
|
70
92
|
if (this.isOpenWsState() || this.isConnectingWsState()) {
|
|
71
93
|
return this.connectPromise ?? undefined;
|
|
72
94
|
}
|
|
@@ -74,27 +96,33 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
74
96
|
// Reuse the promise of an attempt that has not settled yet (an
|
|
75
97
|
// automatic reconnect), so the caller that started connecting is
|
|
76
98
|
// resolved by whichever attempt eventually authenticates.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
if (! this.connectPromise) {
|
|
100
|
+
this.connectPromise = new Promise<void>((...args) => this.authenticatedResolvers = args);
|
|
101
|
+
// Automatic reconnects create this promise with no one awaiting it,
|
|
102
|
+
// so its rejection must not surface as an unhandled one. Callers
|
|
103
|
+
// awaiting it still receive the rejection.
|
|
104
|
+
this.connectPromise.catch(() => undefined);
|
|
105
|
+
}
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
this.ws.onmessage = ev => this.onMessage(ev);
|
|
85
|
-
this.connectingTimeoutId = setTimeout(
|
|
86
|
-
() => this.triggerConnectionTimeout(),
|
|
87
|
-
this.options.connectingTimeoutMs ?? 10000
|
|
88
|
-
);
|
|
89
|
-
this.authenticated = false;
|
|
107
|
+
const connectPromise = this.connectPromise;
|
|
108
|
+
this.openSocket();
|
|
90
109
|
|
|
91
|
-
return
|
|
110
|
+
return connectPromise;
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
public disconnect(): void {
|
|
114
|
+
const wasActive = this.ws !== null || this.reconnectTimeoutId !== undefined;
|
|
115
|
+
|
|
116
|
+
this.reconnectEnabled = false;
|
|
117
|
+
this.reconnectAttempts = 0;
|
|
118
|
+
this.cancelScheduledReconnect();
|
|
119
|
+
this.releaseSocket(1000); // Normal closure
|
|
95
120
|
this.failPendingCommands(new Error('Client disconnected before the command was answered'));
|
|
96
|
-
this.
|
|
97
|
-
|
|
121
|
+
this.settleConnect(new Error('Client disconnected before authentication'));
|
|
122
|
+
|
|
123
|
+
if (wasActive) {
|
|
124
|
+
this.emit(this.Event.disconnect, false);
|
|
125
|
+
}
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
public async send<CommandType extends keyof CommandsMap>(commandType: CommandType, commandData: CommandRequest<CommandType>):
|
|
@@ -115,6 +143,37 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
115
143
|
return this.isOpenWsState() && this.authenticated;
|
|
116
144
|
}
|
|
117
145
|
|
|
146
|
+
private openSocket(): void {
|
|
147
|
+
// Never leave a previous socket attached (e.g. one still closing).
|
|
148
|
+
this.releaseSocket(1000);
|
|
149
|
+
|
|
150
|
+
const params = new URLSearchParams(this.options.queryParams ?? {});
|
|
151
|
+
params.set('token', this.options.token);
|
|
152
|
+
|
|
153
|
+
let ws: WebSocket;
|
|
154
|
+
try {
|
|
155
|
+
ws = new WebSocket(`${this.options.url}?${params}`);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// Invalid URL - no retry can fix that.
|
|
158
|
+
this.settleConnect(error);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Events of an abandoned socket must not touch the current connection.
|
|
163
|
+
ws.onmessage = ev => ws === this.ws && this.onMessage(ev);
|
|
164
|
+
ws.onclose = ev => ws === this.ws && this.onClose(ev);
|
|
165
|
+
// Not every implementation follows a failed handshake with a close
|
|
166
|
+
// event (e.g. Node.js), so an error alone means the connection is lost.
|
|
167
|
+
ws.onerror = () => ws === this.ws && this.handleConnectionLoss(true);
|
|
168
|
+
|
|
169
|
+
this.ws = ws;
|
|
170
|
+
this.authenticated = false;
|
|
171
|
+
this.connectingTimeoutId = setTimeout(
|
|
172
|
+
() => this.triggerConnectionTimeout(),
|
|
173
|
+
this.options.connectingTimeoutMs ?? 10000
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
118
177
|
private sendEnvelope(envelope: Envelope): void {
|
|
119
178
|
if (this.isReady) {
|
|
120
179
|
this.ws.send(JSON.stringify(envelope));
|
|
@@ -139,36 +198,104 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
139
198
|
const isAuthenticated = envelope.type !== 'Bye';
|
|
140
199
|
this.authenticated = isAuthenticated;
|
|
141
200
|
if (isAuthenticated) {
|
|
201
|
+
this.reconnectAttempts = 0;
|
|
142
202
|
this.startConnectionMonitor();
|
|
143
203
|
this.settleConnect();
|
|
144
204
|
this.emit(this.Event.connect);
|
|
145
205
|
this.sendFromQueue();
|
|
146
206
|
} else {
|
|
147
207
|
this.settleConnect(envelope.data);
|
|
208
|
+
|
|
209
|
+
const error = (envelope.data as Bye)?.reason?.error;
|
|
210
|
+
if (error?.code === 'AuthenticationException') {
|
|
211
|
+
// Invalid token - retrying would be rejected the same way.
|
|
212
|
+
this.handleConnectionLoss(false);
|
|
213
|
+
this.emit(this.Event.error, new Error(`Authentication rejected: ${error.message}`));
|
|
214
|
+
}
|
|
148
215
|
}
|
|
149
216
|
}
|
|
150
217
|
}
|
|
151
218
|
|
|
152
219
|
private onClose(event: CloseEvent): void {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
220
|
+
// Connection was closed because of error
|
|
221
|
+
this.handleConnectionLoss(event.code !== 1000);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Abandon the current socket without waiting for its close event (which
|
|
226
|
+
* may never come, or take minutes on a dead TCP connection), settle
|
|
227
|
+
* everything that depended on it and schedule a retry when requested.
|
|
228
|
+
*/
|
|
229
|
+
private handleConnectionLoss(reconnect: boolean): void {
|
|
230
|
+
this.releaseSocket(reconnect ? 3000 : 1000);
|
|
156
231
|
|
|
157
232
|
// The server can no longer answer anything that was queued or in
|
|
158
233
|
// flight, so settle those promises instead of leaving them pending.
|
|
159
234
|
this.failPendingCommands(new Error('Connection closed before the command was answered'));
|
|
160
235
|
|
|
236
|
+
reconnect &&= this.reconnectEnabled;
|
|
237
|
+
|
|
161
238
|
if (reconnect) {
|
|
162
239
|
// Keep a pending connect() promise unsettled - the retry below is
|
|
163
240
|
// expected to authenticate and will resolve it.
|
|
164
|
-
|
|
241
|
+
this.scheduleReconnect();
|
|
165
242
|
} else {
|
|
243
|
+
this.reconnectEnabled = false;
|
|
166
244
|
this.settleConnect(new Error('Connection closed before authentication'));
|
|
167
245
|
}
|
|
168
246
|
|
|
169
247
|
this.emit(this.Event.disconnect, reconnect);
|
|
170
248
|
}
|
|
171
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Detach the current socket from the client and close it if it is still
|
|
252
|
+
* alive, together with all timers bound to it.
|
|
253
|
+
*/
|
|
254
|
+
private releaseSocket(closeCode: number): void {
|
|
255
|
+
this.stopConnectionMonitor();
|
|
256
|
+
clearTimeout(this.connectingTimeoutId);
|
|
257
|
+
this.connectingTimeoutId = undefined;
|
|
258
|
+
this.authenticated = false;
|
|
259
|
+
|
|
260
|
+
const ws = this.ws;
|
|
261
|
+
this.ws = null;
|
|
262
|
+
|
|
263
|
+
if (! ws) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ws.onmessage = ws.onclose = ws.onerror = null;
|
|
268
|
+
|
|
269
|
+
if (ws.readyState === ws.CONNECTING || ws.readyState === ws.OPEN) {
|
|
270
|
+
try {
|
|
271
|
+
ws.close(closeCode);
|
|
272
|
+
} catch {
|
|
273
|
+
// Nothing more can be done with a broken socket.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private scheduleReconnect(): void {
|
|
279
|
+
this.cancelScheduledReconnect();
|
|
280
|
+
|
|
281
|
+
const minDelay = Math.max(0, this.options.reconnect?.minDelayMs ?? 1000);
|
|
282
|
+
const maxDelay = Math.max(minDelay, this.options.reconnect?.maxDelayMs ?? 30000);
|
|
283
|
+
const delay = Math.min(maxDelay, minDelay * 2 ** Math.min(this.reconnectAttempts, 30));
|
|
284
|
+
this.reconnectAttempts++;
|
|
285
|
+
|
|
286
|
+
// Random jitter (50-100% of the delay) keeps clients from reconnecting
|
|
287
|
+
// in lockstep after a server restart.
|
|
288
|
+
this.reconnectTimeoutId = setTimeout(() => {
|
|
289
|
+
this.reconnectTimeoutId = undefined;
|
|
290
|
+
this.connect().catch(() => undefined);
|
|
291
|
+
}, delay / 2 + Math.random() * delay / 2);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private cancelScheduledReconnect(): void {
|
|
295
|
+
clearTimeout(this.reconnectTimeoutId);
|
|
296
|
+
this.reconnectTimeoutId = undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
172
299
|
/**
|
|
173
300
|
* Resolve (or reject, when an error is given) a pending connect() promise.
|
|
174
301
|
* No-op when there is nothing pending.
|
|
@@ -214,7 +341,7 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
214
341
|
}
|
|
215
342
|
|
|
216
343
|
private triggerConnectionTimeout(): void {
|
|
217
|
-
this.
|
|
344
|
+
this.handleConnectionLoss(true);
|
|
218
345
|
this.emit(this.Event.error, new Error('Connection timeout'));
|
|
219
346
|
}
|
|
220
347
|
|
|
@@ -227,6 +354,8 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
227
354
|
}
|
|
228
355
|
|
|
229
356
|
private startConnectionMonitor(): void {
|
|
357
|
+
this.stopConnectionMonitor();
|
|
358
|
+
|
|
230
359
|
if (!this.options.ping!.enabled) {
|
|
231
360
|
return;
|
|
232
361
|
}
|
|
@@ -244,11 +373,13 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
244
373
|
|
|
245
374
|
this.inFlightPingTimeout = setTimeout(() => {
|
|
246
375
|
this.inFlightPingTimeout = undefined;
|
|
247
|
-
|
|
376
|
+
// Closing a dead connection can hang in CLOSING for minutes,
|
|
377
|
+
// so drop it right away instead of waiting for the close event.
|
|
378
|
+
this.handleConnectionLoss(true);
|
|
248
379
|
}, this.options.ping.pongBackTimeoutMs);
|
|
249
380
|
|
|
250
381
|
// A rejection here means the connection dropped while the ping was
|
|
251
|
-
// in flight;
|
|
382
|
+
// in flight; the loss is already handled, so just stop waiting.
|
|
252
383
|
this.send('Ping', {}).catch(() => undefined).then(() => {
|
|
253
384
|
clearTimeout(this.inFlightPingTimeout);
|
|
254
385
|
this.inFlightPingTimeout = undefined;
|
|
@@ -266,4 +397,4 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
266
397
|
this.pingMonitorInterval = undefined;
|
|
267
398
|
}
|
|
268
399
|
}
|
|
269
|
-
}
|
|
400
|
+
}
|