polfan-server-js-client 0.4.2 → 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/shelf/Uncommitted_changes_before_Update_at_08_09_2026_17_04_[Changes]/shelved.patch +0 -0
- package/.idea/shelf/Uncommitted_changes_before_Update_at_08_09_2026_17_04__Changes_.xml +4 -0
- package/.idea/workspace.xml +49 -28
- package/README.md +20 -0
- package/build/index.cjs.js +192 -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/Permissions.d.ts +4 -0
- package/build/types/WebSocketChatClient.d.ts +34 -2
- package/build/types/index.d.ts +2 -2
- package/build/types/types/src/index.d.ts +3 -3
- package/build/types/types/src/schemes/Message.d.ts +10 -0
- package/build/types/types/src/schemes/Role.d.ts +14 -0
- package/build/types/types/src/schemes/commands/CreateRole.d.ts +1 -0
- package/build/types/types/src/schemes/commands/UpdateRole.d.ts +1 -0
- package/package.json +1 -1
- package/src/Permissions.ts +1 -0
- package/src/WebSocketChatClient.ts +157 -26
- package/src/index.ts +2 -1
- package/src/types/src/index.ts +4 -2
- package/src/types/src/schemes/Message.ts +11 -0
- package/src/types/src/schemes/Role.ts +16 -1
- package/src/types/src/schemes/commands/CreateRole.ts +1 -0
- package/src/types/src/schemes/commands/UpdateRole.ts +1 -0
- package/tests/space-roles.test.ts +5 -5
- package/tests/websocket-reconnect.test.ts +408 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import {WebSocketChatClient, WebSocketClientOptions} from "../src/WebSocketChatClient";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Connection lifecycle of the WebSocket client: every loss of the connection
|
|
5
|
+
* (error, connecting timeout, missing pong, closure with a code other than
|
|
6
|
+
* 1000) must lead to another attempt after a delay, forever, until
|
|
7
|
+
* `disconnect()` is called - without leaving any promise unsettled.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
class FakeWebSocket {
|
|
11
|
+
public static instances: FakeWebSocket[] = [];
|
|
12
|
+
|
|
13
|
+
public readonly CONNECTING = 0;
|
|
14
|
+
public readonly OPEN = 1;
|
|
15
|
+
public readonly CLOSING = 2;
|
|
16
|
+
public readonly CLOSED = 3;
|
|
17
|
+
|
|
18
|
+
public readyState = 0;
|
|
19
|
+
public onmessage: ((ev: any) => void) | null = null;
|
|
20
|
+
public onclose: ((ev: any) => void) | null = null;
|
|
21
|
+
public onerror: ((ev: any) => void) | null = null;
|
|
22
|
+
public readonly sent: any[] = [];
|
|
23
|
+
public closedWith?: number;
|
|
24
|
+
|
|
25
|
+
public constructor(public readonly url: string) {
|
|
26
|
+
FakeWebSocket.instances.push(this);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public send(data: string): void {
|
|
30
|
+
this.sent.push(JSON.parse(data));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public close(code: number): void {
|
|
34
|
+
this.closedWith = code;
|
|
35
|
+
// Like a dead TCP connection: the close event does not come (soon).
|
|
36
|
+
this.readyState = this.CLOSING;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public receive(type: string, data: any = {}, ref?: string): void {
|
|
40
|
+
this.onmessage?.({ data: JSON.stringify({ type, data, ref }) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public authenticate(): void {
|
|
44
|
+
this.readyState = this.OPEN;
|
|
45
|
+
this.receive('Session', {});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public serverClose(code: number): void {
|
|
49
|
+
this.readyState = this.CLOSED;
|
|
50
|
+
this.onclose?.({ code });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public fail(): void {
|
|
54
|
+
this.readyState = this.CLOSED;
|
|
55
|
+
this.onerror?.({});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sockets = () => FakeWebSocket.instances;
|
|
60
|
+
const lastSocket = () => sockets()[sockets().length - 1];
|
|
61
|
+
|
|
62
|
+
const createClient = (options: Partial<WebSocketClientOptions> = {}) => {
|
|
63
|
+
const client = new WebSocketChatClient({
|
|
64
|
+
url: 'ws://test',
|
|
65
|
+
token: 'token',
|
|
66
|
+
stateTracking: false,
|
|
67
|
+
connectingTimeoutMs: 5000,
|
|
68
|
+
reconnect: { minDelayMs: 1000, maxDelayMs: 8000 },
|
|
69
|
+
...options,
|
|
70
|
+
});
|
|
71
|
+
const disconnects: boolean[] = [];
|
|
72
|
+
const errors: string[] = [];
|
|
73
|
+
client.on('disconnect', reconnect => disconnects.push(reconnect));
|
|
74
|
+
client.on('error', error => errors.push(error.message));
|
|
75
|
+
return { client, disconnects, errors };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const originalWebSocket = (global as any).WebSocket;
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
FakeWebSocket.instances = [];
|
|
82
|
+
(global as any).WebSocket = FakeWebSocket;
|
|
83
|
+
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
|
84
|
+
// Upper bound of the jitter - makes the delays exact.
|
|
85
|
+
jest.spyOn(Math, 'random').mockReturnValue(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
jest.useRealTimers();
|
|
90
|
+
jest.restoreAllMocks();
|
|
91
|
+
(global as any).WebSocket = originalWebSocket;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('retries with a delay', () => {
|
|
95
|
+
test('failed attempts are spaced out with a growing, capped backoff', () => {
|
|
96
|
+
const { client } = createClient();
|
|
97
|
+
client.connect();
|
|
98
|
+
|
|
99
|
+
for (const delay of [1000, 2000, 4000, 8000, 8000, 8000]) {
|
|
100
|
+
const count = sockets().length;
|
|
101
|
+
lastSocket().serverClose(1006);
|
|
102
|
+
|
|
103
|
+
jest.advanceTimersByTime(delay - 1);
|
|
104
|
+
expect(sockets().length).toBe(count);
|
|
105
|
+
|
|
106
|
+
jest.advanceTimersByTime(1);
|
|
107
|
+
expect(sockets().length).toBe(count + 1);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('keeps retrying indefinitely', () => {
|
|
112
|
+
const { client } = createClient();
|
|
113
|
+
client.connect();
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < 200; i++) {
|
|
116
|
+
lastSocket().serverClose(1006);
|
|
117
|
+
// The scheduled retry is the only pending timer.
|
|
118
|
+
expect(jest.getTimerCount()).toBe(1);
|
|
119
|
+
jest.advanceTimersToNextTimer();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(sockets().length).toBe(201);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('an error without a following close event still leads to a retry', () => {
|
|
126
|
+
const { client, disconnects } = createClient();
|
|
127
|
+
client.connect();
|
|
128
|
+
|
|
129
|
+
sockets()[0].fail();
|
|
130
|
+
expect(disconnects).toEqual([true]);
|
|
131
|
+
|
|
132
|
+
jest.advanceTimersByTime(1000);
|
|
133
|
+
expect(sockets().length).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the backoff starts over after a successful connection', () => {
|
|
137
|
+
const { client } = createClient();
|
|
138
|
+
client.connect();
|
|
139
|
+
|
|
140
|
+
sockets()[0].serverClose(1006);
|
|
141
|
+
jest.advanceTimersByTime(1000);
|
|
142
|
+
sockets()[1].serverClose(1006);
|
|
143
|
+
jest.advanceTimersByTime(2000);
|
|
144
|
+
sockets()[2].authenticate();
|
|
145
|
+
|
|
146
|
+
sockets()[2].serverClose(1006);
|
|
147
|
+
jest.advanceTimersByTime(1000);
|
|
148
|
+
expect(sockets().length).toBe(4);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('a manual connect() during the backoff connects immediately, once', async () => {
|
|
152
|
+
const { client } = createClient();
|
|
153
|
+
client.connect();
|
|
154
|
+
sockets()[0].serverClose(1006);
|
|
155
|
+
|
|
156
|
+
const promise = client.connect();
|
|
157
|
+
expect(sockets().length).toBe(2);
|
|
158
|
+
|
|
159
|
+
jest.advanceTimersByTime(1000);
|
|
160
|
+
expect(sockets().length).toBe(2);
|
|
161
|
+
|
|
162
|
+
sockets()[1].authenticate();
|
|
163
|
+
await expect(promise).resolves.toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a jittered delay stays within 50-100% of the backoff', () => {
|
|
167
|
+
(Math.random as jest.Mock).mockReturnValue(0);
|
|
168
|
+
const { client } = createClient();
|
|
169
|
+
client.connect();
|
|
170
|
+
sockets()[0].serverClose(1006);
|
|
171
|
+
|
|
172
|
+
jest.advanceTimersByTime(499);
|
|
173
|
+
expect(sockets().length).toBe(1);
|
|
174
|
+
jest.advanceTimersByTime(1);
|
|
175
|
+
expect(sockets().length).toBe(2);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('connecting timeout', () => {
|
|
180
|
+
test('abandons the attempt, schedules a retry and connect() resolves once a later attempt succeeds', async () => {
|
|
181
|
+
const { client, disconnects, errors } = createClient();
|
|
182
|
+
const promise = client.connect();
|
|
183
|
+
const first = sockets()[0];
|
|
184
|
+
|
|
185
|
+
jest.advanceTimersByTime(5000);
|
|
186
|
+
|
|
187
|
+
expect(first.closedWith).toBe(3000);
|
|
188
|
+
expect(first.onclose).toBeNull();
|
|
189
|
+
expect(disconnects).toEqual([true]);
|
|
190
|
+
expect(errors).toEqual(['Connection timeout']);
|
|
191
|
+
|
|
192
|
+
jest.advanceTimersByTime(1000);
|
|
193
|
+
expect(sockets().length).toBe(2);
|
|
194
|
+
|
|
195
|
+
sockets()[1].authenticate();
|
|
196
|
+
await expect(promise).resolves.toBeUndefined();
|
|
197
|
+
expect(client.isReady).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('repeated timeouts keep retrying', () => {
|
|
201
|
+
const { client, errors } = createClient();
|
|
202
|
+
client.connect();
|
|
203
|
+
|
|
204
|
+
jest.advanceTimersByTime(5000 + 1000 + 5000 + 2000 + 5000 + 4000);
|
|
205
|
+
|
|
206
|
+
expect(errors).toEqual(['Connection timeout', 'Connection timeout', 'Connection timeout']);
|
|
207
|
+
expect(sockets().length).toBe(4);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('abandoned sockets', () => {
|
|
212
|
+
test('late events of an old socket do not affect the current connection', async () => {
|
|
213
|
+
const { client, disconnects } = createClient({ ping: { noActivityTimeoutMs: 3000, pongBackTimeoutMs: 1000 } });
|
|
214
|
+
client.connect();
|
|
215
|
+
const stale = sockets()[0];
|
|
216
|
+
const staleClose = stale.onclose!;
|
|
217
|
+
const staleMessage = stale.onmessage!;
|
|
218
|
+
|
|
219
|
+
jest.advanceTimersByTime(5000 + 1000); // timeout + retry
|
|
220
|
+
const current = sockets()[1];
|
|
221
|
+
current.authenticate();
|
|
222
|
+
disconnects.length = 0;
|
|
223
|
+
|
|
224
|
+
const command = client.send('GetSession', {});
|
|
225
|
+
staleClose({ code: 1006 });
|
|
226
|
+
staleMessage({ data: JSON.stringify({ type: 'Bye', data: {} }) });
|
|
227
|
+
|
|
228
|
+
expect(disconnects).toEqual([]);
|
|
229
|
+
expect(client.isReady).toBe(true);
|
|
230
|
+
|
|
231
|
+
const ref = current.sent.find(envelope => envelope.type === 'GetSession').ref;
|
|
232
|
+
current.receive('Session', { ok: true }, ref);
|
|
233
|
+
await expect(command).resolves.toEqual({ data: { ok: true }, error: null });
|
|
234
|
+
|
|
235
|
+
// The ping monitor of the current connection is still running.
|
|
236
|
+
jest.advanceTimersByTime(3000);
|
|
237
|
+
expect(current.sent.some(envelope => envelope.type === 'Ping')).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('ping monitor', () => {
|
|
242
|
+
test('a missing pong drops the connection immediately, without waiting for the close event', () => {
|
|
243
|
+
const { client, disconnects } = createClient({ ping: { noActivityTimeoutMs: 1000, pongBackTimeoutMs: 500 } });
|
|
244
|
+
client.connect();
|
|
245
|
+
const first = sockets()[0];
|
|
246
|
+
first.authenticate();
|
|
247
|
+
|
|
248
|
+
jest.advanceTimersByTime(1000);
|
|
249
|
+
expect(first.sent.map(envelope => envelope.type)).toEqual(['Ping']);
|
|
250
|
+
|
|
251
|
+
jest.advanceTimersByTime(500);
|
|
252
|
+
expect(first.closedWith).toBe(3000);
|
|
253
|
+
expect(first.readyState).toBe(first.CLOSING);
|
|
254
|
+
expect(disconnects).toEqual([true]);
|
|
255
|
+
|
|
256
|
+
jest.advanceTimersByTime(1000);
|
|
257
|
+
expect(sockets().length).toBe(2);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a received pong keeps the connection', () => {
|
|
261
|
+
const { client } = createClient({ ping: { noActivityTimeoutMs: 1000, pongBackTimeoutMs: 500 } });
|
|
262
|
+
client.connect();
|
|
263
|
+
const first = sockets()[0];
|
|
264
|
+
first.authenticate();
|
|
265
|
+
|
|
266
|
+
jest.advanceTimersByTime(1000);
|
|
267
|
+
first.receive('Pong', {}, first.sent[0].ref);
|
|
268
|
+
jest.advanceTimersByTime(400);
|
|
269
|
+
|
|
270
|
+
expect(first.closedWith).toBeUndefined();
|
|
271
|
+
expect(client.isReady).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('disconnect()', () => {
|
|
276
|
+
test('while connecting: rejects connect() and pending commands, no timeout and no retry follow', async () => {
|
|
277
|
+
const { client, disconnects, errors } = createClient();
|
|
278
|
+
const promise = client.connect();
|
|
279
|
+
const command = client.send('GetSession', {});
|
|
280
|
+
|
|
281
|
+
client.disconnect();
|
|
282
|
+
|
|
283
|
+
await expect(promise).rejects.toThrow('Client disconnected before authentication');
|
|
284
|
+
await expect(command).rejects.toThrow('Client disconnected before the command was answered');
|
|
285
|
+
expect(sockets()[0].closedWith).toBe(1000);
|
|
286
|
+
expect(disconnects).toEqual([false]);
|
|
287
|
+
|
|
288
|
+
jest.advanceTimersByTime(60000);
|
|
289
|
+
expect(errors).toEqual([]);
|
|
290
|
+
expect(sockets().length).toBe(1);
|
|
291
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('during the backoff: cancels the scheduled retry', async () => {
|
|
295
|
+
const { client, disconnects } = createClient();
|
|
296
|
+
const promise = client.connect();
|
|
297
|
+
sockets()[0].serverClose(1006);
|
|
298
|
+
|
|
299
|
+
client.disconnect();
|
|
300
|
+
|
|
301
|
+
await expect(promise).rejects.toThrow('Client disconnected before authentication');
|
|
302
|
+
expect(disconnects).toEqual([true, false]);
|
|
303
|
+
jest.advanceTimersByTime(60000);
|
|
304
|
+
expect(sockets().length).toBe(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('when connected: stops the ping monitor and never reconnects', () => {
|
|
308
|
+
const { client, disconnects } = createClient();
|
|
309
|
+
client.connect();
|
|
310
|
+
sockets()[0].authenticate();
|
|
311
|
+
|
|
312
|
+
client.disconnect();
|
|
313
|
+
|
|
314
|
+
expect(disconnects).toEqual([false]);
|
|
315
|
+
expect(client.isReady).toBeFalsy();
|
|
316
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('connect() after disconnect() enables reconnecting again', async () => {
|
|
320
|
+
const { client } = createClient();
|
|
321
|
+
const first = client.connect();
|
|
322
|
+
client.disconnect();
|
|
323
|
+
await expect(first).rejects.toThrow('Client disconnected before authentication');
|
|
324
|
+
|
|
325
|
+
client.connect();
|
|
326
|
+
sockets()[1].serverClose(1006);
|
|
327
|
+
jest.advanceTimersByTime(1000);
|
|
328
|
+
expect(sockets().length).toBe(3);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('without any connection does not emit anything', () => {
|
|
332
|
+
const { client, disconnects } = createClient();
|
|
333
|
+
client.disconnect();
|
|
334
|
+
expect(disconnects).toEqual([]);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('server closure', () => {
|
|
339
|
+
test('a normal closure (1000) does not reconnect and rejects a pending connect()', async () => {
|
|
340
|
+
const { client, disconnects } = createClient();
|
|
341
|
+
const promise = client.connect();
|
|
342
|
+
|
|
343
|
+
sockets()[0].serverClose(1000);
|
|
344
|
+
|
|
345
|
+
await expect(promise).rejects.toThrow('Connection closed before authentication');
|
|
346
|
+
expect(disconnects).toEqual([false]);
|
|
347
|
+
jest.advanceTimersByTime(60000);
|
|
348
|
+
expect(sockets().length).toBe(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('an invalid token rejects connect() and stops reconnecting, whatever the close code', async () => {
|
|
352
|
+
const { client, disconnects, errors } = createClient();
|
|
353
|
+
const promise = client.connect();
|
|
354
|
+
const bye = { reason: { type: 'Error', error: { code: 'AuthenticationException', message: 'Unauthorized' } } };
|
|
355
|
+
|
|
356
|
+
sockets()[0].readyState = 1;
|
|
357
|
+
sockets()[0].receive('Bye', bye);
|
|
358
|
+
|
|
359
|
+
await expect(promise).rejects.toEqual(bye);
|
|
360
|
+
expect(sockets()[0].closedWith).toBe(1000);
|
|
361
|
+
expect(disconnects).toEqual([false]);
|
|
362
|
+
expect(errors).toEqual(['Authentication rejected: Unauthorized']);
|
|
363
|
+
|
|
364
|
+
// A late close of the rejected socket (e.g. 1006 through a proxy) is ignored.
|
|
365
|
+
sockets()[0].onclose?.({ code: 1006 });
|
|
366
|
+
jest.advanceTimersByTime(60000);
|
|
367
|
+
expect(sockets().length).toBe(1);
|
|
368
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('an invalid token during an automatic reconnect stops retrying', () => {
|
|
372
|
+
const { client, disconnects } = createClient();
|
|
373
|
+
client.connect();
|
|
374
|
+
sockets()[0].authenticate();
|
|
375
|
+
|
|
376
|
+
sockets()[0].serverClose(1006);
|
|
377
|
+
jest.advanceTimersByTime(1000);
|
|
378
|
+
sockets()[1].readyState = 1;
|
|
379
|
+
sockets()[1].receive('Bye', { reason: { type: 'Error', error: { code: 'AuthenticationException', message: 'Unauthorized' } } });
|
|
380
|
+
|
|
381
|
+
expect(disconnects).toEqual([true, false]);
|
|
382
|
+
jest.advanceTimersByTime(60000);
|
|
383
|
+
expect(sockets().length).toBe(2);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('a rejected automatic attempt does not cause an unhandled rejection', async () => {
|
|
387
|
+
const unhandled: any[] = [];
|
|
388
|
+
const listener = (reason: any) => unhandled.push(reason);
|
|
389
|
+
process.on('unhandledRejection', listener);
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const { client } = createClient();
|
|
393
|
+
await Promise.all([client.connect(), sockets()[0].authenticate()]);
|
|
394
|
+
|
|
395
|
+
sockets()[0].serverClose(1006);
|
|
396
|
+
jest.advanceTimersByTime(1000);
|
|
397
|
+
sockets()[1].readyState = 1;
|
|
398
|
+
sockets()[1].receive('Bye', { reason: 'Unauthorized' });
|
|
399
|
+
client.disconnect();
|
|
400
|
+
|
|
401
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
402
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
403
|
+
expect(unhandled).toEqual([]);
|
|
404
|
+
} finally {
|
|
405
|
+
process.off('unhandledRejection', listener);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
});
|