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.
@@ -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 10 seconds.
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 2 seconds.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "polfan-server-js-client",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -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 10 seconds.
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 2 seconds.
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
- this.connectPromise ??= new Promise<void>((...args) => this.authenticatedResolvers = args);
78
-
79
- const params = new URLSearchParams(this.options.queryParams ?? {});
80
- params.set('token', this.options.token);
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
- this.ws = new WebSocket(`${this.options.url}?${params}`);
83
- this.ws.onclose = ev => this.onClose(ev);
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 this.connectPromise;
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.ws?.close(1000); // Normal closure
97
- this.ws = null;
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
- this.stopConnectionMonitor();
154
- clearTimeout(this.connectingTimeoutId);
155
- const reconnect = event.code !== 1000; // Connection was closed because of error
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
- void this.connect();
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.disconnect();
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
- this.ws.close(3000); // Service Restart (reconnect)
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; onClose already handles that, so just stop waiting.
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
+ }